java.util.concurrent
패키지는 실행자 프레임워크(Executor Framework)라고 하는 인터페이스 기반의 유연한 태스크 실행 기능을 담고 있다.
이것을 이용하면, 다음의 아주 짧은 코드로 작업 큐를 사용할 수 있다.
// 작업 큐를 생성한다.
ExecutorService exec = Executors.newSingleThreadExecutor();
// 실행자에 실행할 태스크를 넘긴다.
exec.execute(runnable);
// 실행자를 종료시킨다. 이 작업이 실패하면 VM 자체가 종료되지 않는다.
exec.shutdown();
실행자 서비스의 주요 기능은 다음과 같다.
큐를 둘 이상의 스레드가 처리하게 하고 싶다면 간단히 다른 정적 팩터리를 이용하여 다른 종류의 실행자 서비스(스레드 풀)을 생성하면 된다. 우리에게 필요한 실행자 대부분은 java.util.concurrent.Executors
의 정적 팩터리들을 이용해 생성할 수 있을 것이다.
평범하지 않은 실행자를 원한다면 ThreadPoolExecutor
클래스를 직접 사용해도 된다. 이 클래스로는 스레드 풀 동작을 결정하는 거의 모든 속성을 설정할 수 있다.
작은 프로그램이나 가벼운 서버라면 Executors.newCachedThreadPool
가 일반적으로 좋은 선택일 것이다. 특별히 설정할 게 없고 일반적인 용도에 적합하게 동작한다.
하지만, CachedThreadPool
은 무거운 프로덕션 서버에는 좋지 못하다! CachedThreadPool 에서는 요청받은 태스크들이 큐에 쌓이지 않고 즉시 스레드에 위임돼 실행된다. 이때 가용한 스레드가 없다면 새로 하나를 생성한다. 서버가 아주 무겁다면 CPU 사용률이 100%로 치닫고, 새로운 태스크가 도착하는 족족 또 다른 스레드를 생성하며 상황을 더욱 악화시킨다. 따라서 무거운 프로덕션 서버에서는 스레드 개수를 고정한 Executors.newFixedThreadPool
을 선택하거나 완전히 통제할 수 있는 ThreadPoolExecutor
를 직접 사용하는 편이 훨씬 낫다.
작업 큐를 손수 만들거나, 스레드를 직접 다루는 일은 일반적으로 삼가향 한다. 스레드를 직접 다루면 Thread 가 작업 단위와 수행 메커니즘 역할을 모두 수행하게 된다.
반면 실행자 프레임워크에서는 작업 단위와 실행 메커니즘이 분리된다. 작업 단위를 나타내는 핵심 추상 개념이 태스크다. 태스크에는 Runnable
과 그 사촌인 Callable
, 2가지가 있다. Callable
은 Runnable
과 비슷하지만 값을 반환하고 임의의 예외를 던질 수 있다.
그리고 태스크를 수행하는 일반적인 메커니즘이 바로 실행자 서비스다. 태스크 수행을 실행자 서비스에게 맡기면 원하는 태스크 수행 정책을 선택할 수 있고, 언제든 변경할 수 있다. 핵심은 실행자 프레임워크가 작업 수행을 담당해준다는 것이다.
자바 7이 되면서, 실행자 프레임워크는 포크-조인(fork-join) 태스크를 지원하도록 확장되었다. 포크-조인 태스크는 포크-조인 풀이라는 특별한 실행자 서비스가 실행해준다. ForkJoinTask
의 인스턴스는 작은 하위 태스크로 나뉠 수 있고, ForkJoinPool
을 구성하는 스레드들이 이 태스크들을 처리하며, 일을 먼저 끝낸 스레드는 다른 스레드의 남은 태스크를 가져와 대신 처리할 수도 있다. 이렇게 하여 모든 스레드가 바쁘게 움직여 CPU를 최대한 활용하면서 높은 처리량과 낮은 지연시간을 달성한다.
포크-조인 태스크를 직접 작성하고 튜닝하는 것은 어렵지만, 포크-조인 풀을 이용해 만든 병렬 스트림을 이용하면 적은 노력으로 그 이점을 얻을 수 있다.