본문 바로가기

안드로이드/Kotlin

[Kotlin] 코틀린 공식 문서 - Coroutine context and dispatchers

개요

해당 게시글은 Coroutine context and dispatchers | Kotlin (kotlinlang.org)을 번역한 게시글 입니다.

 

 

Coroutine context and dispatchers

코루틴은 코틀린 표준 라이브러리에 정의된 Coroutine Context 타입의 값으로 표현되는 어떤 context에서 항상 실행된다. Coroutine context는 다양한 요소의 집합으로, 주요 요소로는 Job, dispatcher 등이 존재한다.

 

Dispatchers and threads

Coroutine context에는 코루틴이 어느 스레드에서 실행될 지 결정하는 Coroutine dispatcher가 존재한다. Coroutine dispatcher는 특정 스레드에서 코루틴이 실행되게 하거나 스레드 풀에 발송(dispatch)하거나 특정 스레드에 제한 없이 실행하게 할 수 있다.

 

launch, async와 같은 코루틴 빌더는 CoroutineContext를 파라미터로 받아 dispatcher나 다른 context 요소를 명시적으로 설정할 수 있다. 

 

그 예시는 다음과 같다:

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
Default               : I'm working in thread DefaultDispatcher-worker-1
newSingleThreadContext: I'm working in thread MyOwnThread
main runBlocking      : I'm working in thread main

launch가 파라미터 없이 사용될 경우 실행된 CoroutineScope에서 context를 상속받는다. 이 경우에는 메인 스레드에서 동작하는 runBlocking 코루틴의 context를 상속받는다.

 

Dispatcher.Unconfined는 특별한 dispatcher로 메인 스레드에서 동작하는 것으로 보이지만 사실 나중에 설명하겠지만 약간 다른 메커니즘이다.

 

Dispatcher.Default는 다른 dispatcher가 명시적으로 scope에 설정되지 않은 경우 사용되는 dispacher이고 공유된 스레드의 백그라운드 풀을 사용한다.

 

newSingleThreadContext는 코루틴을 실행하기 위한 스레드를 생성한다. 이런 전용 스레드는 매우 비싼 자원이다. 실제 앱에서 더 이상 필요하지 않을 경우 이를 close 함수를 호출하여 릴리스하거나 최상위 변수에 저장하여 전체 앱에서 재사용하는게 좋다.

 

 

Unconfined vs Confined dispatchers

Dispatcher.Unconfined는 첫 일시 중지 지점까지만 호출한 스레드에서 코루틴을 시작한다. 일시 중지 후 호출된 suspend 함수에 의해 결정된 스레드에서 코루틴을 재개한다. Unconfined dispatcher는 CPU 시간을 소모하지 않거나 특정 스레드에 국한된 UI와 같은 공유 데이터를 업데이트하지 않는 코루틴에 적합하다.

 

이와 다른 dispatcher는 기본적으로 외부 Coroutine Context를 상속받는다. 특히, runBlocking 코루틴을 위한 default dispatcher는 호출한 스레드에 의해 결정되므로 상속은 예측 가능한 FIFO 스케쥴링으로 실행을 이 스레드로 제한하는 효과가 있다. 

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
main runBlocking: I'm working in thread main
Unconfined      : After delay in thread kotlinx.coroutines.DefaultExecutor
main runBlocking: After delay in thread main

runBlocking을 상속한 코루틴은 메인 스레드에서 계속해서 수행되는 반면 Unconfined는 delay 함수를 사용한 default executor 스레드에서 수행된다.

 

Unconfined dispatcher는 코루틴의 실행을 위해 나중에 코루틴을 dispatching할 필요가 없거나 부작용을 일으키는 일부 특정한 경우에 사용할 수 있는 고급 메커니즘이다. 왜냐하면 코루틴에서의 일부 동작은 즉시 수행되어야 하기 때문이다. Unconfined dispatcher는 일반적인 코드에는 사용하지 않는 것을 권고한다.

 

Debugging corougines and threads

