본문 바로가기
프로그래밍 놀이터/안드로이드, Java

[Java Concurrency] 스레드 풀 활용

by 돼지왕 왕돼지 2017. 4. 27.
반응형

 [Java Concurrency] 스레드 풀 활용


abort, abortpolicy, accesscontrolcontext, afterExecute, allowcorethreadtimeout, arrayblockingqueue, availableprocessors, beforeexecute, BlockingQueue, blockingqueue.put, caller runs, callerrunspolicy, Comparator, Consumer, contextclassloader, coundownlatch.await, CPU, cpu 갯수, cpu 활용도, discard, discard oldest, discardoldestpolicy, discardpolicy, error, exception, execute, executor, executors, FIFO, Hook, invokeall, IO, java concurrency, keep-alive, linkedblockingqueue, Maximum, natural order, newcachedthreadpool, newfixedthreadpool, newscheduledthreadpool, newsinglethreadexecutor, newthread, prestartallcorethreads, priorityblockingqueue, privilegedthreadfactory, Producer, recursive, RejectedExecutionException, run, runtime.availableprocessors, runtime.getruntime.availableprocessors, RuntimeException, saturation policy, security policy, selector.select, setrejectedexecutionhandler, synchronousqueue, terminated, thread, thread local, thread starvation deadlock, thread.join, threadfactory, threadpoolexecutor, unconfigurableexecutorservice, [Java Concurrency] 스레드 풀 활용, 권한, 극단적 크기, 대기 시간, 데드락, 데몬, 데이터베이스 연결, 독립적, 독립적인 작업, 로그, 메모리, 메소드, 모니터링, 문서, 반복문, 블로킹 작업, 비율, 상속, 생성, 설정 변경, 설정 파일, 소켓 핸들, 스레드 개수, 스레드 부족 데드락, 스레드 수, 스레드 유지, 스레드 팩토리, 스레드 풀, 스레드 풀 크기 공식, 스레드 풀 크기 조절, 스레드 풀 활용, 스레드 한정, 스레드 한정 기법, 스레드에 직접 전달, 시간, 시간 제한, 실행 정책, 앱의 보안 정책, 앱의 특성, 오래된 항목 제거, 위험, 유연, 응답 속도, 응답 시간, 의존성, 자바 동시성, 자바6, 자원, 작업, 작업 시간, 작업 실행 스레드 내부, 작업량, 작업의 종류, 재귀, 재귀 함수 병렬화, 전략, 제거, 중단 정책, 직접 전달, 집중 대응 정책, 최대 코어 크기, 최대 크기, 최적 성능, 컨슈머, 코어 크기, 코어 크기 0, 큐 크기, 큐의 크기 제한, 통계값, 튜닝, 파일 핸들, 팩토리 메소드, 프로듀서, 하드코딩, 호출자 실행, 훅


8.1. 작업과 실행 정책 간의 보이지 않는 연결 관계


-
일정한 조건을 갖춘 실행 정책이 필요한 작업에는 다음과 같은 것들이 있다.

    의존성이 있는 작업
    스레드 한정 기법을 사용하는 작업
    응답 시간이 민감한 작업
    ThreadLocal 을 사용하는 작업


-
스레드 풀은 동일하고 서로 독립적인 다수의 작업을 실행할 때 가장 효과적이다.


-
특정 작업을 실행하고자 할 때 그에 맞는 실행 정책을 요구하는 경우도 있고, 특정 실행 정책 아래에서는 실행되지 않는 경우도 있다.
다른 작업에 의존성이 있는 작업을 실행해야 할 때는 스레드 풀의 크기를 충분히 크게 잡아서 작업이 큐에서 대기하거나 등록되지 못하는 상황이 없도록 해야 한다.
스레드 한정 기법을 사용하는 작업은 반드시 순차적으로 실행돼야 한다.
작업을 구현할 때는 나중에 유지보수를 진행할 때 해당 작업과 호환되지 않는 실행 정책 아래에서 실행하도록 변경해
앱의 안전성을 해치거나 실행되지 않는 경우를 막을 수 있도록 실행 정책과 관련된 내용을 문서로 남겨야 한다.



* 8.1.1. 스레드 부족 데드락

