클로저 future 함수의 동작방식

2023년 04월 27일

Clojure

# clojure# future# newCachedThreadPool# 스레드 풀# 비동기 작업

📕 목차

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

세줄 요약

  1. future 함수는 스레드 풀에 유휴상태인 스레드가 있다면 스레드를 재사용하고, 없다면 생성합니다.
  2. 스레드 풀은 soloExecutor라는 것을 사용하고 내부적으로 newCachedThreadPool을 사용합니다.
  3. 짧은 비동기성 작업들을 처리할 때 성능 향상을 기대해볼 수 있습니다.

참고자료

profile

박민기

단순하게 살아라. 현대인은 쓸데없는 절차와 일 때문에 얼마나 복잡한 삶을 살아가는가? - 이드리스 샤흐

© 2023, 미나리와 함께 만들었음