본문 바로가기

Reading/Effective Java

[Effective-Java] Item 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라

병렬 작업 처리가 많아지면 스레드 개수가 증가되고 그에 따라 스레드 생성과 스케줄링으로 인해 CPU가 바빠져 메모리 사용량이 늘어나고 이는 애플리케이션 성능을 저하시킨다. 이런 갑작스런 병렬 작업 폭증이 야기하는 스레드 폭증을 막으려면 ThreadPool을 사용해야 한다. 

 

스레드풀(ThreadPool)


작업 처리에 사용되는 스레드의 개수를 제한해 두고 작업 Queue에 들어오는 작업들을 하나씩 스레드가 맡아 처리한다. 처리가 끝난 스레드는 다시 작업 Queue에서 새 작업을 가져와 처리한다. 그래서 작업이 폭증돼도 스레드 전체 개수가 늘지 않아 급격한 성능 저하가 없다.

java.util.concurrent 패키지는 실행자 프레임워크(Executor Framework)라는 인터페이스 기반의 유연한 태스크 실행 기능을 담고있다.

 

ExecutorService Interface


실행자 서비스는 인터페이스로, task의 progress를 제어하고 서비스를 종료할 수 있는 많은 메소드들을 포함하고 있다. 

 

메소드명 리턴타입 설명
shutdown() void 현재 처리 중인 작업작업 큐에 대기하고 있는 모든 작업을 처리한 후 스레드풀을 종료시킨다.
shutdownNow() List<Runnable> 현재 처리 중인 스레드를 interrupt해서 작업 중지를 시도하고 스레드 풀을 종료시킨다. 모든 스레드가 동시에 중단되는 것은 보장하지 않는다. 리턴값은 작업 큐에 있는 미처리된 Runnable의 List다.
awaitTermination(long timeout, TimeUnit unit) boolean shutdown 요청 후 모든 작업 처리를 timeout 시간내에 완료하면 true, 완료를 못하면 작업 처리 중인 스레드를 interrupt하고 false 리턴
isShutdown() boolean shutdown 메소드가 이전에 실행됐다면 true를 반환
isTerminated() boolean shutdown 메소드 실행 후 모든 task가 완료되었다면 true를 반환(shutdwonNow 메소드라면 현재 동작중인 스레드를 정상적으로 종료가 다 되어야한다)
submit(Callable<T> task) Future<T> - Runnable/Callable을 작업 큐에 저장한다.
- Runnable만 파라미터로 넘길 경우 null을 반환받을 수 있다.
submit(Runnable task, T result) Future<T>
submit(Runnable task) Future<?>
execute(Runnable command) void Runnable을 작업 큐에 저장한다.
invokeAll(Collection<? extends Callable<T>> tasks) List<Future<T>> - callable의 Collection을 수행하도록 작업 큐에 넣는다.
- 모든 작업 수행 결과를 List로 반환한다.
- timeout 파라미터를 보낸다면, 시간 초과시 Future 객체는 done은 되었지만, cancel된다.
invokeAll(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) List<Future<T>>
invokeAny(Collection<? extends Callable<T>> tasks) T - task의 Collection을 수행하도록 작업 큐에 넣는다.
- 성공적인 작업 수행 결과 중 하나를 반환한다.(callable의 결과)
- timeout 파라미터를 보낸다면, 시간이 초과한다면 TimeoutException을 던진다.
invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) T

 

submit() vs execute()

execute()는 작업 처리 도중 예외 발생시 스레드가 종료되고 해당 스레드는 스레드 풀에서 제거하고 다른 작업 처리를 위해 새로운 스레드를 생성한다. 반면 submit()은 예외가 발생하더라도 스레드는 종료되지 않고 다음 작업을 위해 재사용된다. 따라서 스레드의 생성 오버헤드를 줄이기 위해선 submit()을 사용하는게 낫다. 

execute()는 Executor 인터페이스의 메소드고 ExecutorService가 이를 확장했으니 둘의 차이는 있고, 리턴값이 있냐 없냐도 다르다.

 

ExecutorCompletionService 활용