코루틴은 한 스레드에서 일시중지되고 다른 스레드에서 재개될 수 있다. 싱글 스레드 dispatcher를 사용하더라도 특정한 도구가 없을 경우 코루틴이 언제, 어디서, 무엇을 하고 있는지 알기 어렵다.

 

Debugging with IDEA

코루틴 플러그인의 Coroutine Debugger는 Intellij IDEA에서 코루틴 디버깅을 쉽게 할 수 있게 도와준다. Debug 툴 창에 Coroutine 탭이 생기고, 이 탭에서 현재 동작하거나 일시 중지된 코루틴에 대한 정보를 확인할 수 있다. 코루틴은 동작하고 잇는 dispatcher로 그룹이 지어진다.

코루틴 디버거를 통해:

  • 각 코루틴의 상태 확인이 가능하다.
  • 동작하고 일시중지된 코루틴의 변수를 확인할 수 있다.
  • 코루틴 생성 스택을 확인하고 코루틴 내부의 콜스택도 확인할 수 있다. 해당 스택에는 일반 디버깅에서 놓치기 쉬운 가변 변수 프레임도 포함한다.
  • 각 코루틴과 그 스택에 대한 상태에 대한 모든 보고를 받을 수 있다. 이를 받기 위해선 Coroutine 탭 내부에서 우클릭한 뒤 Get Coroutine Dump를 클릭하면 된다.

코루틴 디버깅을 시작하기 위해 필요 시 중단지점을 설정하거나 디버깅 모드에서 앱을 실행시킬 필요가 있다.

 

Debugging using logging

코루틴 디버거 없이 스레드를 사용하는 앱을 디버깅하는 방법은 로그 구문마다 스레드 이름을 출력하는 것이다. 이는 로깅이 되는 프레임 워크에서는 모두 지원한다. 코루틴을 사용할 때 스레드의 이름만으로는 많은 context 정보를 제공하지 않기 때문에 kotlinx.coroutines에는 이를 쉽게 하기 위한 디버깅 장치가 포함되어 있다.

 

아래 코드를 -Dkotlinx.coroutines.debug 옵션을 사용하여 실행시켜보라.

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

fun main() = runBlocking<Unit> {
    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()}")    
}

