(Coroutine) 4. CoroutineContext, Dispatcher
1. CoroutineContext
우리는 지금까지의 포스팅을 통해 코루틴은 항상 CoroutineContext 상에서 실행되는 것을 알고있다.
그렇다면 CoroutineContext는 어떻게 구현되어 있을까?
// CoroutineContext.kt
public interface CoroutineContext {
public operator fun <E : Element> get(key: Key<E>): E?
public fun <R> fold(initial: R, operation: (R, Element) -> R): R
public operator fun plus(context: CoroutineContext): CoroutineContext = { ... }
public fun minusKey(key: Key<*>): CoroutineContext
public interface Key<E : Element>
public interface Element : CoroutineContext {
public val key: Key<*>
public override operator fun <E : Element> get(key: Key<E>): E? =
@Suppress("UNCHECKED_CAST")
if (this.key == key) this as E else null
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
operation(initial, this)
public override fun minusKey(key: Key<*>): CoroutineContext =
if (this.key == key) EmptyCoroutineContext else this
}
}
CoroutineContext는 내부적으로 4개의 메서드를 갖는다.
위 4가지 메서드와 key를 통해 CoroutineContext를 구성할 수 있다.
CoroutineContext를 구성하는 Element로는 CoroutineId, CoroutineName, CoroutineDispatcher, ContinuationInterceptor, CoroutineExceptionHandler를 들 수 있다.
이 요소들은 Element 인터페이스를 구현하고, 각각의 key를 기반으로 CoroutineContext에 등록될 수 있다.
CoroutineContext에 대해 Deep-Dive한 내용은 링크를 통해 확인할 수 있다. [코루틴 공식 가이드 읽고 분석하기]
2. Dispatcher
CoroutineContext를 구성하는 CoroutineDispatcher에 대해 알아보자.
정확히 말하자면, CoroutineDispatcher는 ContinuationInterceptor 인터페이스를 구현하며 이 인터페이스가 CoroutineContext의 Element이다.
CoroutineDispatcher를 구현하는 Dispatchers는 아래와 같이 구현되어있다.
// Dispatchers.kt
public const val IO_PARALLELISM_PROPERTY_NAME: String = "kotlinx.coroutines.io.parallelism"
/**
* Groups various implementations of [CoroutineDispatcher].
*/
public actual object Dispatchers {
@JvmStatic
public actual val Default: CoroutineDispatcher = DefaultScheduler
@JvmStatic
public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher
@JvmStatic
public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined
@JvmStatic
public val IO: CoroutineDispatcher = DefaultIoScheduler
@DelicateCoroutinesApi
public fun shutdown() {
DefaultExecutor.shutdown()
// Also shuts down Dispatchers.IO
DefaultScheduler.shutdown()
}
}
코루틴은 대표적으로 Default, Main, Unconfined, IO 디스패처를 갖고 있다.
아래 예시를 통해 각 디스패처가 어떤 역할을 하는지 알아보자.
fun main() = runBlocking<Unit> {
launch {
println("부모 context / ${Thread.currentThread().name}")
}
launch(Dispatchers.Default) {
println("Default / ${Thread.currentThread().name}")
}
launch(Dispatchers.IO) {
println("IO / ${Thread.currentThread().name}")
}
launch(Dispatchers.Unconfined) {
println("Unconfined / ${Thread.currentThread().name}")
}
launch(newSingleThreadContext("Jaesung")) {
println("newSingleThreadContext / ${Thread.currentThread().name}")
}
}
>> Default / DefaultDispatcher-worker-1 @coroutine#3
>> IO / DefaultDispatcher-worker-2 @coroutine#4
>> Unconfined / main @coroutine#5
>> newSingleThreadContext / Jaesung @coroutine#6
>> 부모 context / main @coroutine#2
이전 포스팅에서도 봤듯이 launch의 파라미터로 Dispatcher를 넣을 수 있었다.
그 이유는 CoroutineContext를 구성하는 Element에 Dispatcher도 포함되기 때문이다.
당연히, CoroutineContext를 구성하는 다른 Element들도 추가할 수 있을 것이다.
이와 관련된 내용은 잠시 후에 보기로 하고 지금은 이 Dispatcher들이 어떤 역할을 하는지 알아보도록 하자.
첫 번째 launch와 같이 Dispatcher가 없는 경우에는 기본적으로 부모의 컨텍스트를 상속하여 수행된다.
부모 코루틴인 runBlocking이 main에서 수행되기 때문에 해당 launch는 main으로 수행될 것이다.
두 번째 launch는 Default 디스패처를 갖는다.
Dispatchers.Default는 DefaultDispatcher에서 수행되며 main 쓰레드가 아닌 worker 쓰레드를 통해 동작하는 것을 알 수 있다.
이 디스패처는 코어 수에 비례하는 쓰레드 풀에서 수행된다.
세 번째 launch는 IO 디스패처를 갖는다.
마찬가지로 DefaultDispatcher에서 수행되며 Dispatchers.Default와 동일하게 worker 쓰레드를 통해 동작하는 것을 알 수 있다.
이 디스패처는 코어 수 보다 훨씬 많은 쓰레드를 갖는 쓰레드 풀에서 수행된다.
그렇다면 동일한 worker 쓰레드에서 동작하는 Default와 IO의 차이는 무엇일까?
Dispatchers.Default와 Dispatchers.IO는 동일한 DefaultDispatcher worker 쓰레드를 사용하지만 얼마만큼의 쓰레드를 만들지에 대한 정책에 차이가 있다.
일반적으로 Dispatchers.Default는 Dispatchers.IO보다 더 복잡한 연산을 수행할 수 있다.
예를 들어, JSON 파싱이나 정렬 작업과 같은 연산을 생각해볼 수 있다.
이러한 연산들은 여러 개의 쓰레드를 만들면 효율적으로 동작하지 못하게 된다.
그렇기 때문에 코어 수에 비례하는 쓰레드를 만들어 활용할 수 있다.
반면, Dispatchers.IO는 CPU를 덜 소모하는 연산에 적합하다.
File 연산이나 네트워크 작업은 CPU를 덜 소모하기 때문에 이와 같은 IO 작업은 쓰레드를 몇 개를 만들더라도 영향이 크지 않다.
네 번째 launch에서는 Dispatchers.Unconfined를 사용하고 있다.
이 디스패처는 잘 사용되지는 않지만 우선적으로는 main 쓰레드에서 동작한다.
직역 그대로 어디에도 속하지 않기 때문에 위 코드에서는 부모가 main에서 동작하기 때문에 main에서 수행되는 것이다.
하지만, 앞으로 어떤 쓰레드에서 실행될지는 예측할 수 없다.
아래 예시를 살펴보자.
fun main() = runBlocking<Unit> {
async(Dispatchers.Unconfined) {
println("Unconfined / ${Thread.currentThread().name}")
delay(100L) // suspension point
println("Unconfined / ${Thread.currentThread().name}")
delay(200L) // suspension point
println("Unconfined / ${Thread.currentThread().name}")
}
}
>> Unconfined / main @coroutine#2
>> Unconfined / kotlinx.coroutines.DefaultExecutor @coroutine#2
>> Unconfined / kotlinx.coroutines.DefaultExecutor @coroutine#2
처음에는 main 쓰레드에서 동작하다가 Suspension point 이후에는 다른 쓰레드에서 수행되고 있다.
이처럼 Dispatchers.Unconfined는 Suspension point 이후 어느 쓰레드로 수행되는지 예측하기 어렵다는 단점이 있다.
가능하면 확실한 디스패처를 사용하는 것이 좋다.
마지막 launch에서는 디스패처가 아닌 newSingleThreadContext를 통해 새로운 커스텀 쓰레드를 생성하고 있다.
여러 작업을 동시에 하는 경우에는 Default나 IO를 통해 worker 쓰레드를 할당받지 못하는 경우가 있을 수 있다.
무조건 쓰레드를 할당받아 작업을 해야하는 경우에 사용할 수 있다.
추가로 Dispatchers.Main이 있다.
이 디스패처는 안드로이드의 main 쓰레드에서 코루틴을 실행하도록 하는 디스패처이다.
안드로이드에서는 View와 상호작용하는 작업을 실행할 때 사용할 수 있다.
3. async에서 Dispatcher 사용
디스패처는 launch 뿐만 아니라 async와 withContext와 같은 코루틴 빌더에서도 사용할 수 있다.
아래 예시들을 통해 간단히 봐보도록 하자.
fun main() = runBlocking<Unit> {
async {
println("부모의 콘텍스트 / ${Thread.currentThread().name}")
}
async(Dispatchers.Default) {
println("Default / ${Thread.currentThread().name}")
}
async(Dispatchers.IO) {
println("IO / ${Thread.currentThread().name}")
}
async(Dispatchers.Unconfined) {
println("Unconfined / ${Thread.currentThread().name}")
}
async(newSingleThreadContext("Fast Campus")) {
println("newSingleThreadContext / ${Thread.currentThread().name}")
}
}
>> Default / DefaultDispatcher-worker-1 @coroutine#3
>> Unconfined / main @coroutine#5
>> IO / DefaultDispatcher-worker-3 @coroutine#4
>> newSingleThreadContext / Fast Campus @coroutine#6
>> 부모의 콘텍스트 / main @coroutine#2
4. CoroutineContext Element 결합
이 포스팅의 초반부에는 CoroutineContext에 대한 간단한 설명을 했었다.
또한 CoroutineContext는 여러가지 Element로 구성이 되어있으며 key를 사용하여 내부 메서드를 통해 Element끼리 결합을 할 수 있다.
아래 그림을 보도록 하자.
위 그림은 launch 빌더의 첫 번째 파라미터로 들어가는 CoroutineContext에 어떤 Element가 들어가냐에 따라 바뀌는 CoroutineContext의 상태를 보인다.
CoroutineContext를 명시하지 않는 경우에는 기본적으로 EmptyCoroutineContext가 적용된다.
아래 내부 코드를 통해 확인할 수 있다.
// Builders.common.kt
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
val newContext = newCoroutineContext(context)
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)
coroutine.start(start, coroutine, block)
return coroutine
}
// kotlinx-coroutines-core-jvm-1.6.4-souces.jar/.../CoroutineContext.kt
@ExperimentalCoroutinesApi
public actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {
val combined = foldCopies(coroutineContext, context, true)
val debug = if (DEBUG) combined + CoroutineId(COROUTINE_ID.incrementAndGet()) else combined
return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null)
debug + Dispatchers.Default else debug
}
CoroutineContext의 plus 메서드를 통해 각각의 Element를 Element + Element로 매핑시킬 수 있으며 이렇게 병합된 CoroutineContext는 CombinedContext를 만들어낸다.
CombinedContext는 다음과 같은 구조로 사용된다.
ex. CoroutineContext : A, B, C
launch { // parent context : A
launch(B + C) { // context : A + B + C
}
}
아래 예시에서 Dispatcher와 CoroutineName을 결합하는 것을 확인해보자.
fun main() = runBlocking<Unit> { // 조부모
launch { // 부모
launch(Dispatchers.IO + CoroutineName("launch1")) { // 자식 1
println("launch1: ${Thread.currentThread().name}")
println(coroutineContext[CoroutineDispatcher])
println(coroutineContext[CoroutineName])
delay(5000L)
}
launch(Dispatchers.Default + CoroutineName("launch2")) { // 자식 2
println("launch2: ${Thread.currentThread().name}")
println(coroutineContext[CoroutineDispatcher])
println(coroutineContext[CoroutineName])
delay(10L)
}
}
}
>> launch2: DefaultDispatcher-worker-3 @launch2#4
>> launch1: DefaultDispatcher-worker-1 @launch1#3
>> Dispatchers.Default
>> Dispatchers.IO
>> CoroutineName(launch1)
>> CoroutineName(launch1)
자식 1은 Dispatchers.IO에서 수행되는 "launch1"이라는 이름을 갖는 코루틴이다.
자식 2는 Dispatchers.Default에서 수행되는 "launch2"라는 이름을 갖는 코루틴이다.
예시에 나와있는 것 처럼 두 코루틴은 CoroutineContext를 구성하는 Element인 Dispatcher와 CoroutineName의 CombinedContext이다.
이렇게 두 Element를 결합하고 나면 CoroutineContext에서 key를 통해 각각의 Element에 대해 접근할 수 있다.
Reference
https://kotlinlang.org/docs/coroutines-overview.html
https://myungpyo.medium.com/reading-coroutine-official-guide-thoroughly-part-1-7ebb70a51910