[Kotlin] 코루틴(Coroutine) Context and Dispatchers

Coroutine Context and Dispatchers

코루틴은 항상 Kotlin 표준 라이브러리에 정의된 컨텍스트에서 실행된다. 코루틴 context에는 다양한 요소가 있으며, 그중 가장 중요한 요소는 이전 포스팅에서 다룬 job과 이번 포스팅에 다룰 Dispatchers이다.

※ IntelliJ나 Android Studio가 설치되어 있지 않다면 Kotlin 공식 사이트에서 지원하는 온라인 IDE(Kotlin Playground)를 사용하여 아래 코드를 실행할 수 있다.


Dispatchers and threads

코루틴은 코루틴 컨텍스트에서 실행되는데, 코루틴 컨텍스트에 이 Dispatchers가 있다. Dispatchers는 코루틴을 어떤 스레드 혹은 스레드풀에서 실행할지 결정한다. 모든 코루틴 빌더는 optional로 CoroutineContext parameter를 가지고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun main() = runBlocking<Unit> {
// 코루틴이 main thread에서 실행됨
launch { // context of the parent, main runBlocking coroutine
println("main runBlocking : I'm working in thread ${Thread.currentThread().name}")
}
// 코루틴이 main thread에서 실행됨
launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
println("Unconfined : I'm working in thread ${Thread.currentThread().name}")
}
// 코루틴이 DefaultDispather-worker-1에서 실행됨
launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher
println("Default : I'm working in thread ${Thread.currentThread().name}")
}
// 코루틴이 MyOwnThread에서 실행됨
launch(newSingleThreadContext("MyOwnThread")) { // will get its own new thread
println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
}
}

위 코드 실행 결과는 다음과 같은데,

1
2
3
4
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

왠지 모르게 복잡해 보이는 이름을 출력한 Dispatchers.Default 가 특별(?)하게 느껴질 수도 있다. 그러나 Dispatcher.Default 는 global scope에서 실행했던 코루틴들이 실행되는 스레드를 뜻한다(==기본 스레드). 그러니까, global로 실행하나 Dispatchers.Default 로 실행하나 결국 같은 스레드라는 의미이다.

코루틴을 생성할 때마다 newSingleThreadContext 를 만드는 방식은 비용이 높은 방식이다. 새차원님의 강의에 따르면, 위 예시의 방식으로 작성하는 것보다 다음과 같이

1
2
3
4
5
newSingleThreadContext().use {
launch(it) {
println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}"
}
}

사용하는 것이 좋다고 한다. 왜냐하면 newSingleThreadContext 를 호출하면 새로운 스레드를 만들게 되고, 이 경우 close 를 하지 않으면 메모리 누수가 발생할 수 있기 때문이다. use 를 사용할 경우 close 처리를 알아서 해 주기 때문에 이 방식을 권장한다.

그리고 당연히 Dispatchers 를 지정하지 않아도 코루틴 실행에는 문제가 없다.


Unconfined vs confined dispatcher

Dispatchers.Unconfined 는 호출한 스레드에서 코루틴을 시작하지만, 첫번째 suspension point까지만 그 스레드에 머물러 있다. 중단 이후 코루틴이 재개(resume)되면 resume을 명령한 코루틴 스레드에서 수행된다. Dispatchers.Unconfined 는 어떤 스레드에서 동작해도 무관할 때에 사용하면 된다. 즉, Dispatchers.Unconfined 는 시작 스레드와 종료 스레드가 같지 않기 때문에 프로그램의 규모가 커질 경우 어디서 종료될지 예측이 어려워진다. 도큐먼트에서는 특수한 상황(사실 어떤 상황에 도움이 될지 잘 모르겠다.)에는 도움이 될 수 있는 진보된 매커니즘이라고 소개하는데, 웬만해서 사용하지 않는 것이 좋을 것 같다.

반면, 코루틴 컨텍스트 요소들은 보통 부모 코루틴 스코프의 컨텍스트 요소가 자식 컨텍스트 요소에게 상속된다. 특히 runBlocking 코루틴의 기본 디스패처는 호출한 스레드에 국한되기 때문에 이를 상속하는 것은 그 스레드에만 국한되도록 하는 효과가 있다. 예측 가능한 FIFO 스케줄링이 필요할 때 confined dispatcher를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
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}")
}
}

위의 코드를 실행해 보면 다음의 결과를 볼 수 있다.