-
스레드 풀에서 다른 작업에 의존성을 갖고 있는 작업을 실행시킨다면 데드락에 걸릴 가능성이 높다.
스레드 풀의 크기가 크더라도 실행되는 모든 스레드가 큐에 쌓여 아직 실행되지 않은 작업의 결과를 받으려고 대기 중이라면 이와 동일한 상황이 발생할 수 있다.
스레드 부족 데드락(thread startvation deadlock)이라고 한다.

필요한 작업을 데드락 없이 실행시킬 수 있을 만큼 풀의 크기가 충분히 크다면 물론 문제가 없을 수도 있다.


-
완전히 독립적이지 않은 작업을 Executor 에 등록할 때는 항상 스레드 부족 데드락이 발생할 수 있다는 사실을 염두에 둬야 하며, 작업을 구현한 코드나 Executor 를 설정하는 설정 파일 등에 항상 스레드 풀의 크기나 설정에 대한 내용을 설명해야 한다.



* 8.1.2. 오래 실행되는 작업


-
데드락이 발생하지 않는다 하더라도, 특정 작업이 예상보다 긴 시간동안 종료되지 않고 실행된다면 스레드 풀의 응답 속도에 문제점이 생긴다.


-
제한 없이 계속해서 대기하는 기능 대신 일정 시간 동안만 대기하는 메소드를 사용할 수 있다면,
오래 실행되는 작업이 주는 악영향을 줄일 수 있는 하나의 방법으로 볼 수 있다.
Thread.join, BlockingQueue.put, CounDownLatch.await, Selector.select 등을 이용하면 좋다.

대기하는 도중에 지정한 시간이 지나면 해당 작업이 제대로 실행되지 못했다고 기록해두고 일단 종료시킨 다음
큐의 맨 뒤에 다시 추가하는 등의 대책을 세울 수 있다.


-
스레드 풀을 사용하는 도중에 모든 스레드에서 실행 중인 작업이 대기 상태에 빠지는 경우가 자주 발생한다면,
스레드 풀의 크기가 작다는 것으로 이해할 수도 있겠다.



8.2. 스레드 풀 크기 조절


-
스레드 풀의 가장 이상적인 크기는 스레드 풀에서 실행할 작업의 종류와 스레드 풀을 활용할 앱의 특성에 따라 결정된다.
스레드 풀의 크기를 하드코딩해 고정시키는 것은 그다지 좋은 방법이 아니며,
스레드 풀의 크기는 설정 파일이나 Runtime.availableProcessors 등의 메소드 결과 값에 따라 동적으로 지정되도록 해야 한다.


-
스레드 풀의 크기를 결정하는 데 특별한 공식이 있지는 않다.
너무 크거나 너무 작은 극단적인 크기만 아니면 된다.

스레드 풀의 크기가 너무 크게 설정되어 있다면 스레드는 CPU 나 메모리 등의 자원을 조금이라도 더 확보하기 위해 경쟁하게 되고, 그러다 보면 CPU 에 부하가 걸리고 메모리는 모자라 금방 자원 부족에 시달리게 된다.

반대로 스레드 풀의 크기가 너무 작다면 작업량은 계속해서 쌓이는데 CPU 나 메모리는 남아돌면서 작업 처리 속도가 떨어질 수 있다.


-
스레드 풀의 크기를 적절하게 산정하려면 현재 컴퓨터 환경이 어느 정도인지 확인해야 하고,
확보하고 있는 자원의 양도 알아야 하며,
해야 할 작업이 어떻게 동작하는지도 정확하게 알아야 한다.


-
CPU 를 많이 사용하는 작업의 경우 N 개의 CPU 를 탑재하고 있는 하드웨어에서 스레드 풀을 사용할 때는 스레드의 개수를 N + 1 로 맞추면 최적의 성능을 발휘한다고 알려져 있다.
CPU의 개수는 Runtime.getRuntime().availableProcessors() 로 알아낼 수 있다.

I/O 작업이 많거나 기타 다른 블로킹 작업을 해야 하는 경우라면 어느 순간에는 모든 스레드가 대기 상태에 들어가 전체적인 진행이 멈출 수 있기 때문에 스레드 풀의 크기를 훨씬 크게 잡아야 할 필요가 있다.