세 개의 코루틴이 있고 메인 코루틴은 runBlocking이고 두 개의 코루틴은 deferred 값인 a(#2)와 b(#3)를 처리한다. ㅇ모두 runBlocking의 context 내에서 수행되고 메인 스레드로 국한된다. 결과는 다음과 같다:

[main @coroutine#2] I'm computing a piece of the answer
[main @coroutine#3] I'm computing another piece of the answer
[main @coroutine#1] The answer is 42

log 함수는 대괄호 안에 스레드의 이름을 출력하고 현재 수행중인 코루틴의 식별자도 붙어있다. 해당 식별자는 디버깅 모드가 켜져 있을 때 생성된 모든 코루틴에 할당된다.

 

Jumping between threads

아래 코드를 -Dkotlinx.coroutines.debug JVM 옵션으로 실행시켜보라.

fun main() {
    newSingleThreadContext("Ctx1").use { ctx1 ->
        newSingleThreadContext("Ctx2").use { ctx2 ->
            runBlocking(ctx1) {
                log("Started in ctx1")
                withContext(ctx2) {
                    log("Working in ctx2")
                }
                log("Back to ctx1")
            }
        }
    }    
}

이는 새로운 테크닉 몇 가지를 보여준다. 하나는 runBlocking에 context를 명시하여 사용하는 것이고 다른 하나는 withContext 함수를 사용하여 같은 코루틴 내에서 코루틴의 context를 바꿀 수 있다는 것을 보여준다.

[Ctx1 @coroutine#1] Started in ctx1
[Ctx2 @coroutine#1] Working in ctx2
[Ctx1 @coroutine#1] Back to ctx1

이 예제의 코틀린 표준 라이브러이의 use함수는 newSingleThreadContext로 생성된 스레드가 더 이상 필요 없을 때 릴리즈한다는 것도 알아 두어라.

 

Job in the context

코루틴의 Job은 context의 일부이고 coroutineContext[Job]을 통해 검색할 수도 있다.

디버깅 모드에서 위와 같은 결과를 볼 수 있다. CoroutineScope 내에서 isActivecoroutineScope[Job]?.isActive==true를 의미하는 단축키다.

 

Children of a coroutine

어떤 코루틴의 CoroutineScope 내에서 한 코루틴을 시작할 때 해당 코루틴은 부모 코루틴의 Job의 자식 코루틴이 되고 context를 상속받는다. 그리고 부모 코루틴이 취소되면 그 모든 자식 코루틴도 재귀적으로 취소된다.

 

그러나 이런 부모-자식 관계는 다음 두 방법 중 하나로 명시적으로 재정의 할 수 있다. 

(이는 부모-자식 관계를 없애는 것을 의미한다. 부모가 취소되더라도 자식은 영향을 받지 않게 된다.)

  1. 코루틴을 시작할 때 다른 scope가 명시적으로 지정되면  상위 범위에서 Job을 상속하지 않는다.
  2. 다른 Job 객체가 새로운 코루틴의 context로 전달했을 때 부모 scope의 Job을 덮어씌운다.

위 두 가지 경우에서 실행된 코루틴은 해당 코루틴이 실행된 scope에 제한되지 않고 GlobalScope과 유사하게 독립적으로 동작한다.

fun main() = runBlocking<Unit> {
    // 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
    delay(1000) // delay a second to see what happens
    println("main: Who has survived request cancellation?")
}
job1: I run in my own Job and execute independently!
job2: I am a child of the request coroutine
job1: I am not affected by cancellation of the request
main: Who has survived request cancellation?

 

Parental responsibilities

부모 코루틴은 항상 자식 코루틴이 완료되기를 기다린다. 부모 코루틴은 모든 자식 코루틴의 추적을 명시할 필요도 없고 Job.join을 사용하여 자식 코루틴을 기다릴 필요가 없다.

fun main() = runBlocking<Unit> {
    // 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은 context의 한 요소로 스레드의 이름과 같은 목적으로 제공된다. 디버깅 모드가 켜져있을 대 이 코루틴을 실행하는 스레드 이름에 포함된다. 

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

fun main() = runBlocking(CoroutineName("main")) {
    log("Started main coroutine")
    // run two background value computations
    val v1 = async(CoroutineName("v1coroutine")) {
        delay(500)
        log("Computing v1")
        252
    }
    val v2 = async(CoroutineName("v2coroutine")) {
        delay(1000)
        log("Computing v2")
        6
    }
    log("The answer for v1 / v2 = ${v1.await() / v2.await()}")    
}

 -Dkotlinx.coroutines.debug 옵션을 주고 실행하면 아래와 유사한 결과를 볼 수 있다.

[main @main#1] Started main coroutine
[main @v1coroutine#2] Computing v1
[main @v2coroutine#3] Computing v2
[main @main#1] The answer for v1 / v2 = 42

만약 옵션을 주지 않았따면 @Coroutines#1,@Coroutines#2,@Coroutines#3 등의 이름으로 출력되었을 것이다.

 

Combining context elements

+ 연산자를 통해 여러 개의 Coroutine context 요소를 정할 수도 있다. 예를 들어 코루틴을 명시적으로 특정한 dispatcher에 실행시키고 이름도 짓고 싶은 경우에 다음과 같이 작성한다.

fun main() = runBlocking<Unit> {
    launch(Dispatchers.Default + CoroutineName("test")) {
        println("I'm working in thread ${Thread.currentThread().name}")
    }    
}
I'm working in thread DefaultDispatcher-worker-1 @test#2

 

Coroutine scope

앱이 특정한 생명주기를 갖는 코루틴이 아닌 객체를 갖고 있다고 가정하자. 안드로이드에서 비동기적으로 데이터를 갱신하고 가져오기 위해 액티비티의 context에서 다양한 코루틴을 실행시키게 작성하는 경우가 그 예시이다. 이 경우 액티비티가 종료될 때 메모리 릭을 방지하기 위해 모든 코루틴이 취소되어야 한다. 그리고 당연히 context와 job을 수동으로 조작하여 액티비티와 코루틴의 생명주기를 연결할 수 있다. 하지만 kotlinx.coroutines는 CoroutineScope을 캡슐화 하는 추상화를 제공한다. 모든 코루틴 빌더가 확장 함수의 형태로 선언되어 있으므로 CoroutineScope에 익숙해져야 한다.

 

코루틴의 생명 주기를 액티비티의 생명주기에 국한된 CoroutineScope 객체를 생성하여 관리한다. 하나의 CoroutineScope 객체는 CoroutineScope() 혹은 MainScope의 팩토리 함수로 의해 생성될 수 있다. 전자는 일반적인 용도의 scope을 만들고 후자는 UI 앱에 대한 Scope을 만들고 Dispatchers.Main을 dispatcher로 사용한다.

class Activity {
    private val mainScope = MainScope()

    fun destroy() {
        mainScope.cancel()
    }
    // to be continued ...

 

이제 해당 Activity 내에서 정의된 scope를 이용하여 코루틴을 생성할 수 있다. 데모 버전에서는 서로 다른 delay 시간을 갖는 10개의 코루틴을 실행시켰다:

// 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

 

메인 함수에서 activity를 만들고 doSomething 함수를 호출 한 다음 500ms 후 액티비티를 삭제할 경우, doSomething에서 시작된 모든 코루틴을 취소하게 된다. 액티비티가 파괴 된 이후에는 더 이상 메시지가 출력되지 않은 것으로 이를 확인할 수 있다.

fun main() = runBlocking<Unit> {
    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!

보는 것과 같이 처음 두 개의 코루틴만 메시지를 출력하고 남은 코루틴은 Activity.destroy() 내의 mainScope.cancel()에 의해 취소되었다.

 

Thread-local data

 

코루틴 간에 스레드의 로컬 데이터를 전달하는 기능이 필요할 수도 있다. 하지만 코루틴은 특정 스레드에 바인딩되어 있지 않기 때문에 이를 수동으로 수행하면 보일러 플레이트가 많아질 수 있다.

이 경우 ThreadLocal의 asContextElement 확장 함수로 가능하다. 주어진 ThreadLocal의 값을 유지하고 코루틴이 context switching 될 때마다 값을 복원하는 Context 요소를 추가로 생성한다. 

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()}'")    
}

이 예제에서 Dispatcher.Default를 사용하여 백그라운드 스레드 풀에서 코루틴을 실행시켜 runBlocking의 main과 서로 다른 스레드에서 동작한다. 하지만 threadLocal.asContextElement(value="launch")를 사용하여 스레드 로컬 변수를 접근하여 더 이상 어느 스레드에서 코루틴이 동작하는 지는 중요하지 않다.

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'

 

해당 context 요소를 설정하는 것을 잊기 쉽다. 코루틴을 실행하는 스레드가 다른 경우 코루틴에서 접근한 스레드 로컬 변수는 예상했던 값과 다른 값을 갖고 있을 수 있다. 이를 방지 하기 위해선 ensureParent 메서드를 사용하고 부적절한 사용 시 fail-fast를 사용하는 게 좋다.

 

ThreadLocal은 최고 수준의 기능을 제공하며 kotlinx.coroutines가 제공하는 다른 기본 요소와 함께 사용할 수 있다. 단 하나의 제한은 thread-local이 변경되었을 때 새로운 값 다른 코루틴을 호출한 쪽으로 전파되지 않고 다음 suspend 시 갱신 된 값이 손실된다. withContext를 사용하여 코루틴에서 thread-local의 값을 갱신할 수 있다.

 

대안으로 값을 class Counter(var i: Int)와 같은 가변적인 box에 저장될 수 있으며 이는 차례로 thread-local 변수로 저장된다. 그러나 이 경우 해당 박스의 변수에 대한 동기화 문제는 해결해야 될 산이다.

 

로깅 MDC, 트랜잭션 context 또는 데이터 전달을 위해 내부적으로 thread-local을 사용하는 기타 라이브러리와의 통합과 같은 고급 사용에 대해서는 ThreadContextElement 인터페이스 문서를 참조하라.