Beeeam

코루틴 공식 문서 읽기 (Coroutine context and dispatchers) 본문

Kotlin

코루틴 공식 문서 읽기 (Coroutine context and dispatchers)

Beamjun 2023. 3. 28. 23:52

https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html#dispatchers-and-threads

 

Coroutine context and dispatchers | Kotlin

 

kotlinlang.org

 

Coroutine Context는 다양한 요소로 이루어진 집합이다. 여기서 주요한 요소들은 coroutine job 객체와 dispatcher이다.

(Coroutine Context 안에는 Coroutine job이랑 dispatcher 등 다양한 요소들이 들어있다.)

 

Dispatchers and threads

Coroutine Context는 dispatcher를 포함한다. Dispatcher는 해당 코루틴이 실행에 사용하는 스레드를 결정한다. Coroutine Dispatcher는 코루틴의 실행을 특정 스레드로 제한하거나, 스레드 풀로 보내거나, 제한 없이 실행되도록 할 수 있다.

모든 코루틴 빌더들은 선택적 Coroutine Context 매개변수를 허용한다. 즉, 코루틴 빌더를 사용할 때 Coroutine Context 매개변수를 할당 하든 말든 상관 없다.(사용하면 결과는 달라지긴 하지만 실행하는데는 문제가 없다는 말이다)

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

먼저 launch를 파라미터 없이 사용하면 상위 CoroutineScope로 부터 Context를 상속 받는다.

위의 예에서는 상위 CoroutineScope가 메인 스레드에서 실행중인 runBlocking 스코프 이므로 runBlocking 스코프의 Context를 상속 받는다.

Dispatchers.Default(기본 디스패처)는 다른 dispatcher가 스코프 내에서 명시적으로 지정되지 않았을 경우에 사용된다. (GlobalScope 에서 사용되어 별다른 dispatcher 명시가 필요 없는 상황에서 사용된다.)

Dispatchers.Default로 표시되고, 공용 백그라운드 스레드 풀을 사용한다.

newSingleThreadContext코루틴을 실행할 새로운 스레드를 생성한다. 이렇게 생성된 스레드는 고비용의 리소스라서 실제 앱 에서는 더 이상 필요하지 않으면 close함수로 해제하거나 최상위 변수에 저장하고, 앱 전체에서 재사용 해야 한다.

 

Unconfined vs confined dispatcher

Dispatchers.Unconfined 는 해당 코루틴을 호출한 스레드에서 실행을 하는데 첫 번째 중단점 까지만 실행된다. 중단점 이후에 재개(resume) 될 때는 얘를 재개시킨 스레드에서 수행된다.

자신을 호출한 스레드에서 첫 번째 중단점 까지만 실행하고 다른 스레드에서 호출되어 재개가 되면 거기서 실행한다.

그래서 얘는 코루틴이 CPU 시간을 소모하지 않거나 공유되는 데이터를 업데이트 하지 않는 경우처럼 특정 스레드에 국한된 작업이 아닌 경우에 적합하다. ex) ui

 

Dispatcher는 기본적으로 상위 CoroutineScope에서 상속된다. 그런데 runBlocking 의 기본 dispatcher는 호출 스레드에 국한되기 때문에 그것을 상속하는 것은 그 스레드에 국한되도록 만드는 효과가 있으며, 예측 가능한 FIFIO scheduling으로 수행 되어 진다.

