클로저 future 함수의 동작방식
2023년 04월 27일
Clojure
📕 목차
future란?
(future (do-something)) ;; 별도 스레드로 작업 위임
클로저에는 작업을 별도 스레드에 위임해서 비동기로 실행할 수 있는 future
라는 함수가 있습니다. future 함수를 사용하면 기존의 작업 흐름에서 별도 스레드로 작업을 격리시켜 작업을 수행할 수 있기때문에 알림 발송 등 메인 비즈니스 로직이 아닌 작업을 위해 유용하게 사용할 수 있습니다.
이러한 future 함수를 사용하다보니 다음과 같은 의문이 생겼습니다. future 함수를 호출하면 계속해서 스레드를 생성할까?
그래서, 이 함수를 사용할 때 내부적으로 스레드가 어떻게 생성되고 처리되는지 알아보기로 했습니다.
future 내부 동작 방식
먼저 함수 소스코드를 보면서, 어떠한 방식으로 동작하는지 알아보겠습니다. future 함수는 다음과 같이 정의되어 있습니다.
(defmacro future
[& body] `(future-call (^{:once true} fn* [] ~@body)))
future 함수는 (fn* [] ~@body)
형태의 함수를 만들어서 future-call
함수를 호출합니다.
fn*은 클로저에서 fn과 비슷한 역할을 하는 특별한 함수입니다. 매크로 내부에서 함수를 정의하기 위해서 사용합니다.
(defn future-call
[f]
(let [f (binding-conveyor-fn f) ;; 1
fut (.submit clojure.lang.Agent/soloExecutor ^Callable f)] ;; 2
future-call 함수는 두 가지 부분으로 나누어서 볼 수 있습니다. 첫 번째는 binding-conveyor-fn 함수를 호출하는 부분과 soloExecutor에 작업을 전달하는 부분입니다.
1. binding-conveyor-fn의 역할
별도의 스레드에서 작업을 처리할때 현재 스레드의 값들을 참조해야할 수도 있는데, 이를 위해 현재 스레드에 바인딩된 값들을 새로운 작업을 격리할 스레드로 복사해서 새로운 스레드에서 사용할 수 있게 해줍니다.
이 함수는 내부적으로 cloneThreadBindingFrame
함수를 사용합니다. 이 함수(cloneThreadBindingFrame)는 현재 스레드의 ThreadLocal을 가져와 복제를 해줍니다. 이렇게 복제된 ThreadLocal은 작업을 위임한 스레드에서 사용할 수 있습니다.
2. soloExecutor에 작업 전달
다른 스레드에서 작업을 진행하기 위해 앞서, 기존 스레드에 바인딩된 값들을 복제하는 과정을 진행했습니다. 이제, 복제된 값들을 가지고 별도의 스레드에서 작업을 수행할 수 있도록 soloExecutor에 작업을 전달합니다. 이 soloExecutor가 관리하는 스레드 풀에서 작업을 수행합니다.
clojure.lang.Agent/soloExecutor
volatile public static ExecutorService soloExecutor = Executors.newCachedThreadPool(
createThreadFactory("clojure-agent-send-off-pool-%d", sendOffThreadPoolCounter));
soloExecutor는 clojure.lang.Agent 클래스의 정적 필드로 선언되어 있습니다. 이 soleExecutor는 newCachedThreadPool
을 사용하는데 이 스레드 풀 클래스는 다음과 같이 동작합니다.
스레드 풀에 유휴상태(idle)인 스레드가 있다면 해당 스레드를 재사용
- 스레드 풀의 스레드를 전부 사용하고 있다면, 스레드를 새로 생성해서 작업을 할당합니다. 이때 스레드 생성을 위해 createThreadFactory를 사용합니다.
- 짧은 생명 주기의 비동기 작업들을 실행하는 프로그램의 성능을 향상시키기 위해 사용
- 스레드가 60초동안 사용되지 않으면 종료 후 캐시에서 제거
요약하자면, 유휴상태인 스레드가 있다면 재사용하고, 스레드 풀에 모든 스레드를 사용하고 있다면 새로 스레드를 생성해서 사용합니다.
이러한 특징으로 인해 짧은 생명 주기를 가지는 비동기 작업들을 future로 처리할 때 스레드를 재사용해서 유용하게 사용할 수 있습니다.
private static ThreadFactory createThreadFactory(final String format, final AtomicLong threadPoolCounter) {
return new ThreadFactory() {
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable);
thread.setName(String.format(format, threadPoolCounter.getAndIncrement()));
return thread;
}
};
}
새로운 스레드를 생성할때는 createThreadFactory를 사용해서 생성합니다.
그렇다면 이 스레드 풀은 계속해서 스레드를 생성할 수 있을까요? 정답은 X 입니다. 이 답은 newCachedThreadPool의 내부 동작을 통해 알아볼 수 있습니다.
newCachedThreadPool은 내부적으로 ThreadPoolExecutor를 통해서 스레드 풀을 생성합니다. 이 ThreadPoolExecutor는 다음과 같은 설정으로 스레드 풀을 생성합니다.
- corePoolSize: 0
- maximumPoolSize: Integer.MAX_VALUE
- keepAliveTime: 60초
- workQueue: SynchronousQueue
위의 설정을 보면 스레드는 최대 Integer.MAX_VALUE의 값 만큼 생성할 수 있고, 스레드의 유휴시간인 60초는 keepAliveTime으로 설정되어있는 것을 볼 수 있습니다. 작업 큐는 SynchronousQueue를 사용한 것을 볼 수 있는데, 이는 별도의 스레드에서 처리한 작업의 결과를 원래 스레드가 가져갈 수 있어야 하기 때문에 사용합니다.
SynchronousQueue: A blocking queue in which each insert operation must wait for a corresponding remove operation by another thread, and vice versa
세줄 요약
- future 함수는 스레드 풀에 유휴상태인 스레드가 있다면 스레드를 재사용하고, 없다면 생성합니다.
- 스레드 풀은 soloExecutor라는 것을 사용하고 내부적으로 newCachedThreadPool을 사용합니다.
- 짧은 비동기성 작업들을 처리할 때 성능 향상을 기대해볼 수 있습니다.