완료된 태스크들의 결과를 차례로 받을 수 있다. ExecutorCompletionService를 활용하면 된다. invokeAll은 Future가 반환되는 순이지만 완료된 task들의 결과를 차례대로 받는다는 차이가 있다. 매개변수로 Executor를 활용하고 ExecutorService는 Executor를 확장했기 때문에 생성자에서 ExecutorService를 넘겨주면 된다.

 

메소드명 리턴타입 설명
poll() Future<V> 공통: 완료된 작업의 Future를 가져온다.
poll(): 완료된 작업이 없다면 즉시 null return
poll(timeout,unit): 완료된 작업이 없다면 timeout까지 블로킹
take(): 완료된 작업이 없다면 있을때까지 블로킹
poll(long timeout, TimeUnit unit) Future<V>
take() Future<V>
submit(Callable<v> task) Future<V> 스레드풀에 Callable 작업 처리 요청
submit(Runnable task, V result) Future<V> 스레드에 Runnable 작업 처리 요청

 

예제는 참고에서 확인하자.

 

 

Future Interface


ExecutorService에서 invokeAll, submit 메소드는 Future를 반환하는 것을 알 수 있다. Future는 비동기 계산의 결과를 나타낸다. 따라서 작업이 완료될 때까지 지연됐다가 최종 결과를 얻는데 사용한다. 즉, 블로킹을 사용하는 작업 완료 통보 방식이다. 메소드를 알아보자

 

Future<V>

메소드명 리턴타입 설명
get() V 작업이 완료될 때까지 블로킹되었다가 결과 V를 리턴한다
get(long timeout, TimeUnit unit) V timeout 전에 작업이 완료되면 결과 V를 리턴, 완료되지 않으면 TimeoutException을 던진다.
cancel(boolean mayInterruptIfRunning) boolean - 작업이 시작하기 전이라면 정상적으로 취소가 되고, 작업이 시작했다면 mayInterruptIfRunning 매개변수를 통해 제어할 수 잇다.
- 작업이 시작했고 매개변수가 true라면 중단할 수 있고, false라면 중단하지 않는다.
- 작업이 이미 정상적으로 완료되었기 때문에 취소할 수 없으면 false이고 그렇지 않으면 true를 반환한다.
- 이 메소드 반환 후 isCanceled()와 isDone()은 항상 true다. 
isCanceled() boolean 작업이 정상적으로 완료되기 전에 취소된 경우 true를 반환한다.
isDone() boolean 작업이 완료되면 true를 반환한다.
이 때, 예외가 발생하거나, 취소를 했더라도 작업은 완료된 것이다.

 

블로킹 방식이기 때문에, 스레드가 작업을 완료하기 전까진 get() 메소드가 blocking 되므로 다른 코드를 실행할 수 없다. 따라서 get() 메소드를 호출하는 스레드는 새로운 스레드거나 스레드풀의 또 다른 스레드라면 기다리면서 다른 작업을 할 수 있다.

 

implements ExecutorService


ExecutorService는 결국 인터페이스기 때문에 구현체를 이용해야한다. 대표적으로 생성할 수 있는 방법을 알아보자.

 

ExecutorService 구현체 생성자 사용

가장 대표적으로 ThreadPoolExecutor가 있다. ThreadPoolExecutor은 확장 가능한 ThreadPool 구현체로, 더 나은 튜닝을 위한 많은 매개변수와 후크를 가진다. 주요 구성 매개 변수는 다음과 같다.

 

corePoolSize: 스레드 수가 증가된 후 사용되지 않는 스레드를 스레드 풀에서 제거할 때 최소한 유지해야 할 스레드 수

maximumPoolSize: 스레드풀에서 관리하는 최대 스레드 수 

keepAliveTime: 스레드가 아무 일도 하지 않을 때 생존해있을 수 있는 시간. 기본적으로 corePoolSize보다 많은 스레드가 생성되어 아무 일도 하지 않으면 이 경우 non-coreThread이고 이 스레드들이 keepAliveTime을 초과하면 제거한다. core-Thread에도 동일한 정책을 설정할 수 있는데 allowCoreThreadTimeOut 메소드를 활용하면 된다. 기본 값은 false임으로, true로 설정한다면 corePoolSize도 아무 일도 하지 않는 다면 제거될 수 있다. 단, keepAliveTime이 너무 작을 경우 지속적인 스레드 교체로 오버헤드가 발생할 수 있다. 

 

