코루틴을 사용하기 위해서는 코루틴의 코루틴의 환경 정보, 어떻게 시작하는지, 코루틴의 범위등을 알아야 합니다.
이번 시간에는 코루틴을 사용하기 위해 코루틴의 환경 정보(설정)을 담당하는 Context에 대해 알아보겠습니다.
coroutine context는 코루틴이 실행하는 환경에 대한 모든 정보를 담고 있는 곳이라고 생각하면 됩니다.
coroutine context는 키-값에 대한 쌍으로 관리합니다.
coroutine context에는 CoroutineName, Dispatchers, Job, CoroutineExceptionHandler가 있습니다.
이번 글에선 coroutine context의 구성방법과 CoroutineName과 Dispatchers에 대해서 알아보겠습니다.
| Context Key | Context Value |
|---|---|
| CoroutineName 키 | CoroutineName 값 |
| Dispatchers 키 | Dispatchers 값 |
| Job 키 | Job 값 |
| CoroutineExceptionHandler 키 | CoroutineExceptionHandler 값 |
Coroutine Context의 특징은 Context 값을 덮어쓸 수 있습니다.
부모 코루틴의 Context를 상속받으면서도 필요에 따라 특정 요소만 변경할 수 있게 해줍니다.
ex)
fun main() = runBlocking<Unit> {
val myContext = CoroutineName("MyCoroutine") + newSingleThreadContext("MyThread")
val nextContext = CoroutineName("NextCoroutine") + Dispatchers.IO
val context = myContext + nextContext
launch(context) {
println(coroutineContext)
}
}
// 결과
[CoroutineName(NextCoroutine), CoroutineId(2), "NextCoroutine#2":StandaloneCoroutine{Active}@687086a8, Dispatchers.IO]
CoroutineName은 코루틴의 이름을 지정해서 디버깅이나 로그를 확인할 때 쉽게 식별자를 만드는 것이라고 생각하면 됩니다.
코루틴이 하나만 실행 된다면 당연히 파악하는데 어려움이 없지만 여러 코루틴이 동작 된다면 어떤 코루틴이 동작 되고 있는 지 알기 위해 사용하는 context 입니다.
예시 :
val coroutineName = CoroutineName("myThread")
fun main() = runBlocking<Unit> {
launch(coroutineName) {
println("launch: ${Thread.currentThread().name}")
}
}
// 결과 :
// launch: main @myThread#2
이 처럼 CoroutineName을 지정하면 해당 코루틴의 이름을 지정 할 수 있습니다.
Modify Run Configuration에서 Vm options에 -Dkotlinx.coroutines.debug 입력


