우리는 프로그램을 개발할 때 다양한 이유로 쓰레드를 생성하곤 한다. 그것은 비동기 처리의 일환일 수도 있고, 연산 효율을 높이기 위함일 수도 있다. 그러나 쓰레드를 계속하여 생성하고 회수하는 것은, 시스템적으로 오버헤드가 상당히 큰 작업이다.
쓰레드를 한 번 생성할 때마다 OS 가 해당 쓰레드를 위한 메모리 영역 (스택 등) 을 확보해주고, 쓰레드가 더이상 필요 없을 땐 다시 이 메모리 영역을 회수하는 작업이 일어난다. 이는 상당히 비용이 큰 작업이기 때문에, 쓰레드를 계속하여 생성하고 수거했다간 프로그램의 퍼포먼스에 분명히 영향을 끼치게 되어있다.
그렇다고 '음.. 걍 쓰레드 많이 만들지 마세요 ㅋㅋ' 할 순 없는 노릇이다. 그래서 등장하게 된 아이디어는 '여러 쓰레드를 미리 만들어두고 작업이 들어올 때마다 쓰레드들에게 작업을 적절히 분배해주자'는 것이다.
작업 들어올 때마다 쓰레드 생성 (X)
작업 들어올 때마다 미리 만들어져 있는 쓰레드들 중 하나에 작업 할당 (O)
이렇게 되면 쓰레드 생성 및 수거에 대한 오버헤드를 대폭 줄일 수 있기 때문에, 꽤나 효율적인 대안이다. 따라서 자바에서는 이를 적극 지원하기 위해 ExecutorService
라는 인터페이스와 Executor
클래스를 제공한다. 이들을 사용하여 쓰레드 풀을 구현할 수 있다.
아래 그림으로 대략적인 동작을 유추해볼수 있다. Thread Pool 에 많은 쓰레드를 미리 생성해두고, 새로운 작업이 들어올 때마다 Task Queue 에 이를 Enqueue 한다. 그리고 작업을 큐에서 하나씩 꺼내어 적절한 쓰레드로 할당하게 된다. 그리고 만약 작업이 끝나면, 이를 콜백 형태로 작업을 요청한 주체에게 결과를 알려준다.
쓰레드에 작업을 할당하는 동작을 조금 더 명료하게 나타낸 그림도 첨부한다. 새로운 작업을 처리할 때 적절한 쓰레드를 선택하여 해당 작업을 할당한다는 의미는, 작업을 수행하지 않고 있는 쓰레드에 작업을 할당한다는 뜻으로 받아들여도 될 것같다.
앞서말했던 것처럼, 쓰레드를 계속하여 생성하고 수거하는 것은 비용이 상당히 큰 작업이다. 따라서 쓰레드 풀을 사용하게 되면 비용이 대폭 줄어들어 성능 저하를 방지할 수 있다.
이곳 저곳에서 작업 요청이 들어올 수 있다. 서비스 측면에서 빗대어 본다면, 여러 사용자들이 한 번에 작업 요청을 보낼 때 쓰레드풀을 사용한다면 빠르고 효율적으로 동시 작업을 수행할 수 있다.
그런데 결국 마냥 좋은 것도 아니다. 쓰레드 풀이 가지는 단점에 대해 살펴보자면 아래와 같다.
계속 말하지만 쓰레드 풀은 일정 가량 쓰레드를 미리 만들어두고 이들을 적절히 활용하는 솔루션이다. 결국 얼만큼 쓰레드를 만들어 둘지에 대해 결정해야 하는데, 이 과정에서 '에잇 언젠간 다 쓰겠지 머' 하고 너무 많은 쓰레드를 생성해두게 되면 메모리만 차지하고 아무것도 하지 않는 쓰레드가 존재할 가능성이 높아진다.
위 단점과는 조금 다르다. 병렬적으로 A, B, C 쓰레드가 작업을 처리하는 과정에서, 각각 분배된 작업의 소요시간이 서로 다른 경우, A 는 아직 허덕이고 있는데 나머지는 '음 쟤 고생하네 ㅋㅋ'하면서 유휴 시간이 발생하는 상황이 발생할 수 있다. 유휴한 쓰레드들이 A 의 작업을 조금만 도와준다면 좋을텐데.
자바에선 이러한 상황을 방지하기 위해 forkJoinPool
이라는 것을 지원한다.
ExecutorService executorService = Executors.newSingleThreadExecutor();
ExecutorService executorService = Executors.newFixedThreadPool(int nThreads);
ExecutorService executorService = Executors.newCachedThreadPool();
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(int corePoolSize);
자바 8에서 새로 생긴 녀석
쓰레드를 동적으로 늘리고 줄일 수 있음
요청된 병렬 동작을 지원할만큼 충분한 쓰레드를 유지하고, 쓰레드마다 독립적인 큐를 사용
따라서 작업 큐가 비어있는 쓰레드가 존재한다면, 다른 쓰레드의 작업 큐에서 작업을 훔쳐(?)올 수 있음
놀고 먹는 쓰레드에게 다른 쓰레드의 일을 좀 도와줄 수 있도록 함
위 특성때문에, 작업이 실행되는 순서를 보장하진 않음. (큐잉 순서와 무관하게 동작)
ExecutorService executorService = Executors.newWorkStealingPool(int parallelism);
쓰레드 풀은 알게 모르게 상당히 유용하게 사용되고 있다. 우리에게 가장 친숙한(?) 녀석으로는 RxJava
가 있다. Observable 데이터 스트림의 메소드인 subscribeOn()
을 통해 해당 데이터 스트림이 어떤 쓰레드에서 데이터를 발행할지 지정해줄수 있다. 이 때 매번 쓰레드를 생성하여 데이터 스트림을 할당하는 것이 아닌, 쓰레드 풀에서 적절한 쓰레드를 선택하여 작업을 할당해주게 된다.
https://limkydev.tistory.com/55
https://en.wikipedia.org/wiki/Thread_pool