Efficient Android Threading #7 Executor 프레임워크를 통한 스레드 실행 제어
이 글은 Efficient Android Threading 의 일부 내용만 발췌한 내용입니다.
자세한 내용은 책을 구입해서 보세용.
9.1. Executor
-
Executor 는 interface 로 void execute(Runnable command); 하나의 함수를 갖는다.
단순하지만 강력하다.
이는 테스크를 만드는 것과 실행 사이에 분리를 확실하게 해주기 때문에 기본 Thread 인터페이스보다 더 자주 사용된다.
-
public class SimpleExecutor implements Executor {
@Override
public void execute(Runnable runnable){
new Thread(runnable).start();
}
}
SimpleExecutor 는 매우 단간해 보이지만, 디커플링, 확장성, 메모리 참조 감소 등과 같은 장점을 제공한다.
-
executor 를 사용하면 능동적으로 테스크 큐잉, 테스크 실행 순서 조절, 테스크 실행 유형(직렬 또는 동시) 등을 컨트롤하기 쉽다.
9.2. 스레드 풀
-
스레드 풀의 장점은 아래와 같다.
1. 작업자 스레드는 실행할 다음 테스크를 기다리기 위해 살아 있을 수 있다. 이는 스레드가 매 테스크를 위해 생성 및 파괴(성능저하)될 필요가 없다는 것을 의미.
2. 스레드 풀은 스레드의 최대 개수로 정의된다. 이는 (앱 메모리를 소비하는) 백그라운드 스레드 수가 너무 많아져서 스레드 풀에 과부하가 걸리는 것을 막기 위해서이다.
3. 모든 작업자 스레드의 생명 주기는 스레드 풀 생명주기에 의해 제어된다.
** 9.2.1. 미리 정의된 스레드 풀
-
Executor 프레임워크는 Executors 팩토리 클래스에서 만들어진 미리 정의된 스레드 풀 유형을 포함한다.
-
Executors.newFixedThreadPool(n)
무한한 테스크 큐를 사용한다.
n 값이 1이면 singleThreadExecutor 와 동일하게 작동하지만, 요 executor 는 setCorePoolSize 함수를 통해 n 값을 바꿀 수 있다.
Executors.newCachedThreadPool()
처리할 테스크가 있을 때 새로운 스레드를 만든다.
유휴 스레드는 실행할 새로운 테스크를 60초간 기다리고, 테스크 큐가 비어 있는 경우 종료된다.
스레드 풀은 실행할 테스크 수와 함께 늘어나고 줄어든다.
Executor.newSingleThreadExecutor()
** 9.2.2. 커스텀 스레드 풀
-
ThreadPoolExecutor 를 사용하면 스레드의 생성과 종료, 큐잉방식 등을 모두 결정해서 커스텀 스레드 풀을 만들 수 있다.
ThreadPoolExecutor(
int corePoolSize, // 핵심 풀 크기 ( 하한, 최초에는 0으로 시작하며, 유휴 스레드가 있어도 하한을 맞추기 위해 늘어난다 )
int maximumPoolSize, // 최대 풀 크기
long keepAliveTime, // 생존 유지 시간, 0 이면 유휴 스레드는 스레드 풀이 종료될 때까지 종료되지 않는다.
TimeUnit unit, // 생존 유지 시간의 단위
BlockingQueue<Runnable> workQueue); // 태스크 큐 유형
주의!! "작업자 스레드"의 개수가 "핵심 풀 크기"보다 같거나 많아지면, 큐가 가득 찬 경우 새로운 작업자 스레드가 생성된다. 즉 큐는 스레드 생성에 대한 우선권을 얻는다.
주의해야 하는 이유는 핵심 풀 크기가 0 인 경우, queue 가 가득 찰 때까지 아무 thread 도 생성하지 않는다.
** 9.2.3. 스레드 풀 설계
-
무제한 큐는 큐가 무한증가 할 수 있어 메모리가 고갈될 수 있는 반면, 제한 큐의 자원 소비는 더 잘 관리될 수 있다.
한편 제한 큐는 그 크기와 포화 정책(saturation policy)을 모두 준비해야 한다.
포화 정책이란 거부된 태스크를 생산자가 어떻게 처리할 지를 뜻한다.
-
제한 또는 무제한 큐를 구현한 것이 LinkedBlockingQueue, PriorityBlockingQueue, ArrayBlockingQueue 이다.
LinkedBlockingQueue 는 기본적으로 무제한 큐지만 제한 큐로 구성할 수 있고,
PriorityBlockingQueue 와 ArrayBlockingQueue 는 제한큐이다.
-
ThreadPoolExecutor 는 작업자 스레드 개수와 풀의 생성과 종료 뿐만 아니라, 모든 스레드의 속성도 정의한다.
흔히 설정하는 동작은 UI 스레드와 경쟁하지 않도록 스레드 우선순위를 낮추는 것이다.
-
일반적으로 ThreadPoolExecutor 는 주로 독립적으로 사용되지만, 프로그램이 실행자 또는 실행자의 태스크를 추적할 수 있도록 확장될 수 있다.
앱은 스레드가 실행될 때마다 취하는 동작을 추가하기 위해 다음 메서드를 정의할 수 있다.
void beforeExecute(Thread t, Runnable r)
void afterExecute(Runnable r, Throwable t)
void terminate
** 9.2.4. 생명 주기
-
스레드 풀의 생명주기는 스레드 풀이 생성될 때부터 스레드 풀의 모든 작업자 스레드가 종료될 때까지다.
생명주기는 Executor 를 상속받고 ThreadPoolExecutor 에 의해 구현된 ExecutorService 인터페이스를 통해 관리되고 확인된다.
-
ExecutorService.shutdown 호출 후에는 현재 실행중인 테스크와 큐에 있는 테스크는 처리하지만 새로운 테스크는 거부한다.
ExecutorService.shutdownNow 가 호출되면, 작업자 스레드가 중지되고 큐 안의 테스크가 제거된다. 작업중인 스레드에도 interrupt 가 불린다. ( 취소 정책 필요 )
Tidying 상태에서는 내부 정리를 한다.
Terminated 상태에서는 남아있는 테스크와 작업자 스레드가 없다.
ExecutorService.awaitTermination() 는 terminate 될 때까지 blocking 되고, terminate 되면 ThreadPoolExecutor.terminated() 가 호출된다.
-
생명주기 상태는 되돌릴 수 없다.
일단 스레드 풀이 실행 상태를 벗어나면, 종료를 향해 이동하며 다시 재사용 할 수 없다.
* *9.2.5. 스레드 풀의 중단
-
스레드 풀이 수동으로 중단되지 않으면, 스레드 풀에 남아 있는 스레드가 없고 스레드 풀이 앱에 의해 참조되지 않을 때 자동으로 중단된다.
그러나 생존 유지 시간이 설정되지 않으면 스레드는 유휴 상태로 유지된다.
따라서 자동 중단은, 모든 스레드가 일정 시간 이후에 종료되도록 생존 유지 시간을 가지는 스레드 풀에 한해 적용된다.
( 다시 말해 ThreadPool 에 대한 참조가 없어져도, 그 안의 thread 가 살아있으면 GC 가 안 되기 때문에 thread 의 keep alive time 을 지정해 일정 시간 후 사라지도록 해 주어야 제대로 GC 가 된다.)
** 9.2.6. 스레드 풀 사용 사례와 위험성
-
기본적으로 생존 시간은 핵심 풀 스레드에는 적용되지 않지만, allowCoreThreadTimeOut(true) 는 시스템이 유휴 핵심 풀 스레드를 회수할 수 있게 한다.
따라서 스레드 풀은 큐에 저장하기보다 스레드 생성을 선호하도록 정의할 수 있다.
-
스레드 풀은 0개의 작업자 스레드로 시작하고 필요할 때에 생성된다.
스레드 생성은 태스크가 스레드 풀에 보내지면 시작되지만, 태스크가 들어오지 않으면 큐에 태스크가 있어라도(preloaded) 작업자 스레드가 생성되지 않는다.
이러한 조건에서는 테스크가 풀로 보내지는 것이 발생할 때까지 어떤 테스크도 실행되지 않는다.
preload 된 테스크 큐는 ThreadPoolExecutor 인스턴스에 prestartAllCoreThreads() 또는 prestartCoreThread() (싱글 스레드) 로 핵심 스레드를 미리 시작함으로써 직접 실행될 수 있다.
-
핵심 풀 크기는 큐가 스레드 생성에 대한 우선권을 얻기 전에 얼마나 많은 스레드가 시작될 것인지로 결정된다.
핵심 풀 크기에 도달되자마자 테스크는 새롭게 생성된 스레드에서 실행되는 대신 큐에 들어간다.
new ThreadPoolExecutor(
0, // 핵심 풀 크기
N * 2, // max thread count
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(10));
위와 같이 즉 0개의 핵심 스레드와 10개 테스크를 보유할 수 있는 제한된 큐에서는, 스레드 생성을 시작하는 11번째 테스크가 삽입될 때까지 실제로 어떤 테스크도 실행되지 않는다.
9.3. 테스크 관리
** 9.3.1. 테스크 표현
-
Callable 테스크는 자바 5에 처음 도입되었기 때문에 Thread 인스턴스에 의해 직접 실행 될 수 없다.
대신 실행 환경은 테스크를 처리하기 위해 스레드 풀 같은 ExecutorService 구현을 기반으로 해야 한다.
ExecutorService 에 의해 Callable 테스크가 처리되면 Callable 테스크는 테스크를 보낸 이후에 사용할 수 있는 Future 인터페이스를 통해 관찰되고 제어될 수 있다.
아래와 같은 interface 가 제공된다.
boolean cancel(boolean mayInterruptIfRunning) // false 이면 queue 에 있는 동안만 취소되고, true 이면 실행 중일 경우 interrupt 까지 날린다.
V get() // blocking, ExecutionException 을 catch 할 수 있음
V get(long timeout, TimeUnit unit) // timeout 되면 결과는 null
boolean isCancelled() // true 를 return 하는 것이 실행되지 않음을 의미하지 않는다. cancel 이 불림을 이야기함으로 실행중이며 interrupt 만 걸린 것일수도 있다.
boolean isDone()
** 9.3.2. 테스크 보내기
-
스레드 갯수가 핵심 풀 크기에 아직 도달하지 않은 경우, 테스크가 즉시 시작할 수 있게 새로운 스레드가 생성될 수 있다.
스레드 갯수가 핵심 풀 크기에 도달했고 큐에 빈 슬롯이 있는 경우, 테스크는 큐에 추가될 수 있다.
스레드 갯수가 핵심 풀 크기에 도달했으나 큐가 가득 찬 경우, 최대 풀 크기까지 새로운 스레드가 생성될 수 있다.
스레드 갯수가 핵심 풀 크기에 도달했고, 큐가 가득 찼으며, 스레드 갯수가 최대 풀 크기까지 도달한 경우 테스크는 거부되어야 한다.
-
execute 또는 submit 메서드로 낱개의 테스크들을 보낼 수 있다.
invokeAll, invokeAny 를 통해 테스크를 일괄 보낼 수도 있다.
-
Executor 인터페이스는 오직 Runnable 인터페이스를 처리할 수 있지만, ExecutorService 확장은 Runnable 과 Callable 인스턴스로 태스크를 보낼 수 있는 좀 더 일반적인 메서드를 포함한다.
-
invokeAll 은 동시에 여러 개의 독립적인 테스크를 실행하며, 모든 비동기 계산이 완료되거나 시간제한이 만료될 때까지 스레드 호출을 차단하여 앱이 모든 테스크가 완료되기를 기다리게 한다.
return 으로 전달되는 Future List 의 순서는 전달한 Collection 의 순서와 동일하다.
서로 다른 task 들을 수행하고 모두 다 마무리되었을 때 그 결과를 취합해야 하는 경우에 유용하게 쓰인다.
-
invokeAny 는 전달된 Collection 의 task 중 첫 번째로 마친 태스크에서 결과를 반환한 다음 나머지 부분을 무시한다.
서로 다른 여러 데이터 집합에 걸쳐 검색하다가 검색 결과가 발견되자마자 즉시 중지하기 원할 경우 또는 병렬로 실행하고 있는 테스크 중 하나의 결과만 원할 경우에 유용히다.
invokeAny 에 전달하는 collection 의 갯수는 최소 핵심 풀 크기보다 같거나 커야 한다. ( 이미 처리하는 task 가 있을 경우도 고려해야 할 수 있다. )
지연되는 테스크가 하나라도 있으면 테스크 모두를 실행하여 가장 빠른 결과를 얻으려는 전제가 무산된다.
** 9.3.3. 테스크 거부하기
-
테스크 추가는 두 가지 이유로 실패할 수 있다.
작업자 스레드 및 큐의 수가 모두 차 있거나, 실행자가 종료를 시작했을 경우이다.
앱은 스레드 풀에 RejectedExecutionHandler 의 구현을 제공함으로써 거부 처리에 대해 커스터마이즈 할 수 있다.
reject 가 발생하면 void rejectedExecution(Runnable r, ThreadPoolExecutor executor) 가 불린다.
-
안드로이드는 ThreadPoolExecutor 의 내부 클래스로 구현된 거부 작업을 위해 미리 정의된 네 가지 핸들러를 제공한다.
AbortPolicy
RejectedExecutionException 을 던짐으로써 테스크를 거부한다. 기본 동작이다.
CallerRunsPolicy
호출자의 스레드에서 동기적으로 테스크를 실행한다. UI 스레드에서 오래 걸리는 테스크가 추가되었을 때의 대안은 아니다.
DiscardOldestPolicy
큐에서 가장 오래된 테스크를 제거하고, 거부된 테스크를 다시 삽입한다.
큐의 첫번째 테스크는 제거되고, 추가된 테스크는 큐의 마지막에 배치된다.
DiscardPolicy
테스크의 거부를 조용히 무시한다.
9.3. ExecutorCompletionService
-
스레드 풀은 테스크 큐와 작업자 스레드는 관리하지만 완료된 결과는 관리하지 않는다.
완료된 결과의 관리는 ExecutorCompletionService 에 의해 이루어진다.
테스크가 완료되면 완료된 순서대로 결과를 처리할 수 있도록 Future 객체는 소비자 스레드에서 사용 가능한 큐에 배치된다.
'프로그래밍 놀이터 > 안드로이드, Java' 카테고리의 다른 글
Efficient Android Threading #9 서비스 (0) | 2018.03.25 |
---|---|
Efficient Android Threading #8 AsyncTask 로 백그라운드 태스크를 UI 스레드에 묶기 (0) | 2018.03.24 |
Efficient Android Threading #6 핸들러 스레드 : 고수준 큐 메커니즘 (0) | 2018.03.22 |
Efficient Android Threading #5 기본 스레드의 생명주기 관리 (0) | 2018.03.21 |
Efficient Android Threading #4 메모리 관리 (0) | 2018.03.20 |
댓글