Dispatchers는 코루틴이 어느 스레드에서 실행될지를 결정하는 Context 입니다.
Dispatchers의 종류에는
등 다양한 Dispatchers가 있습니다.
먼저 알아볼 Dispatchers는 imediate 입니다.
메인 스레드에서 실행 되며 immediate로 즉시 실행한다는 의미를 가지고 있습니다.
• • •
Returns dispatcher that executes coroutines immediately when it is already in the right context (e.g. current looper is the same as this handler's looper) without an additional re-dispatch.
mmediate dispatcher is safe from stack overflows and in case of nested invocations forms event-loop similar to Dispatchers.Unconfined
이미 올바른 context에 있을 경우 추가적인 dispatch(작업을 스레드에 보내 실행)없이 코루틴을 즉시 실행 합니다. (예를 들면 현재 루퍼와 핸들러의 루퍼가 같을 떄를 말합니다)
Immediate dispatcher는 스택 오버플로우로부터 안전하며, 중첩 호출의 경우 Dispatchers.Unconfined와 유사한 이벤트 루프를 형성합니다.
• • •
설명을 덧 붙이자면 현재 코드가 메인 스레드에서 실행되고 있고, Dispatchers.Main.immediate를 사용한다면, 별도의 스케줄링 과정 없이 바로 그 자리에서 코루틴을 실행합니다.
일반적인 Dispatcher의 경우는 메인 스레드의 이벤트 큐에 작업을 추가한 후 나중에 실행하는 방식이지만 immediate는 즉시 실행합니다.
또 코루틴 내부에서 또 다른 코루틴이 실행될 경우 일반적인 함수처럼 스택이 계속 쌓이지 않고 여러 코루틴이 호출되는 경우에는 모두 즉시 실행하는 대신 순차적으로 처리하는 루프를 형성한다고 합니다.
Main 스레드 즉, UI 스레드에서 실행되는 Dispatchers 입니다.
UI 스레드에서 코루틴을 실행하므로 View를 업데이트하거나 사용자 상호작용 (액션 및 이벤트) 처리 등에 사용하는 Dispatcher 입니다.
UI 스레드에서 동작하므로 무거운 작업을 하거나 오래 걸리는 작업을 할 경우에는 ANR이 발생할 위험이 있습니다.
실무에선 UI관련 데이터 작업을 보통 ViewModel이 처리하고 UI를 업데이트 하여 viewModelScope.launch{ }가 Dispatchers.Main.immediate이기 때문에 Dispatcher.Main를 직접 명시해서 사용하는 경우는 거의 없습니다.
IO 스레드에서 실행되어 입출력 작업에 최적화된 Dispatcher 입니다.
Dispatchers.IO에 대한 설명을 보겠습니다.
• • •
"The CoroutineDispatcher that is designed for offloading blocking IO tasks to a shared pool of threads.
Additional threads in this pool are created and are shutdown on demand. The number of threads used by tasks in this dispatcher is limited by the value of "kotlinx.coroutines.io.parallelism" (IO_PARALLELISM_PROPERTY_NAME) system property. It defaults to the limit of 64 threads or the number of cores
CoroutineDispatcher는 블로킹 IO 작업들을 공유 스레드 풀로 분산시키기 위해 설계되었습니다. 이 풀의 추가 스레드들은 필요에 따라 생성되고 종료됩니다. 이 디스패처에서 작업들이 사용하는 스레드 수는 "kotlinx.coroutines.io.parallelism" 시스템 속성값으로 제한됩니다. 기본값은 64개 스레드 또는 CPU 코어 수 중 더 큰 값입니다.
• • •
Dispatchers.IO는 *공유 스레드풀을 사용하여 필요한 만큼의 스레드를 사용하고 스레드를 반환 합니다.
여러 스레드를 통해 작업을 하는동안 (I/O 작업동안) 다른 작업 처리가 가능합니다.
Dispatchers.IO를 사용하는 경우는 대게 파일 시스템에 접근 하거나 데이터베이스 및 이미지/동영상 다운로드 처리에 적합 합니다.
만약 viewModel에서 viewModelScope.launch{}를 통해 작업을 한다면 사실상 Dispatchers.IO를 사용하지 않아도 되긴 합니다.
그 이유는 Dispatchers.Main.immediate는 ANR를 일으키지 않기 때문입니다.
Dispatchers.IO를 사용해야 한다면 데이터베이스의 결과를 미리 캐싱 한다던지 이미지 및 영상 다운로드 처리 등에 사용할 것 같습니다.
*공유 스레드 풀 : 미리 생성된 스레드들을 모아놓은 곳 작업 요청이 오면 기존 스레드를 재사용 합니다.(Dispatchers.Default와 같은 공유 스레드풀 사용)
공유 스레드 풀 기반으로 동작하며 CPU 작업을 처리하기 위한 Dispatcher입니다.
Default는 메인 스레드를 블로킹하지 않으면서 무거운 작업을 할 때 사용하게 되는데 큰 리스트를 정렬하거나 Json파일을 파싱, 이미지 처리 작업을 할 때 사용한다고 합니다.
실무에서는 이미지 해상도를 위해 이미지를 리사이징 처리를 할 때 사용한 경험이 있습니다.
Default의 중점은 계산 작업이고 IO의 기다리는 작업과는 구분이 필요합니다.
Dispatchers.Unconfined은 제한되지 않은 Dispatcher로 호출한 스레드에서 즉시 시작 된다고 합니다.
이렇게 되면 스레드 전환에 있어서 예측이 불가능해지기 때문에 특히 UI 업데이트를 해야 되는 경우 ANR 발생 위험이 있는 Dispatcher입니다.
하지만 즉시 시작되므로 지연시간이 최소화 된다는 장점이 있습니다.
앞서 위에서 말했던 것 처럼 일반적인 Dispatcher는 이벤트 큐에 작업을 추가한 후 나중에 실행 되므로 주로 기다릴 필요 없는 테스트 코드를 작성할 때 사용합니다.
단일 스레드를 사용하는 코루틴 실행 컨텍스트를 생성하는 함수입니다.
인자 값으로 Coroutine Cotext의 이름을 지정할 수 있습니다.
내부 설명을 보면
• • •
Creates a coroutine execution context using a single thread with built-in yield support.
Resources are reclaimed by CloseableCoroutineDispatcher.close
It should not be allocated in place, should be closed at the end of its lifecycle
내장된 *yield 지원 기능을 가진 단일 스레드를 사용하여 코루틴 실행 컨텍스트를 생성합니다.
리소스들은 CloseableCoroutineDispatcher.close() 메서드에 의해 회수됩니다.
즉석에서 할당되어서는 안 되며, 라이프사이클의 끝에서 반드시 close 되어야 합니다.
• • •
*yield() : 현재 실행중인 코루틴을 다른 코루틴에게 실행기회를 양보할 수 있는 suspend 함수
단일 스레드로 구성되어 있어 순차적인 처리에 유리하며 라이프사이클에 맞춰서 리소스를 꼭 해제해야 합니다.
ex)
@OptIn(ExperimentalCoroutinesApi::class)
fun main() = runBlocking<Unit> {
val singleThread = newSingleThreadContext("MyThread")
try {
val totalTime = measureTimeMillis {
coroutineScope {
repeat(3) { index ->
launch(singleThread) {
println("시작 $index: ${Thread.currentThread().name}")
Thread.sleep(500)
println("종료 $index: ${Thread.currentThread().name}")
}
}
}
}
println("총 걸린 시간: ${totalTime}ms")
} finally {
singleThread.close()
}
}
// 결과
시작 0: MyThread @coroutine#2
종료 0: MyThread @coroutine#2
시작 1: MyThread @coroutine#3
종료 1: MyThread @coroutine#3
시작 2: MyThread @coroutine#4
종료 2: MyThread @coroutine#4
총 걸린 시간: 1574ms
실제로 스레드 이름을 출력해보면 단일 스레드로 출력하는 것을 볼 수 있습니다.
그리고 단일 스레드를 사용하여 3개의 작업의 스레드가 0.5초씩 블로킹 되어 최종 작업 시간이 약 1.5초 걸리는 것을 볼 수 있습니다.
고정된 개수의 스레드를 가진 스레드 풀을 생성하는 함수입니다.
newSingleThreadContext와 차이점은 여러개의 스레드를 동시에 사용하여 병렬적으로 실행이 가능하다는 것입니다.
@OptIn(ExperimentalCoroutinesApi::class)
fun main() = runBlocking<Unit> {
val fixedThreadPool = newFixedThreadPoolContext(2, "MyThread")
try {
val totalTime = measureTimeMillis {
coroutineScope {
repeat(3) { index ->
launch(fixedThreadPool) {
println("시작 $index: ${Thread.currentThread().name}")
Thread.sleep(500)
println("종료 $index: ${Thread.currentThread().name}")
}
}
}
}
println("총 걸린 시간: ${totalTime}ms")
} finally {
fixedThreadPool.close()
}
}
// 결과 :
시작 0: MyThread-1 @coroutine#2
시작 1: MyThread-2 @coroutine#3 // MyThread-1과 다른 스레드 MyThread-2 사용
종료 0: MyThread-1 @coroutine#2
시작 2: MyThread-1 @coroutine#4
종료 1: MyThread-2 @coroutine#3
종료 2: MyThread-1 @coroutine#4
총 걸린 시간: 1065ms
이 처럼 newSingleThreadContext와 다르게 스레드가 블로킹 되어도 다른 할당된 다른 스레드를 이용해 병렬적으로 작업을 할 수 있습니다.
이름 처럼 병렬처리를 제한 하여 새로운 Dispatcher를 만드는 함수 입니다.
여기서 n은 동시에 실행할 수 있는 작업의 수를 제한하는 수 입니다.
예를 들어 100개의 스레드에서 limitedParallelism(5)로 제한하면 최대 5개까지 동시에 실행 됩니다.
newFixedThreadPoolContext와 다른 점은 newFixedThreadPoolContext은 새로운 스레드를 생성하고 라이프사이클에 맞춰 리소스를 해제해야하지만 limitedParallelism은 기존의 스레드 풀에서 동시 실행 개수를 제한 합니다.
ex)
val limitedIO = Dispatchers.IO.limitedParallelism(3)
val limitedMain = Dispatchers.Main.limitedParallelism(2)
...
launch(limitedIO) {
// 코드
}
limitedIO context를 사용하면 실제로 스레드 풀 내에서 3개의 스레드를 사용하고 만약 4개이상의 작업을 해야 된다면 3개의 작업이 먼저 각자의 스레드에서 실행되고 하나의 작업은 3개의 스레드 중 작업이 끝나는 스레드의 할당 받기를 기다리게 됩니다.
Coroutine의 Context의 구성요소와 CoroutineName 및 다양한 Dispatchers에 대해 알아보았습니다.
Coroutine Context에 대해서는 크게 신경 쓰지 않고 Coroutine을 사용해왔지만 Coroutine을 사용하는 곳에서 어떤 Context가 적절할지에 대해서 한번 더 생각할 수 있는 시간이 되었습니다.
제가 작성한 글은 틀린 내용이 있을 수 있습니다.
피드백은 언제나 환영입니다!
글을 쓰실 때 깊이 공부하시고 싶어하는 게 느껴지네요. 굿굿 다음 글 기대가 됩니다.