이동식 저장소

[Kotlin] Coroutines - Context and Dispatchers 본문

Primary/Kotlin

[Kotlin] Coroutines - Context and Dispatchers

해스끼 2021. 1. 21. 11:33

목차

코루틴은 항상 CoroutineContext 문맥 안에서 실행된다. 코루틴 문맥에는 여러 원소가 포함되는데, 대표적인 것으로 Job과 dispatcher가 있다.

Dispatcher와 스레드

코루틴 문맥에는 코루틴이 어느 스레드에서 동작할지를 결정하는 CoroutineDispatcher가 포함된다. dispatcher는 코루틴이 특정 스레드에서 실행되도록 할 수도 있고, 스레드 풀에서 실행되도록 할 수도 있고, 또는 어느 스레드에서 실행될지 결정하지 않은 상태로 놔 둘 수도 있다. launchasync 같은 모든 코루틴 빌더에서 CoroutineContext 매개변수를 지정할 수 있다.

launch { // parent(main)에서 작동
    println("main runBlocking      : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Unconfined) { // 결정되지 않음: main에서 작동할 것
    println("Unconfined            : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Default) { // DefaultDispatcher가 결정함
    println("Default               : I'm working in thread ${Thread.currentThread().name}")
}
launch(newSingleThreadContext("MyOwnThread")) { // 자기만의 스레드를 생성
    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

코루틴 빌더를 매개변수 없이 사용하면 자신을 실행한 부분의 CoroutineScopeCoroutineContext를 상속받는다. 위의 코드에서는 runBlocking을 실행한 main 스레드를 상속받았다.

Dispatchers.Unconfined는 언뜻 보면 옵션을 지정하지 않은 경우와 같아 보이지만, 실제로는 조금 다른 방법으로 작동한다. 밑에서 자세히 설명할 것이다.

Dispatchers.DefaultGlobalScope에서 기본값으로 사용되는 dispatcher이다. 따라서 launch(Dispatchers.Default) {...}GlobalScope.launch {...}와 같은 dispatcher를 사용한다.

newSingleThreadContext는 코루틴을 실행할 별도의 스레드를 만든다. 사실 이렇게 만들어진 스레드는 매우 무겁기 때문에, 코루틴이 종료된 후 스레드를 close 메소드로 닫던가 아니면 따로 저장해놓고 나중에 재활용하던가 해야 한다.

Unconfined vs. confined

Dispatchers.Unconfined로 지정된 코루틴은 첫 번째 suspending 함수가 호출되기 전까지만 자신을 실행한 스레드에서 실행된다. 그 후에는 호출된 suspending 함수가 지정한 스레드에서 실행된다. Dispatchers.Unconfined는 CPU를 적게 사용하면서 특정 스레드에 종속된 공유 데이터에도 접근하지 않는 코루틴에 적합하다.

Dispatcher를 지정하지 않는 경우에는 바깥의 CoroutineScope가 상속된다. 아래의 예시에서는 runBlocking 코루틴의 dispatcher가 main 스레드에 종속되었기 때문에, 이 dispatcher를 상속받으면 코루틴의 실행이 main 스레드에 종속된다.

launch(Dispatchers.Unconfined) { // main에서 작동되다가 delay 이후에는 다른 스레드에서 작동
    println("Unconfined      : I'm working in thread ${Thread.currentThread().name}")
    delay(500)
    println("Unconfined      : After delay in thread ${Thread.currentThread().name}")
}
launch { // runBlocking의 스레드(main)에서 작동
    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

Dispatcher.Unconfined로 지정된 코루틴은 delay 함수가 실행된 이후에는 다른 스레드에서 작동한다. 사실 저 스레드는 delay 함수가 실행된 스레드이다.

다만 코틀린 공식 문서에서는 일반적인 경우 Dispatcher.Unconfined의 사용을 권장하지 않는다. 원문은 다음과 같다.

The unconfined dispatcher is an advanced mechanism that can be helpful in certain corner cases where dispatching of a coroutine for its execution later is not needed or produces undesirable side-effects, because some operation in a coroutine must be performed right away. The unconfined dispatcher should not be used in general code.

코루틴과 스레드 디버깅

코루틴이 중단된 후 다시 실행될 때 다른 스레드에서 실행될 수도 있다. 물론 하나의 스레드에서만 동작하는 경우도 있지만, 코루틴 특성상 코루틴의 실행 순서 등을 따라가기 쉽지 않다.

IntelliJ IDEA에서 디버깅

kotlinx-coroutines-core 1.3.8 이후 버전을 설치했다면 코루틴 디버거를 사용할 수 있다. 참고로 2021년 1월 21일 기준 최신 stable 버전은 1.4.2이다.

하단의 Debug 창에 Coroutines 탭이 포함되어 있다. 여기에서 현재 실행 중이거나 중단된 코루틴을 확인할 수 있다.

코루틴 디버거

코루틴 디버거로 다음의 작업을 수행할 수 있다.

  • 코루틴의 상태를 확인할 수 있다.
  • 코루틴 내부의 변수값을 확인할 수 있다.
  • 코루틴이 만들어진 과정(creation stack)과 내부의 call stack을 확인할 수 있다. Stack 안에는 모든 frame의 변수 값이 포함된다.
  • 각 코루틴과 코루틴 스택이 포함된 report를 볼 수 있다. 코루틴 탭에서 오른쪽 버튼을 클릭하고 Get Coroutines Dump를 선택하면 된다.

코루틴을 디버깅하려면 중단점을 설정한 후 프로그램을 디버그 모드로 실행해야 한다. 자세한 디버거 활용 방법은 여기에서 확인할 수 있다.

로그로 확인하기

안드로이드 앱을 개발할 때처럼 로그를 출력해 볼 수도 있다. 코루틴 안에서 로그를 찍으면 해당 코루틴의 이름과 코루틴이 실행되고 있는 스레드 이름이 함께 출력된다. 다음의 코드를 -Dkotlinx.coroutines.debug JVM 옵션을 주고 실행해 보자.

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

로그 메시지 앞에 스레드와 코루틴의 이름이 함께 출력되는 것을 확인할 수 있다.

코루틴에 이름 붙이기

스레드에 이름을 붙일 수 있듯이, 코루틴에도 디버깅 등을 위해 이름을 붙일 수 있다. 이름을 지정하지 않으면 위에서처럼 coroutine#i 형식으로 붙여지며, 이름을 지정할 경우에는 로그에 해당 이름이 출력된다.

log("Started main coroutine")
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

-Dkotlinx.coroutines.debug JVM 옵션을 사용하여 실행하면 코루틴 이름이 출력됨을 확인할 수 있다.

문맥 조합하기

지금까지 살펴본 코루틴 문맥의 원소는 dispatcher, 코루틴 이름 등이 있다. 지금까지는 한 번에 하나씩만 사용했지만, + 연산자를 이용하여 여러 원소를 모두 지정할 수 잇다.

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

-Dkotlinx.coroutines.debug JVM 옵션을 사용하면 다음과 같은 출력을 확인할 수 있다.

I'm working in thread DefaultDispatcher-worker-1 @test#2

스레드 간 이동

다음의 코드를 -Dkotlinx.coroutines.debug JVM 옵션을 주고 실행해 보자.

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

뭔가 새로운 방법이 많이 사용되었다.

  1. runBlocking을 실행할 때 코루틴 문맥을 지정할 수 있다.
  2. withContext 함수를 이용하여 코루틴 안에서 문맥을 바꿀 수 있다. 즉 코루틴이 실행되는 도중 자신이 실행될 문맥을 바꿀 수 있다.
  3. 표준 함수 use를 사용하여 스레드를 사용한 후 자동으로 close하였다.

Job 확인하기

코루틴 문맥에는 Job도 포함된다고 했다. 문맥 안의 JobcoroutineContext[Job]을 이용하여 확인할 수 있다.

println("My job is ${coroutineContext[Job]}")

이 코드를 디버깅 모드에서 실행하면 다음의 메시지를 출력한다.

My job is "coroutine#1":BlockingCoroutine{Active}@573fd745

부모-자식 관계

코루틴 안에서 새로운 코루틴을 시작하면 코루틴 문맥이 상속된다고 했다. 이때 새로 시작된 코루틴의 Job은 자신을 시작한 코루틴의 Job의 자식이 된다. 부모 코루틴이 종료되면 모든 자식 코루틴도 종료된다.

하지만 GlobalScope으로 실행된 코루틴은 예외이다. GlobalScope으로 실행된 코루틴은 어떠한 부모-자식 관계도 갖지 않으며, 자신을 실행한 코루틴과 독립적으로 실행된다.

val request = launch {
    // GlobalScope로 실행: request가 종료되어도 계속 실행됨
    GlobalScope.launch {
        println("job1: I run in GlobalScope and execute independently!")
        delay(1000)
        println("job1: I am not affected by cancellation of the request")
    }
    // 자식 코루틴: 부모의 문맥을 상속받아 실행됨
    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() // request를 취소하고
delay(1000)      // 무슨 일이 벌어지는지 잠시 기다려 보자.
println("main: Who has survived request cancellation?")
job1: I run in GlobalScope 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?

GlobalScope로 실행된 job1request가 종료된 후에도 계속 실행되지만, request의 자식 job2는 부모가 종료될 때 같이 종료된다.

부(모)성애

부모 코루틴은 모든 자식 코루틴이 완료될 때까지 기다린다. 명시적으로 job.join() 등을 하지 않아도 기다린다.

// request: 부모 코루틴
val request = launch {
    repeat(3) { i ->
        // 자식 코루틴 실행
        launch  { 
            delay((i + 1) * 200L)
            println("Coroutine $i is done")
        }
    }
    println("request: I'm done and I don't explicitly join my children that are still active")
}
request.join() // request와 request의 자식이 모두 종료될 때까지 기다림
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

부모 코루틴 request는 자신의 코드를 모두 실행했지만, 즉시 종료하지 않고 자식 launch가 모두 종료될 때까지 기다린다.

정리: 코루틴 스코프 (Coroutine scope)

안드로이드 앱을 개발하고 있다고 가정해 보자. 어떤 Activity가 실행 중일 때 코루틴 여러 개가 실행되어야 한다고 하자. 코루틴은 각각 UI 업데이트, 데이터 로드 등을 담당할 수 있다. 그러나 Activity와 관련된 코루틴은 메모리 누수를 방지하기 위해 Activity가 종료될 때 같이 종료되어야 한다. 지금까지 배운 내용으로도 코루틴 각각의 작동을 수동으로 제어할 수는 있다. 하지만 모든 코루틴의 레퍼런스를 저장해 놓고 수동으로 종료하는 방법은 매우 번거로우며 유지보수하기도 쉽지 않다.

kotlinx.coroutines 패키지에 포함된 CoroutineScope을 사용하면 여러 개의 코루틴을 간편하게 제어할 수 있다. 일반적인 목적의 CoroutineScopeCoroutineScope()로 만들 수 있다. 또는 MainScope()로 만들 수도 있는데, 이렇게 만들어진 스코프는 Dispatchers.Main dispatcher를 사용하기 때문에 UI 업데이트 등에 적합하다.

// scope 테스트용, 실제 안드로이드 코드와는 다릅니다.
class TestActivity {
    private val mainScope = MainScope()

    // ...

    fun destroy() {
        mainScope.cancel()
    }

    fun doSomething() {
        // 코루틴 10개 실행
        repeat(10) { i ->
            // mainScope 안에서 코루틴 실행
            mainScope.launch {
                delay((i + 1) * 200L) // 각기 다른 시간 동안 실행됨
                println("Coroutine $i is done")
            }
        }
    }
}

이제 main 함수에서 TestActivity 객체를 만들고, doSomething 함수를 호출한 다음 500ms 후에 activity 객체를 제거할 것이다. 코루틴 스코프에 cancel 명령이 내려지면 스코프에 속한 모든 코루틴이 종료된다.

val activity = Activity()
activity.doSomething()         // 코루틴 실행
println("Launched coroutines")
delay(500L)                    // 잠깐 기다렸다가
println("Destroying activity!")
activity.destroy()             // activity 종료: 모든 코루틴 종료
delay(1000)                    // 모든 코루틴이 종료되었는지 확인
Launched coroutines
Coroutine 0 is done
Coroutine 1 is done
Destroying activity!

스코프 안의 모든 코루틴이 종료되었음을 알 수 있다.

안드로이드에서 lifecycle이 있는 모든 객체는 자신만의 코루틴 스코프를 갖는다. 자세한 내용은 여기를 참고하자.

참고문헌

Coroutine Context and Dispatchers - kotlinlang

Comments