launch 에 별다른 디스패처 세팅을 하지 않은 코루틴은 runBlocking 을 따라 호출한 스레드에 국한되어 실행된다. 반면 Dispatchers.Unconfined 세팅을 한 코루틴은 delay 전과 후에 코루틴이 실행되는 스레드가 다르다.


Debugging coroutines and threads

Debugging with IDEA

코루틴은 한 스레드에서 중단된 후 다른 스레드에서 재개될 수 있다. 단일 스레드 디스패처에서조차 어떤 코루틴이 언제, 어디서 수행 중이었는지 알아내는 건 어렵다. 일반적으로 스레드를 사용하는 애플리케이션을 디버깅할 땐 각각의 로그마다 현재 수행 중인 스레드의 이름을 붙여 출력한다. 이 기능은 보통 logging framework에서 지원하며, 코루틴 프레임워크 또한 디버깅 기능을 제공한다.

단, 디버깅 기능은 kotlinx-coroutines-core 버전 1.3.8 이상에서만 지원한다.

JVM 옵션에 -Dkotlinx.coroutines.debug 을 추가하면 Thread 명에 추가로 코루틴 이름까지 출력되는 것을 확인할 수 있다.

코루틴 디버거를 사용하여 디버그 모드로 애플리케이션을 실행하면 다음과 같은 기능을 사용할 수 있다.

  1. 각 코루틴의 상태 확인
  2. 실행 중인 코루틴과 일시 중지된 코루틴 모두에 대한 변수 값 확인
  3. 전체 코루틴 생성 스택과 내부 호출 스택 확인
  4. 각 코루틴의 상태와 스택이 포함된 전체 정보

[참고] Android Studio에 VM 옵션 설정하기

  1. Help > Edit Custom VM Options 클릭
  2. VM 옵션을 한 번도 수정한 적이 없다면 새로운 studio.vmoptions 를 생성하라는 메시지가 출력됨 → Yes 클릭
  3. 오픈된 studio.vmoptions 파일에 위의 VM 옵션을 추가한다.

자세한 내용은 공식 도큐먼트를 참고하자.


Debugging using logging

디버거가 없는 애플리케이션을 디버깅하기 위해 로그에 스레드 이름을 출력하는 방법이 있다. JVM 옵션을 추가하는 위의 방식이 더 발전된 방식이기에 위의 방법을 권장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
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()}")
}

위 코드의 출력 결과는 다음과 같다.