처리해야 할 작업이 시작해서 끝날 때까지 실제 작업하는 시간 대비 대기 시간의 비율을 구해봐야 한다.
아주 정확해야 할 필요는 없으며, 몇 가지 성능 측정 툴을 사용하거나 기타 단순한 방법으로 비율을 구해볼 수 있다.
아니면 스레드 풀의 크기를 바꿔가면서 앱을 자꾸 실행시켜 보면서 스레드 풀의 크기가 어느 수준일 때 CPU 가 가장 열심히 일을 하는지 알아볼 수 있다.


-
(스레드 수) = (CPU 개수) * (CPU 활용도 0~1) * ( 1 + 작업시간 대비 대기 시간의 비율)


-
스레드 풀을 적용하면 메모리, 파일 핸들, 소켓 핸들, 데이터베이스 연결과 같은 자원의 사용량도 적절하게 조절할 수 있다.
CPU가 아닌 이런 자원을 대상으로 하는 스레드 풀의 크기를 정하는 일은 CPU 때보다 훨씬 쉬운데, 각 작업에서 실제로 필요한 자원의 양을 모두 더한 값을 자원의 전체 개수로 나눠주면 된다.
이 값이 바로 스레드 풀의 최대 크기에 해당된다.



8.3. ThreadPoolExecutor 설정


-
ThreadPoolExecutor 는 Executors 클래스에 들어 있는 newCachedThreadPool, newFixedThreadPool, newScheduledThreadPool 과 같은 팩토리 메소드에서 생성해주는 Executor 에 대한 기본적인 내용이 구현되어 있는 클래스이다.
ThreadPoolExecutor 클래스는 유연하면서도 안정적이고 여러 가지 설정을 통해 입맛에 맞춰 바꿔 사용할 수 있도록 되어 있다.

팩토리 메소드를 사용해 만들어진 스레드 풀의 기본 실행 정책이 요구사항에 잘 맞지 않는다면
ThreadPoolExecutor 클래스의 생성 메소드를 직접 호출해 스레드 풀을 생성할 수 있으며 생성 메소드에 넘겨주는 값을 통해 스레드 풀의 설정을 마음대로 조절할 수 있다.






* 8.3.1. 스레드 생성과 제거

-
풀의 코어(core) 크기나 최대(maximum) 크기, 스레드 유지(keep-alive) 시간 등의 값을 통해
스레드가 생성되고 제거되는 과정을 조절할 수 있다.
코어 크기는 스레드 풀을 사용할 때 원하는 스레드의 개수라고 볼 수 있다.

스레드풀 클래스는 실행할 작업이 없다 하더라도 스레드의 개수를 최대한 코어 크기에 맞추도록 되어 있다.
( 최초에 ThreadPoolExecutor 를 생성한 이후에도 prestartAllCoreThreads 메소드를 호출하지 않는 한 코어 크기만큼의 스레드가 미리 만들어지지 않는다. 작업이 실행되면서 코어 크기까지의 스레드가 차례로 생성된다. )

큐에 작업이 가득 차지 않는 이상 스레드의 수가 코어 크기를 넘지 않는다.
( 풀의 코어 크기를 0 으로 맞추어 처리할 작업이 없을 때 스레드가 모두 사라지도록 하려는 의도는 위험할 수 있다.
newCachedThreadPool 와 같이 SynchronousQueue 가 아닌 다른 큐를 사용하는 스레드 풀의 경우 이상한 현상이 발생할 수 있다. 자바 6 버전에서는 allowCoreThreadTimeOut 메소드를 통해 시간 제한을 걸 수 있는데,
이것과 동시에 코어 크기를 0보다 큰 값으로 지정하여 스레드가 점차 사라지는 효과를 볼 수는 있다. )

지정한 스레드 유지 시간 이상 아무런 작업 없이 대기하고 있던 스레드는 제거 대상 목록에 올라가며,
풀의 스레드 개수가 코어 크기를 넘어설 때 제거될 수 있다.



* 8.3.2 큐에 쌓인 작업 관리