fun main() = runBlocking<Unit> {
    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으로 부터 상속 받은 Context로 동작하는 코루틴(아무 파라미터 X)은 main 스레드에서만 동작하는데 Unconfined코루틴main 스레드에서 실행되다가 딜레이 되고 재개하면서 실행되는 스레드가 kotlinx.coroutines.DefaultExecutor로 바뀐 것을 확인할 수 있다. 이는 DefaultExecutor에서 delay()를 호출한 것을 의미한다.

 

Jumping between threads

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

위의 코드를 실행하면 다음과 같은 결과가 나온다.

[Ctx1] Started in ctx1 
[Ctx2] Working in ctx2 
[Ctx1] Back to ctx1

코드의 진행을 보면 newSingleThreadContext()로 스레드를 생성하고 .use()를 사용했다. 이 use함수를 사용하는 것은 newSingleThreadContext()로 생성된 스레드를 사용하지 않으면 해제 하기 위해서이다. 그리고 withContext()를 사용하여 스레드간 전환을 진행하였다.

결과를 보면 withContext로 ctx2 스레드로 전환되서 ctx2에서 실행되다가 해당 동작이 끝나서 필요가 없어지자 ctx2 스레드가 해제된 것을 확인할 수 있다.

 

Job in the context

Coroutine의 job은 그 Context의 일부이다. 그리고 coroutineContext[Job] 표현을 통해서 job을 얻을 수 있다.

fun main() = runBlocking<Unit> {
    println("My job is ${coroutineContext[Job]}")
}

위의 코드를 실행하면 다음과 같은 결과가 나온다.

My job is BlockingCoroutine{Active}@5383967b

 

Children of a coroutine

어떤 코루틴이 다른 코루틴의 코루틴 스코프 안에서 시작되면 CoroutineScope.coroutineContext을 통해서 컨텍스트를 상속하게 된다. 그리고 새로운 코루틴(자식)의 job은 부모 코루틴의 job의 자식이 된다.

이 때문에 부모 코루틴이 취소되면 이 코루틴의 모든 자식들은 재귀적으로 취소된다.

그런데 이러한 코루틴의 부모, 자식 관계는 밑의 두 가지 방법을 통해서 무시할 수 있다.

  1. 코루틴이 시작될 때 다른 범위를 명시적으로 지정한 경우 부모로 부터 job을 상속하지 않는다. ex) GlobalScope
  2. 다른 job이 새 코루틴의 Context로 전달 되면 부모의 job을 재정의 한다.

이 두 가지 경우로 실행된 코루틴은 부모와의 관계와 별개로 독립적으로 실행이 된다.

fun main(args: Array<String>) = runBlocking<Unit> {

    val request = launch {
        GlobalScope.launch {
            println("job1: Global Scope에서 실행")
            delay(1000)
            println("job1: 영향 x")
        }
        launch(Job()) {
            println("job2: 다른 job을 지정")
            delay(1000)
            println("job2: 영향 x")
        }
        launch {
            delay(100)
            println("job3: 자식 코루틴")
            delay(1000)
            println("job3: 부모 코루틴의 영향을 받아 취소")
        }
    }
    delay(500)
    request.cancel()
    delay(1000)
    println("main: Who has survived request cancellation?")
}

위의 코드를 실행하면 다음과 같은 결과가 나온다.

job1: Global Scope에서 실행 
job2: 다른 job을 지정 
job3: 자식 코루틴 
job2: 영향 x 
job1: 영향 x 
main: Who has survived request cancellation?

job1은 첫 번째 경우, job2는 두 번째 경우, job3은 부모 코루틴의 자식으로 생성되었다.

위에서 언급했던 것처럼 job1과 job2는 부모 코루틴의 취소에 영향을 받지 않고, job3은 부모 코루틴의 취소에 영향을 받아 취소된 것을 확인할 수 있다.

 

Parental responsibilities

부모 코루틴은 자신의 모든 자식 코루틴들의 실행이 완료될 때 까지 기다린다. 부모 코루틴은 이를 위해서 실행이 완료된 모든 자식들을 추적할 필요는 없고, join함수를 사용해서 자식들의 종료를 기다릴 필요도 없다.

fun main(args: Array<String>) = runBlocking<Unit> {

    val request = launch {
        repeat(3) { i -> 
            launch {
                delay((i + 1) * 200L) // variable delay 200ms, 400ms, 600ms
                println("코루틴 $i 완료")
            }
        }
        println("request: join함수 사용해서 자식 기다릴 필요 x")
    }
    request.join() // wait for completion of the request, including all its children
    println("request 실행 완료")
}

위의 코드를 실행하면 다음과 같은 결과가 나온다.

request: join함수 사용해서 자식 기다릴 필요 x 
코루틴 0 완료 
코루틴 1 완료 
코루틴 2 완료 
request 실행 완료

결과를 보면 부모 코루틴에 대해서만 join 함수를 사용했더니 자식 코루틴이 종료된 후에 부모 코루틴이 종료되는 것을 확인할 수 있다.

 

Naming coroutines for debugging

자동으로 할당되는 id는 로그를 확인할 때 어떤 로그가 동일한 코루틴에서 발생한 로그인지 코루틴 식별을 하는데 유용하다. 하지만 코루틴이 특정 요청의 수행 과정에 연관되어 있거나, 특정 백그라운드 테스크를 수행 중이라면 이름을 명시적으로 지정하는 것이 디버깅에 도움이 된다.

