8장: 스레드 풀 활용
작업과 실행 정책 간 보이지 않는 연결 관계
타이밍, 작업 결과, 다른 작업의 조건 등에 영향을 받지 않는 독립적인 작업은 스레드 풀에서 실행시켜도 문제가 없다.
결국 스레드 풀은 동일하고 서로 독립적인 다수의 작업을 실행할 때 가장 효과적이다.
다른 작업에 의존성을 갖는 작업을 스레드 풀에서 실행하려는 경우 데드락 등의 문제가 발생할 여지가 있으므로 보이지 않는 조건을 면밀히 따져 대비해야 한다.
단일 스레드로 동작할 때 적합한 경우 Executor 프레임워크를 사용해도 하나의 스레드만으로 동작해야 하는 조건이 생긴다.
응답 시간에 민감한 작업을 스레드 풀에 위임했는데, 다른 스레드들이 모두 오래걸리는 작업을 수행하고 있는 경우 응답 성능이 떨어질 수 있다.
Executor가 자동으로 유휴 스레드를 제거하거나 작업이 많으면 새로운 스레드를 추가하기 때문에 ThreadLocal을 기반으로 동작하는 작업을 스레드 풀에 위임하면 안된다.
Thread Starvation Deadlock
스레드 풀에서 다른 작업에 의존하는 어떠한 작업을 실행할 때, 조건이 충족되지 않아 작업이 계속 대기하게 되는 상태를 의미한다.
특정 작업 1개를 n개의 작업이 의존하고 있고, n개의 작업이 각각 스레드 풀의 스레드에 할당되어 있는 경우, 새로운 특정 작업 1개는 스레드 풀을 통해 진행될 수 없어 영원히 멈추게 된다.
특정 작업이 오래 실행된다면 스레드 풀 전체의 응답 속도에 영향이 갈 수 있다. 오래 걸리는 작업이 생기지 않도록 가급적 시간 제한을 둘 수 있는 메서드를 사용하고, 실패한 경우 다시 작업 큐에 할당하는 것이 바람직하다.
스레드 풀 크기 조절
스레드 풀에서 실행할 작업의 종류와 스레드 풀을 활용할 애플리케이션 특성에 따라 크기를 정할 수 있다.
스레드 풀의 크기는
Runtime#availableProcessors등의 메서드를 통해 동적으로 지정되도록 해야 한다.크기가 너무 크면 스레드 간에 CPU, 메모리 자원 획득을 위해 경쟁하게 되어 자원 고갈이 발생할 수 있다.
스레드 풀이 너무 작으면 자원은 충분하지만 작업이 실행되지 못할 수 있다.
CPU bounded 작업인 경우
CPU 코어 + 1개의 스레드를 가진 스레드 풀이 최적의 성능을 발휘한다고 알려져 있다.페이징 오류 등의 원인으로 인해 스레드가 멈추는 경우가 있기 때문에 1개의 추가 스레드를 마련해두면 CPU가 쉬지 않고 일할 수 있다.
I/O bounded 작업 또는 블로킹 작업인 경우 모든 스레드가 대기 상태에 들어가기 쉬우므로 스레드 풀의 크기를 크게 잡아야 한다.
실제 작업하는 시간 대비 대기 시간의 비율을 측정해보거나, 직접 테스트해보는 것이 좋다.
다음 수식을 통해 원하는 활용도를 유지할 수 있는 스레드 풀의 크기를 구할 수 있다.
(CPU 개수)*(목표로 하는 CPU 활용 비율 ex. 0.5)*(1 + 작업 시간 대비 대기 시간의 비율)
CPU가 아닌 메모리, 소켓, 데이터베이스 연결 등을 기준으로 스레드 풀 크기를 지정하려면, 단순히
필요한 자원의 총합 / 자원의 전체 개수로 지정하면 된다.
ThreadPoolExecutor 설정
ThreadPoolExecutor는 스레드 풀을 생성하는 팩토리 메서드(newFixedThreadPool, newScheduledThreadPool 등)를 제공한다.
설정 인자
여러 설정을 통해 원하는 형태대로 스레드 풀을 생성할 수 있다.
corePoolSize
실행할 작업이 없어도 스레드 개수를 최대한 코어 크기에 맞춘다.
단, 최초에 ThreadPoolExecutor를 생성한 경우 작업이 실행되면서 코어 크기까지 스레드가 차례로 생성된다.
prestartAllCoreThreads 메서드 호출 시 작업을 맡기기 전에 스레드를 준비시킬 수 있다.
maximumPoolSize
동시에 동작 가능한 스레드의 최대 개수를 지정한다.
큐에 작업이 가득 찼다면 스레드를 늘려 작업을 바로 할당한다.
스레드 개수가 이만큼 도달한 상태라면 작업이 들어왔을 때 집중 대응 정책에 따라 거부할 수도 있다.
keepAliveTime
설정한 스레드 유지 시간이 지나도록 아무런 작업 없이 대기하고 있던 스레드는 제거 대상이 된다. 스레드 풀의 스레드 개수가 코어 크기를 넘어있는 경우 해당 스레드는 제거된다.
workQueue
작업을 쌓아둘 BlockingQueue 객체를 지정할 수 있다.
대부분 기본적으로 LinkedBlockingQueue를 사용하여 작업이 처리되는 속도보다 추가되는 속도가 빠르면 큐에 계속해서 작업이 쌓인다.
자원 관리 측면에서는 크기가 제한된 BlockingQueue를 사용하는 것이 안정적이다. 이 경우 큐가 꽉 찬 상황에서 새로운 작업을 등록하려할 때 처리 방안을 마련해야 한다. 또한 큐의 크기와 스레드 개수를 같이 튜닝해야 한다.
스레드 수가 매우 많아 작업이 쌓일 가능성이 없는 경우 SynchronousQueue를 사용해 바로 스레드에게 실행하도록 넘길 수 있다.
PriorityBlockingQueue를 사용하면 작업이 지정된 우선 순위에 따라 실행되도록 할 수 있다.
스레드 풀에서 실행할 작업이 다른 작업에 의존성을 갖는 경우, 스레드 부족 데드락에 빠질 수 있으므로 크기가 제한되지 않은 풀을 사용하는 것이 좋다.
집중 대응 정책
saturation policy
크기가 제한된 큐에 작업이 가득차있을 때 새로운 작업을 실행시키는 경우 어떻게 처리할 지 지정할 수 있다.
Abort
RejectedExecutionException 예외를 던져 작업을 추가할 수 없음을 알린다.
Discard
작업을 추가하지 않고 무시한다.
Discard Oldest
가장 오래 전에 추가된 작업을 제거하고, 새로운 작업을 추가한다.
우선 순위 큐인 경우 이 방식을 사용하면 안된다.
Caller Runs
작업을 제거하거나 예외를 던지지 않고, 스레드 풀에 작업을 할당하려 했던 스레드에서 작업을 실행시키는 방식이다.
예를 들어 웹 서버의 스레드가 별도 스레드 풀에 작업을 할당하려 했지만 이를 실패하면, 웹 서버의 스레드들이 직접 작업을 수행할 것이다. 요청이 여전히 많이 들어온다면 웹 서버의 스레드들도 고갈될 것이고, 자연스럽게 TCP 계층에서 접속 요청을 자체 큐에 쌓아 대기시킨다. TCP 계층에서도 큐에 요청을 쌓을 수 없는 상태가 되면 연결이 최종적으로 거부될 것이다. 이렇게 점진적으로 성능이 떨어지도록 하는 것이 이 방식의 목적이다.
작업 큐가 가득 찼을 때 작업을 추가하려던 execute 메서드를 대기시키는 정책은 따로 없다. 해당 방법을 원한다면 직접 외부에서 구현해야 한다.
큐의 크기를 제한하지 않고 작업 추가 속도를 적절한 범위에서 제한하는 방법은 다음과 같다.
스레드 풀의 스레드 개수+큐에서 대기하도록 허용하는 최대 작업 개수값을 세마포어 크기로 설정하여 작업 추가 시 세마포어를 획득하도록 한다.
동적 변경
ThreadPoolExecutor 객체를 생성한 후에도 setter 메서드를 통해 설정 값을 변경할 수 있다.
이를 막고자 한다면 unconfigurableExecutionService 메서드를 통해 동적 변경이 불가능한 객체를 얻을 수 있다.
스레드 팩토리
스레드 풀에서 생성할 스레드에 대해 설정할 수 있다.
스레드의 이름을 직접 만든 규칙에 의해 붙이거나, 스레드의 실행 우선 순위 조절, 데몬 상태 지정, UncaughtExceptionHandler 지정 등의 목적으로 사용할 수 있다.
ThreadPoolExecutor 상속
ThreadPoolExecutor는 상속받아 기능을 추가할 수 있도록 설계되었다.
beforeExecute, afterExecute 메서드를 구현하면 작업 실행 전후에 특정 작업을 수행할 수 있다.
beforeExecute 과정에서 예외가 발생하면 실제 execute 메서드도 수행되지 않는다.
afterExecute 과정은 execute 메서드 내에서 어떤 일이 발생하든 항상 수행된다.
스레드 풀의 종료 절차가 모두 마무리되면 terminated 훅 메서드를 호출한다. 이 때 각종 자원을 반납하거나 로그 출력, 통계 확보 등의 작업을 수행할 수 있다.
재귀 함수 병렬화
특정 작업을 여러 번 실행하는 반복문이 있을 때 각 작업들이 서로 독립적이라면 병렬화하여 성능적 이점을 얻을 수 있다.
반복문의 각 단계에서 실행되는 작업이 내부에서 재귀적으로 호출했던 작업의 결과를 사용하지 않는 경우에도 작업을 병렬화할 수 있다.
모든 연산이 완료되길 기다리려면 shutdown 메서드와 awaitTermination 메서드를 차례로 호출하면 된다.
하나의 연산이 완료되었을 때 다른 스레드들의 작업을 중단하게 하려면, 스레드 내부에서 항상 latch를 확인한 후 작업을 실행하면 된다.
Last updated