Hanbit the Developer

Kotlin Documentation | Coroutine context and dispatchers 본문

Mobile/Kotlin

Kotlin Documentation | Coroutine context and dispatchers

hanbikan 2023. 5. 24. 18:00

Kotlin Documentation 시리즈에 대해

Category: Official libraries - Coroutines

문서 링크: https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html


코루틴은 언제나 여러 코루틴 컨택스트로 표현된 컨텍스트에서 실행되며, 코루틴 컨택스트는 Dispatcher, Job, CoroutineExceptionHandler, CoroutineName 등 여러 요소의 집합이다.

Dispatchers and threads

CoroutineDispatcher는 어떤 쓰레드가 실행되는 데 사용되는지를 결정한다. 코루틴 디스패처는 코루틴 실행을 특정 쓰레드로 제한하거나, 쓰레드 풀 내에서 디스패치를 하거나, 제한 없이 실행되도록 할 수 있다.

launch, async 등 모든 코루틴 빌더는 코루틴 컨텍스트를 명시할 수 있게 파라미터를 제공한다.

fun main() = runBlocking<Unit> {
    launch { // context of the parent, main runBlocking coroutine
        println("main runBlocking      : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
        println("Unconfined            : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher 
        println("Default               : I'm working in thread ${Thread.currentThread().name}")
    }
    launch(newSingleThreadContext("MyOwnThread")) { // will get its own new thread
        println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
    }    
}
Unconfined            : I'm working in thread main @coroutine#3
Default               : I'm working in thread DefaultDispatcher-worker-1 @coroutine#4
main runBlocking      : I'm working in thread main @coroutine#2
newSingleThreadContext: I'm working in thread MyOwnThread @coroutine#5

명시하지 않을 경우 코루틴 빌더가 실행되는 곳에서의 컨텍스트를 그대로 사용한다.

Unconfined vs confined dispatcher

Dispatchers.Unconfined는 caller thread에서 코루틴을 첫번째 정지점 이전까지만 실행한다. 정지가 풀리면, 호출된 정지함수에 의해 결정되는 쓰레드에서 코루틴을 재개한다.(delay()에 의해 정지되었다면 정지 이후에는 해당 정지 함수가 사용하는 default executor thread에서 실행된다. 말그대로 특정 쓰레드에 제한되지 않고 원래 수행하던 쓰레드로 돌아가지 않는다.) 해당 디스패처는 CPU 시간을 소비하거나 UI처럼 특정 쓰레드에 제한된 공유 데이터를 업데이트하는 코루틴에 적절하지 않다.

launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
    println("Unconfined      : I'm working in thread ${Thread.currentThread().name}")
    delay(500)
    println("Unconfined      : After delay in thread ${Thread.currentThread().name}")
}
launch { // context of the parent, main runBlocking coroutine
    println("main runBlocking: I'm working in thread ${Thread.currentThread().name}")
    delay(1000)
    println("main runBlocking: After delay in thread ${Thread.currentThread().name}")
}
Unconfined      : I'm working in thread main @coroutine#2
main runBlocking: I'm working in thread main @coroutine#3
Unconfined      : After delay in thread kotlinx.coroutines.DefaultExecutor @coroutine#2
main runBlocking: After delay in thread main @coroutine#3

Debugging coroutines and threads

코루틴이 정지를 통해 다른 쓰레드로 이동할 수 있기 때문에 툴 없이는 코루틴이 어디서 언제 무엇을 하는지 파악하기 힘들다.

Debugging with IDEA

Debug - Coroutines tab

Debugging using logging

val a = async {
    log("I'm computing a piece of the answer")
    6
}
val b = async {
    log("I'm computing another piece of the answer")
    7
}
log("The answer is ${a.await() * b.await()}")

Jumping between threads

withContext()를 통해 같은 코루틴을 유지하면서 코루틴 컨텍스트를 변경할 수 있다.

newSingleThreadContext("Ctx1").use { ctx1 ->
    newSingleThreadContext("Ctx2").use { ctx2 ->
        runBlocking(ctx1) {
            log("Started in ctx1")
            withContext(ctx2) {
                log("Working in ctx2")
            }
            log("Back to ctx1")
        }
    }
}
[Ctx1 @coroutine#1] Started in ctx1
[Ctx2 @coroutine#1] Working in ctx2
[Ctx1 @coroutine#1] Back to ctx1

Job in the context

coroutineContext[Job]

위 같은 형식으로 코루틴 컨텍스트에 속한 Job을 가져올 수 있다.

Children of a coroutine

코루틴 내에서 다른 코루틴이 실행될 때, coroutineContext를 통해 컨텍스트가 상속되며 자식 코루틴의 Job은 부모 코루틴의 Job의 자식이 된다. 부모 코루틴이 취소되면 자식 코루틴 또한 재귀적으로 취소된다.

하지만 이러한 부모-자식 관계는 다음과 같은 방법으로 명시적으로 오버라이드 될 수 있다:

  • 코루틴을 실행할 때 다른 스코프가 명시된 경우
  • 코루틴을 실행할 때 다른 Job 객체가 컨텍스트로 전달된 경우
// launch a coroutine to process some kind of incoming request
val request = launch {
    // it spawns two other jobs
    launch(Job()) { 
        println("job1: I run in my own Job and execute independently!")
        delay(1000)
        println("job1: I am not affected by cancellation of the request")
    }
    // and the other inherits the parent context
    launch {
        delay(100)
        println("job2: I am a child of the request coroutine")
        delay(1000)
        println("job2: I will not execute this line if my parent request is cancelled")
    }
}
delay(500)
request.cancel() // cancel processing of the request
println("main: Who has survived request cancellation?")
delay(1000) // delay the main thread for a second to see what happens
job1: I run in my own Job and execute independently!
job2: I am a child of the request coroutine
main: Who has survived request cancellation?
job1: I am not affected by cancellation of the request

Parental responsibilities

// launch a coroutine to process some kind of incoming request
val request = launch {
    repeat(3) { i -> // launch a few children jobs
        launch  {
            delay((i + 1) * 200L) // variable delay 200ms, 400ms, 600ms
            println("Coroutine $i is done")
        }
    }
    println("request: I'm done and I don't explicitly join my children that are still active")
}
request.join() // wait for completion of the request, including all its children
println("Now processing of the request is complete")
request: I'm done and I don't explicitly join my children that are still active
Coroutine 0 is done
Coroutine 1 is done
Coroutine 2 is done
Now processing of the request is complete

Naming coroutines for debugging

코루틴이 로그를 자주 찍거나, 같은 코루틴에서의 로그 기록들을 서로 연관지어야 할 경우 자동으로 할당되는 코루틴 id가 적합하다. 하지만 코루틴이 특정 요청 과정에 묶여있거나 백그라운드 태스크를 진행할 경우에는 디버깅을 위해 명시적으로 네이밍하는 것이 낫다. CoroutineName이라는 컨텍스트 요소는 쓰레드 네임과 비슷한 목적으로 쓰인다.

log("Started main coroutine")
// run two background value computations
val v1 = async(CoroutineName("v1coroutine")) {
    delay(500)
    log("Computing v1")
    252
}
val v2 = async() {
    delay(1000)
    log("Computing v2")
    6
}
log("The answer for v1 / v2 = ${v1.await() / v2.await()}")
[main @main#1] Started main coroutine
[main @v1coroutine#2] Computing v1
[main @main#3] Computing v2
[main @main#1] The answer for v1 / v2 = 42

Combining context elements

여러 코루틴 컨텍스트 요소를 정의해야 할 때 + 연산자로 묶을 수 있다:

launch(Dispatchers.Default + CoroutineName("test")) {
    println("I'm working in thread ${Thread.currentThread().name}")
}

Coroutine scope

안드로이드의 lifecycle과 같은 또다른 스코프 내에 속해서 앱이 종료될 때, 메모리 누수를 피하기위해 코루틴 또한 종료되어야 한다. kotlinx.coroutines는 CoroutineScope라는 추상화를 제공한다.

public interface CoroutineScope {
    /**
     * The context of this scope.
     * Context is encapsulated by the scope and used for implementation of coroutine builders that are extensions on the scope.
     * Accessing this property in general code is not recommended for any purposes except accessing the [Job] instance for advanced usages.
     *
     * By convention, should contain an instance of a [job][Job] to enforce structured concurrency.
     */
    public val coroutineContext: CoroutineContext
}

CoroutineScope 인스턴스는 CoroutineScope(), MainScope()라는 팩토리 함수로 생성될 수 있다. 전자는 일반적인 목적으로 쓰이며, 후자는 UI 애플리케이션에 대한 스코프를 생성하고 기본 디스패처로서 Dispatchers.Main을 사용한다.

사용 사례는 다음과 같다:

class Activity {
    private val mainScope = MainScope()

    fun destroy() {
        mainScope.cancel()
    }
    // to be continued ...
// class Activity continues
    fun doSomething() {
        // launch ten coroutines for a demo, each working for a different time
        repeat(10) { i ->
            mainScope.launch {
                delay((i + 1) * 200L) // variable delay 200ms, 400ms, ... etc
                println("Coroutine $i is done")
            }
        }
    }
} // class Activity ends
val activity = Activity()
activity.doSomething() // run test function
println("Launched coroutines")
delay(500L) // delay for half a second
println("Destroying activity!")
activity.destroy() // cancels all coroutines
delay(1000) // visually confirm that they don't work
Launched coroutines
Coroutine 0 is done
Coroutine 1 is done
Destroying activity!

<aside> <img src="/icons/info-alternate_green.svg" alt="/icons/info-alternate_green.svg" width="40px" /> 안드로이드는 lifecycleScope와 같은 first-party support를 지원한다.

</aside>

Thread-local data

코루틴은 특정 쓰레드에서만 실행되지 않기 때문에 이를 처리해야 할 필요성이 생기며, asContextElement()를 통해 쓰레드 로컬 데이터를 처리할 수 있다. 해당 함수는 주어진 ThreadLocal을 저장하고 컨텍스트 스위칭 이후에 이를 복구할 수 있는, 추가적인 컨텍스트 요소를 생성한다.

import kotlinx.coroutines.*

val threadLocal = ThreadLocal<String?>() // declare thread-local variable

fun main() = runBlocking<Unit> {
    threadLocal.set("main")
    println("Pre-main, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")
    val job = launch(Dispatchers.Default + threadLocal.asContextElement(value = "launch")) {
        println("Launch start, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")
        yield()
        println("After yield, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")
    }
    job.join()
    println("Post-main, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")    
}
Pre-main, current thread: Thread[main @coroutine#1,5,main], thread local value: 'main'
Launch start, current thread: Thread[DefaultDispatcher-worker-1 @coroutine#2,5,main], thread local value: 'launch'
After yield, current thread: Thread[DefaultDispatcher-worker-2 @coroutine#2,5,main], thread local value: 'launch'
Post-main, current thread: Thread[main @coroutine#1,5,main], thread local value: 'main'

ensurePresent(): thread local이 있는지 체크

thread local이 변경될 때 코루틴 호출자에게 전파되지 않아 업데이트된 값은 다음 중단점에서 잃게 된다. 따라서 thread local을 변경하기 위해 withContext() 함수를 사용할 수 있다.

또는 class Counter(var i: Int)와 같이 래퍼런스 내부 값을 변경하는 식으로 처리할 수도 있다. 다만 비동기 처리를 해야할 책임이 생긴다.