CoroutineName이라는 컨텍스트 요소는 스레드 이름을 코루틴의 이름과 동일하게 지정해준다. 만약 디버깅 모드이면 CoroutineName은 이 코루틴을 실행 중인 스레드의 이름과 함께 나타난다.

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

위의 코드를 실행하면 다음과 같은 결과가 나온다.

[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

결과를 보면 main 뒤에 @로 시작하는 스레드의 이름이고 이 스레드의 이름은 각 코루틴에 지정한 이름이라는 것을 확인할 수 있다.

 

Combining context elements

코루틴 컨텍스트에 여러 개의 요소를 지정해야 하는 경우가 있는데 이 때는 + 연산자를 사용하면 코루틴 요소들을 병합하여 같이 사용할 수 있다.

ex) 코루틴의 디스패처와 이름을 동시에 적용

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

Dispatcher.Default와 코루틴의 이름 test가 같이 적용된 것을 확인 할 수 있다.

 

Coroutine scope

코루틴은 코루틴 스코프 내에서 동작한다. GlobalScope를 사용하여 코루틴을 생성하면 그 코루틴은 앱이 시작되고, 종료 될 때 까지 유지가 된다. 그런데 GlobalScope로 생성된 코루틴은 메모리 누수의 원인이 될 수 있어서 코루틴이 어느 시점에서 어느 시점까지 동작 하는지에 따라서 코루틴 스코프를 지정하면 좋다.

애플리케이션에 생명주기를 갖는 객체가 있고, 이 객체는 코루틴이 아니라고 가정하자. (안드로이드에서는 액티비티나 프래그먼트로 가정)

예를 들어 안드로이드 애플리케이션을 만들고 있고, 안드로이드 액티비티 컨텍스트에서 데이터 가져오기, 업데이트, 애니메이션 수행 등의 코루틴을 수행한다. 이러한 모든 코루틴들은 메모리 누수를 피하기 위해서 액티비티가 종료될 때 같이 취소가 되어야 한다.

코루틴의 수명 주기를 관리하기 위해서 액티비티의 수명 주기에 연결된 코루틴 스코프 인스턴스를 생성하는데 코루틴 스코프 인스턴스는 CoroutineScope() 또는 MainScope() 함수로 만들 수 있다.

CoroutineScope() 는 범용 범위를 만들고, MainScope() 는 UI 응용 프로그램에 대한 범위를 만든다. 그리고 Dispatchers.Main을 기본 디스패처로 사용한다.

밑의 예시는 CoroutineScope를 사용하여 코루틴 스코프와 액티비티의 생명주기를 일치 시켰다.

그래서 액티비티가 생성되면 코루틴이 생성이 되고, 액티비티 사용이 끝나면 코루틴이 해제가 된다.

class Activity {
    private val mainScope = CoroutineScope(Dispatchers.Default) // use Default for test purposes
    
    fun destroy() {
        mainScope.cancel()
    }

    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

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!

메인 함수는 액티비티를 생성하고, doSomething() 함수를 실행한 후에 500ms 후에 액티비티를 destory 한다. 이렇게 하면 doSomething() 에서 시작된 모든 코루틴이 취소가 된다. 이는 액티비티가 destory 되고, 1s를 기다려도 추가적인 출력이 없음으로 알 수 있다.

 

Thread-local data

스레드 로컬은 한 스레드에서 동작하는 동안 전역 변수처럼 어떤 것을 저장하여 사용할 수 있다. 스레드 로컬을 이용하면 쓰레드 영역에 변수를 설정할 수 있기 때문에, 특정 쓰레드가 실행하는 모든 코드에서 그 쓰레드에 설정된 변수 값을 사용할 수 있게 된다.

스레드 로컬 데이터를 코루틴으로 전달하거나, 코루틴 간에 전달하는 기능이 유용할 때가 있다. 그런데 코루틴은 특정 스레드에 국한되지 않기 때문에 이를 구현하려면 복잡하다.

이럴 때 asContextElement 확장 함수를 사용할 수 있다. 이 함수를 사용하면 주어진 스레드 로컬 값을 유지하고, 코루틴이 컨텍스트를 전환할 때 마다 복원하는 추가 컨텍스트 요소를 생성한다.