-
ThreadPoolExecutor 를 생성할 때 작업을 쌓아둘 큐로 BlockingQueue 를 지정할 수 있다.
스레드 풀에서 작업을 쌓아둘 큐에 적용할 수 있는 전략에는 세 가지가 있다.
    1. 큐에 크기 제한을 두지 않는 방법
    2. 큐의 크기를 제한하는 방법
    3. 작업을 스레드에게 직접 넘겨주는 방법


-
newFixedThreadPool 메소드와 newSingleThreadExecutor 메소드에서 생성하는 풀은
기본 설정으로 크기가 제한되지 않은 LinkedBlockingQueue 를 사용한다.


-
자원 관리 측면에서 ArrayBlockingQueue 또는 크기가 제한된 LinkedBlockingQueue 나 PriorityBlockingQueue 와 같이 큐의 크기를 제한시켜 사용하는 방법이 훨씬 안정적이다.
크기가 제한된 큐를 사용하면 자원 사용량을 한정시킬 수 있다는 장점이 있지만,
큐가 가득 찼을 때 새로운 작업을 등록하려는 상황을 어떻게 처리해야 하는지에 대한 문제가 생긴다.

작업 큐의 크기를 제한한 상태에서는 큐의 크기와 스레드의 개수를 동시에 튜닝해야 한다.


-
스레드의 개수가 굉장히 많거나 제한이 거의 없는 상태인 경우에는 작업을 큐에 쌓는 절차를 생략할 수도 있을텐데,
이럴 때는 SynchronousQueue 를 사용해 프로듀서에서 생성한 작업을 컨슈머인 스레드에게 직접 전달할 수 있다.
SynchronousQueue 는 따지고 보면 큐가 아니며 단지 스레드 간에 작업을 넘겨주는 기능을 담당한다고 볼 수도 있다.
SynchronousQueue 에 작업을 추가하려면 컨슈머인 스레드가 이미 작업을 받기 위해 대기하고 있어야 한다.
대기 중인 스레드가 없는 상태에서 스레드의 개수가 최대 크기보다 작다면 ThreadPoolExecutor 는 새로운 스레드를 생성해 동작시킨다.
반면 스레드의 개수가 최대 크기에 다다른 상태라면 집중 대응 정책(saturation policy)에 따라 작업을 거부하도록 되어 있다.

처리할 작업을 큐에 일단 쌓고 쌓인 작업을 직접 넘겨주는 방법이 있다면 훨씬 효율적일 수 있다.
SynchronousQueue 는 스레드의 개수가 제한이 없는 상태이거나 넘치는 작업을 마음대로 거부할 수 있는 상황이어야 적용할만한 방법이다.
newCachedThreadPool 팩토리 메소드에서는 스레드 풀에 SynchronousQueue 를 적용한다.


-
LinkedBlockingQueue 나 ArrayBlockingQueue 와 같은 FIFO 큐를 사용하면 작업이 등록된 순서에 맞춰 실행된다.
작업이 실행되는 순서를 좀 더 조절하고자 한다면 PriorityBlockingQueue 를 사용해 작업에 지정된 우선 순위에 따라 실행되도록 할 수 있다.
작업의 우선 순위는 기본 순서(natural order)를 따르거나, Comparator 를 지정해 원하는 순서로 배치할 수 있다.


-
크기가 고정된 풀보다는 newCachedThreadPool 팩토리 메소드가 생성해주는 Executor 가 나은 선택일 수 있다.
크기가 고정된 스레드 풀은 자원 관리 측면에서 동시에 실행되는 스레드의 수를 제한해야 하는 경우에 현명한 선택이 될 수 있다.
예를 들어 네트웍으로 클라이언트의 요청을 받아 처리하는 앱과 같은 경우, 크기가 고정되어 있지 않다면 요청이 많아져 부하가 걸릴 때 문제가 커진다.


-
스레드 풀에서 실행할 작업이 서로 독립적인 경우에만 스레드의 개수나 작업 큐의 크기를 제한할 수 있다.
다른 작업에 의존성을 갖는 작업을 실행해야 할 때 스레드나 큐의 크기가 제한되어 있다면
스레드 부족 데드락에 걸릴 가능성이 높다.
이럴 때는 newCachedThreadPool 메소드에서 생성하는 것과 같이 크기가 제한되지 않은 풀을 사용해야 한다.



* 8.3.3. 집중 대응 정책