좀 더 자세한 내용은 공식문서를 참고하자.

이 외에도 다양한 ExecutorService 구현체는 존재한다 ex) ScheduledThreadPoolExecutor는 주기적으로 태스크를 실행

 

Executors Class의 Factory Method 사용

대부분의 실행자는 이 클래스의 정적 팩토리를 이용해 생성할 수 있다. 가장 자주 사용되는 두 가지는 모두 ThreadPoolExecutor 생성자를 이용한다.

 

1. Executors.newCachedThreadPool()

초기 스레드 수는 0, 코어 스레드 수는 0, 최대 스레드 수는 Integer.MAX_VALUE, keepAliveTime은 60초다. 작은 프로그램이나 가벼운 서버라면 좋은 선택이다. 특별히 설정할 게 없고 일반적인 용도에 적합하다. 하지만 무거운 프로덕션 서버에는 좋지 못하다. 요청받은 태스크들이 큐에 쌓이지 않고 즉시 스레드에 위임돼 실행된다. 가능한 스레드가 없다면 새로 만들게 되고, 최대 값이 Integer.MAX_VALUE니, CPU 이용률은 오르고, 새 태스크 도착 즉시 다른 스레드를 생성하며 악화시킨다. 따라서 이 경우 Executors.newFixedThreadPool이나 ExecutorService 구현체 생성자를 사용하는게 더 낫다.

 

2. Executors.newFixedThreadPool(int nThreads)

초기 스레드 수는 0, 코어 스레드 수는 nThreads, 최대 스레드 수는 nThreads다. 즉, 놀고있는 스레드가 있더라도 스레드 개수는 줄어들지 않는다.(단, allowCoreThreadTimeOut이 false인 경우다)

 

이 외에도 Executors Class는 ForkJoinPool이나 DelegatedScheduledExecutorService 등 다양한 정적 팩토리 메소드를 제공한다. 좀 더 자세한 내용은 공식 문서를 참고하자.

 

 

실행자 프레임워크를 써야하는 이유


실행자 프레임워크가 존재하니 작업 큐를 손수 만드는 일은 삼가고 스레드를 직접 다루는 것도 일반적으로 삼가자. 스레드를 직접 다루면 스레드가 작업 단위와 수행 메커니즘 역할을 모두 수행해야하는 반면, 실행자 프레임워크에선 작업 단위와 실행 메커니즘이 분리된다.

 

작업 단위를 나타내는 핵심 추상 개념이 task고 Runnable과 Callable이 존재한다. 그리고 task를 수행하는 일반적 메커니즘이 바로 ExecutorService다. task 수행을 ExecutorService에 맡기면 원하는 task 수행 정책을 선택할 수 있고 언제든 바꿀수도 있다. 

 

 

포크-조인(fork-join) task 지원 확장


자바 7부터 실행자 프레임워크는 포크-조인 task를 지원하도록 확장됐다. 포크-조인 task는 포크-조인 풀이라는 실행자 서비스가 실행해준다. ForkJoinTask의 인스턴스는 작은 하위 태스크로 나뉠 수 있고, ForkJoinPool을 구성하는 스레드들이 이 태스크들을 처리하며 task를 먼저 끝낸 스레드는 다른 스레드의 남은 task를 가져와 대신 처리할 수도 있다. 

 

모든 스레드가 바쁘게 움직여 CPU를 최대한 활용하면 높은 처리량과 낮은 지연시간을 달성하고, 이런 포크-조인 task를 직접 작성하고 튜닝하긴 어렵지만 포크-조인 풀을 이용해 만든 병렬 스트림을 이용하면 적은 노력으로 많은 이점을 얻을 수 있다. [아이템 48 참조]

 

**실행자 서비스 너무 광범위 ㅠㅠ 필요할 때 마다 따로 정리하고 공부할 필요 ** 

 

** 이펙티브 자바 외 출처 **

https://www.baeldung.com/java-executor-service-tutorial

https://www.baeldung.com/thread-pool-java-and-guava

https://www.baeldung.com/java-future

https://www.baeldung.com/java-executor-wait-for-threads

이것이 자바다 - 신용권의 Java 프로그래밍 정복 2편 chapter 12 - 멀티 스레드

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.