1
2
3
[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

Jumping between threads

코루틴이 처음 실행했던 스레드에서 벗어나서 다른 스레드에 갔다가 다시 처음 스레드로 복귀하는 형태의 예제이다. 예를 들어, 안드로이드 메인 스레드에서 백그라운드 스레드에 갔다가 다시 메인스레드로 돌아오는 듯한 기능을 쉽게 만들 수 있게 해 주는 중요한 기능이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

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

여기서 가장 중요한 부분은 withContext() 이다. withContext() 에 스레드를 지정해 주면 그 스레드에서 코루틴을 실행하고, 실행이 끝나면 다시 원래 스레드(ctx1)로 돌아온다. 실행 결과는 다음과 같다.

1
2
3
4
5
6
## Ctx1에서 Started in ctx1 로그 출력
[Ctx1 @coroutine#1] Started in ctx1
## Ctx2에서 Working in ctx2 로그 출력
[Ctx2 @coroutine#1] Working in ctx2
## 다시 Ctx1으로 돌아와서 Back to ctx1 로그 출력
[Ctx1 @coroutine#1] Back to ctx1

Job in the context

코루틴 컨텍스트에서 Job 을 탐색해 보자.

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

위 코드를 실행하면 다음과 같이 출력된다.

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

이건, “코루틴 컨텍스트에서 Job 엘리먼트를 꺼내 보았더니 존재(BlockingCoroutine 로)하더라.” 라는 의미이다.


Children of a coroutine

코루틴이 실행될 때 Job 사이에 부모-자식 관계가 있다는 것을 보여주는 예제이다. 하나의 새로운 코루틴이 실행되면 그 코루틴은 부모 코루틴의 자식이 된다.

단, GlobalScope.launch 는 부모-자식 관계가 없는 독립 스코프로, 별도로 Job 이 생성된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fun main() = runBlocking<Unit> {
// launch a coroutine to process some kind of incoming request
val request = launch {
// it spawns two other jobs, one with GlobalScope
GlobalScope.launch {
println("job1: I run in GlobalScope 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()
delay(1000) // delay a second to see what happens
println("main: Who has survived request cancellation?")
}

부모 코루틴이 취소되면 자식 코루틴 또한 취소된다. 그래서 위 예제의 결과는

1
2
3
4
5
job1: I run in GlobalScope and execute independently! [DefaultDispatcher-worker-1]
job2: I am a child of the request coroutine [main]
## 부모 코루틴 취소 요청 이후 job1은 취소되지 않음(부모-자식 관계 X)
job1: I am not affected by cancellation of the request [DefaultDispatcher-worker-2]
main: Who has survived request cancellation? [main]

위와 같이 출력되며, job2: I will not execute this line if my parent request is cancelled 로그는 출력되지 않는다.


Parental responsibilities

부모 코루틴은 모든 자식 코루틴의 실행 완료를 기다리기 때문에 join 을 사용하지 않아도 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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")
}

13 라인의 request.join() 을 지우고 위의 코드를 실행해 보면 좋을 것 같다. 부모가 자식 코루틴의 실행을 기다리기 때문에 join() 없이도 정상 동작함을 확인할 수 있다.


Naming coroutines for debugging

디버깅을 위한 이름 설정 팁을 알려주는 예시이다. CoroutineName 이라는 컨텍스트 요소를 사용하면 스레드 이름과 동일하게 코루틴 이름을 지정할 수 있다. 만일 디버깅 모드라면 CoroutineName 은 코루틴 수행 중인 스레드 이름에 함께 출력된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun main(args: Array<String>) = runBlocking<Unit> {

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

결과는 다음과 같다.

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

모든 코루틴이 main 스레드에서 실행된 사실과 함께 CoroutineName() 을 이용하여 지정해준 이름이 로그에 출력됨을 확인할 수 있었다.


Combining context elements

CoroutineContext에 여러 요소를 정의할 수 있으며, 이때 + 연산을 사용할 수 있다. 아래 예제에서는 DispatchersCoroutineName 을 함께 사용하였다.

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

다음과 같은 실행 결과를 얻을 수 있다.

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

Coroutine Scope

생명 주기가 있는 객체에서 코루틴을 사용할 땐 메모리 누수 방지를 위해 꼭 코루틴을 취소해 주어야 한다. 아래 코드와 같이 하나의 코루틴 스코프를 생성하고, 그 스코프를 이용하여 동작하도록 하는 방식으로 개발하는 것이 유지보수에 용이하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Activity {
// mainScope라는 코루틴 스코프 생성
private val mainScope = CoroutineScope(Dispatchers.Default)

fun destroy() {
// 화면 destory시 mainScope cancel
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()
delay(1000) // visually confirm that they don't work
}

28 라인의 activity.destroy() 를 없애고, 29 라인의 delay(1000)delay(3000) 으로 변경하여 테스트해 보는 것도 의미가 있다. 3초 후에 main() 이 끝나야 하지만 코루틴 스코프를 취소하지 않기에 코루틴이 계속 동작한다. (메모리 누수 발생 위험)

참고

ktx 유저는 lifecycleScopeviewmodelScope 를 사용하는 것이 좋다. 취소 작업을 알아서 처리하기 때문에 별도로 작업하지 않아도 된다.


Thread-local data

가끔 스레드 로컬 데이터를 코루틴으로 전달하거나 혹은 코루틴 간에 전달하는 기능이 유용할 때가 있다. 그러나 코루틴은 특정 스레드에 국한되어 실행되지 않음으로, 이런 기능을 직접 구현하기 위해선 많은 작업이 필요하다.

ThreadLocal 을 위해 asContextElement() 확장 함수를 사용할 수 있다. 이 함수는 ThreadLocal 의 값을 저장했다가 코루틴이 속한 컨텍스트가 변경될 때마다 해당 값을 복원한다. (조금 다르지만 static 변수로 저장하는 느낌! 그런 느낌으로만 이해했다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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()}'")
// asContextElement 주목
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()}'")
}

실행 결과는 다음과 같다.

1
2
3
4
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 메서드를 사용하고, 부적절한 사용시 fail-fast 를 사용하는 것이 좋다.

스레드 로컬 변수에 다른 값을 업데이트하고 싶다면 withContext 를 사용하면 된다. 자세한 내용은 asContextElement 를 참조하자.


참고


Share