-
크기가 제한된 큐에 작업이 가득 차면 집중 대응 정책(saturation policy)이 동작한다.
ThreadPoolExecutor 의 집중 대응 정책은 setRejectedExecutionHandler 메소드를 사용해 원하는 정책으로 변경할 수 있다.
( 이미 종료된 스레드 풀에 작업을 등록하려는 경우에도 동작한다. )
여러가지 종류의 RejectedExecutionHandler 를 사용해 다양한 집중 대응 정책을 적용할 수 있다.


-
RejectedExecutionHandler 에는 AbortPolicy, CallerRunsPolicy, DiscardPolicy, DiscardOldestPolicy 등이 있다.

기본적으로 사용하는 집중 대응 정책은 중단(abort) 정책이며, execute 메소드에서 RuntimeException 을 상속받은 RejectedExecutionException 을 던진다.
execute 메소드를 호출하는 스레드는 RejectedExecutionException 을 잡아서 작업을 더 이상 추가할 수 없는 상황에 직접 대응해야 한다.

제거(discard) 정책은 큐에 작업을 더 이상 쌓을 수 없다면 방금 추가시키려고 했던 정책을 아무 반응 없이 제거해버린다.

오래된 항목 제거(discard oldest) 정책은 큐에 쌓은 항목 중 가장 오래되어 다음 번에 실행될 예정이던 작업을 제거하고,
추가하고자 했던 작업을 큐에 다시 추가해본다.

호출자 실행(caller runs) 정책은 작업을 제거해 버리거나 예외를 던지지 않으면서 큐의 크기를 초과하는 작업을 프로듀서에게 거꾸로 넘겨 작업 추가 속도를 늦출 수 있도록 일종의 속도 조절 방법으로 사용된다.
다시 말해 새로 등록하려고 했던 작업을 스레드 풀의 작업 스레드로 실행하지 않고,
execute 메소드를 호출해 작업을 등록하려 했던 스레드에서 실행시킨다.


-
스레드 풀에 적용할 집중 대응 정책을 선택하거나 실행 정책의 다른 여러 가지 설정을 변경하는 일은 모두 Executor 를 생성할 때 지정할 수 있다.


-
작업 큐가 가득 찼을 때 execute 메소드가 그저 대기하도록 하는 집중 대응 정책은 따로 만들어진 것이 없다.
책의 BoundedExecutor 클래스와 같이 Semaphore 를 사용하면 작업 추가 속도를 적절한 범위 내에서 제한할 수 있다.






* 8.3.4 스레드 팩토리

-
스레드 풀에서 새로운 스레드를 생성해야 할 시점이 되면, 새로운 스레드는 항상 스레드 팩토리를 통해 생성한다.
기본값으로 설정된 스레드 팩토리에서는 데몬이 아니면서 아무런 설정도 변경하지 않은 새로운 스레드를 생성하도록 되어 있다.

스레드 팩토리를 직접 작성해 적용하면 스레드 풀에서 사용할 스레드의 설정을 원하는 대로 지정할 수 있다.
ThreadFactory 클래스에는 newThread 라는 메소드 하나만 정의되어 있으며, 스레드 풀에서 새로운 스레드를 생성할 때에는 항상 newThread 메소드를 호출한다.


-
스레드 풀에서 사용하는 스레드에 UncaughtExceptionHandler 를 직접 지정하고자 할 경우
Thread 클래스를 상속받은 또 다른 스레드를 생성해 사용하고자 하는 경우
새로 생성한 스레드의 실행 우선 순위를 조절할 경우
데몬 상태를 직접 지정할 경우 등이 ThreadFactory 를 사용할 때이다.

의미가 있는 이름을 지정해 오류가 발생했을 때 나타나는 덤프 파일이나 직접 작성한 로그 파일에서 스레드 이름이 표시되도록 할 수도 있다.


-
앱의 보안정책(security policy)를 사용해 각 부분마다 권한을 따로 지정하고 있다면, Executors 에 포함되어 있는 privilegedThreadFactory 메소드를 사용해 스레드 팩토리를 만들어 사용할 수 있겠다.
privilegedThreadFactory 메소드를 호출한 스레드와 동일한 권한, 동일한 AccessControlContext, 동일한 contextClassLoader 결과를 갖는 스레드를 생성한다.



* 8.3.5 ThreadPoolExecutor 생성 이후 설정 변경

-
ThreadPoolExecutor 를 생성할 때 생성 메소드에 넘겨줬던 설정 값은 대부분 여러가지 set 메소드를 사용해 생성된 이후에도 얼마든지 변경할 수 있다.
Executors 에는 unconfigurableExecutorService 메소드가 있는데, 현재 만들어져 있는 ExecutorService 를 넘겨 받은 다음
ExecutorService 의 메소드만을 외부에 노출하고 나머지는 가리도록 한꺼풀 덮어 씌워 더 이상은 설정을 변경하지 못하도록 할 수 있다.



8.4. ThreadPoolExecutor 상속


-
ThreadPoolExecutor 는 애초부터 상속받아 기능을 추가할 수 있도록 만들어졌다.
특히 상속받은 하위 클래스가 오버라이드해 사용할 수 있도록
beforeExecute, afterExecute, terminated 와 같은 여러 가지 훅(hook)도 제공하고 있으며,
이런 훅을 사용하면 훌씬 다양한 기능을 구사할 수 있다.


-
beforeExecute 메소드와 afterExecute 메소드는 작업을 실행할 스레드의 내부에서 호출하도록 되어 있으며,
로그 메시지를 남기거나 작업 실행 시점이 언제인지 기록해두거나 실행 상태를 모니터링하거나 기타 다양한 통계값을 뽑는 등의 작업을 하기에 적당하다.
특히 afterExecute 훅 메소드는 run 메소드가 정상적으로 종료되거나 아니면 예외가 발생해 Exception 을 던지고 종료되는 등의 어떤 상황에서도 항상 호출된다. ( Error 때문에 중단되면 실행되지 않는다. )
만약 beforeExecute 메소드에서 RuntimeException 이 발생하면 해당 작업도 실행되지 않을 뿐더러 afterExecute 메소드 역시 실행되지 않으니 주의하자


-
스레드 풀이 종료 절차를 마무리한 이후, 즉 모든 작업과 모든 스레드가 종료되고 나면 terminated 훅 메소드를 호출한다.
terminated 메소드에서는 Executor 가 동작하는 과정에서 사용했던 각종 자원을 반납하는 등의 일을 처리하거나 여러 가지 알람이나 로그 출력, 다양한 통계 값을 확보하는 등의 작업을 진행하기에 적당한 메소드이다.



8.5. 재귀 함수 병렬화


-
한 묶음의 작업을 한꺼번에 등록하고 그 작업들이 모두 종료될 때까지 대기하고자 한다면
ExecutorService.invokeAll 메소드를 사용해보자.


-
특정 작업을 여러 번 실행하는 반복문이 있을 때, 반복되는 각 작업이 서로 독립적이라면
병렬화해서 성능상 이점을 얻을 수 있다.
특히 반복문 내부의 작업을 개별적인 작업으로 구분해 실행하느라 추가되는 약간의 부하가 부담되지 않을 만큼 적지 않은 시간이 걸리는 작업이라야 더 효과를 볼 수 있다.


-
반복문을 병렬화하는 작업은 일부 재귀(recursive) 함수 처리 부분에도 적용할 수 있다.



Summary


Executor 프레임웍은 작업을 병렬로 동작시킬 수 있는 강력함과 유연성을 고루 갖추고 있다.
스레드를 생성하거나 제거하는 정책이나 큐에 쌓인 작업을 처리하는 방법, 작업이 밀려 있을 때 밀린 작업을 처리하는 방법 등의 조건을 설정해 입맛에 맞게 튜닝할 수 있는 옵션도 제공하고 있으며,
여러 가지의 훅 메소드를 사용해 필요한 기능을 확장해 사용할 수 있다.

강력하면서도 유연성이 높은 프레임웍에서 자주 발생하는 일이지만,
여러 가지 설정 가운데 서로 잘 맞지 않는 설정이 있을 수 있다.
예를 들어 특정 종류의 작업은 일정한 실행 정책 아래에서만 제대로 동작하기도 하고,
특이한 조합을 사용하면 예측할 수 없는 이상한 형태로 작업이 실행되기도 한다는 점을 주의하자.





반응형

댓글