Coroutine Context and Dispatchers
코루틴은 항상 Kotlin 표준 라이브러리에 정의된 컨텍스트에서 실행된다. 코루틴 context에는 다양한 요소가 있으며, 그중 가장 중요한 요소는 이전 포스팅에서 다룬 job
과 이번 포스팅에 다룰 Dispatchers
이다.
※ IntelliJ나 Android Studio가 설치되어 있지 않다면 Kotlin 공식 사이트에서 지원하는 온라인 IDE(Kotlin Playground)를 사용하여 아래 코드를 실행할 수 있다.
Dispatchers and threads
코루틴은 코루틴 컨텍스트에서 실행되는데, 코루틴 컨텍스트에 이 Dispatchers
가 있다. Dispatchers
는 코루틴을 어떤 스레드 혹은 스레드풀에서 실행할지 결정한다. 모든 코루틴 빌더는 optional로 CoroutineContext
parameter를 가지고 있다.
1 | fun main() = runBlocking<Unit> { |
위 코드 실행 결과는 다음과 같은데,
1 | Unconfined : I'm working in thread main |
왠지 모르게 복잡해 보이는 이름을 출력한 Dispatchers.Default
가 특별(?)하게 느껴질 수도 있다. 그러나 Dispatcher.Default
는 global scope에서 실행했던 코루틴들이 실행되는 스레드를 뜻한다(==기본 스레드). 그러니까, global로 실행하나 Dispatchers.Default
로 실행하나 결국 같은 스레드라는 의미이다.
코루틴을 생성할 때마다 newSingleThreadContext
를 만드는 방식은 비용이 높은 방식이다. 새차원님의 강의에 따르면, 위 예시의 방식으로 작성하는 것보다 다음과 같이
1 | newSingleThreadContext().use { |
사용하는 것이 좋다고 한다. 왜냐하면 newSingleThreadContext
를 호출하면 새로운 스레드를 만들게 되고, 이 경우 close
를 하지 않으면 메모리 누수가 발생할 수 있기 때문이다. use
를 사용할 경우 close
처리를 알아서 해 주기 때문에 이 방식을 권장한다.
그리고 당연히 Dispatchers
를 지정하지 않아도 코루틴 실행에는 문제가 없다.
Unconfined vs confined dispatcher
Dispatchers.Unconfined
는 호출한 스레드에서 코루틴을 시작하지만, 첫번째 suspension point까지만 그 스레드에 머물러 있다. 중단 이후 코루틴이 재개(resume)되면 resume을 명령한 코루틴 스레드에서 수행된다. Dispatchers.Unconfined
는 어떤 스레드에서 동작해도 무관할 때에 사용하면 된다. 즉, Dispatchers.Unconfined
는 시작 스레드와 종료 스레드가 같지 않기 때문에 프로그램의 규모가 커질 경우 어디서 종료될지 예측이 어려워진다. 도큐먼트에서는 특수한 상황(사실 어떤 상황에 도움이 될지 잘 모르겠다.)에는 도움이 될 수 있는 진보된 매커니즘이라고 소개하는데, 웬만해서 사용하지 않는 것이 좋을 것 같다.
반면, 코루틴 컨텍스트 요소들은 보통 부모 코루틴 스코프의 컨텍스트 요소가 자식 컨텍스트 요소에게 상속된다. 특히 runBlocking
코루틴의 기본 디스패처는 호출한 스레드에 국한되기 때문에 이를 상속하는 것은 그 스레드에만 국한되도록 하는 효과가 있다. 예측 가능한 FIFO 스케줄링이 필요할 때 confined dispatcher를 사용하면 된다.
1 | fun main() = runBlocking<Unit> { |
위의 코드를 실행해 보면 다음의 결과를 볼 수 있다.
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 명에 추가로 코루틴 이름까지 출력되는 것을 확인할 수 있다.
코루틴 디버거를 사용하여 디버그 모드로 애플리케이션을 실행하면 다음과 같은 기능을 사용할 수 있다.
- 각 코루틴의 상태 확인
- 실행 중인 코루틴과 일시 중지된 코루틴 모두에 대한 변수 값 확인
- 전체 코루틴 생성 스택과 내부 호출 스택 확인
- 각 코루틴의 상태와 스택이 포함된 전체 정보
[참고] Android Studio에 VM 옵션 설정하기
- Help > Edit Custom VM Options 클릭
- VM 옵션을 한 번도 수정한 적이 없다면 새로운
studio.vmoptions
를 생성하라는 메시지가 출력됨 → Yes 클릭 - 오픈된
studio.vmoptions
파일에 위의 VM 옵션을 추가한다.
자세한 내용은 공식 도큐먼트를 참고하자.
Debugging using logging
디버거가 없는 애플리케이션을 디버깅하기 위해 로그에 스레드 이름을 출력하는 방법이 있다. JVM 옵션을 추가하는 위의 방식이 더 발전된 방식이기에 위의 방법을 권장한다.
1 | fun log(msg: String) = println("[${Thread.currentThread().name}] $msg") |
위 코드의 출력 결과는 다음과 같다.
1 | [main @coroutine#2] I'm computing a piece of the answer |
Jumping between threads
코루틴이 처음 실행했던 스레드에서 벗어나서 다른 스레드에 갔다가 다시 처음 스레드로 복귀하는 형태의 예제이다. 예를 들어, 안드로이드 메인 스레드에서 백그라운드 스레드에 갔다가 다시 메인스레드로 돌아오는 듯한 기능을 쉽게 만들 수 있게 해 주는 중요한 기능이다.
1 | fun log(msg: String) = println("[${Thread.currentThread().name}] $msg") |
여기서 가장 중요한 부분은 withContext()
이다. withContext()
에 스레드를 지정해 주면 그 스레드에서 코루틴을 실행하고, 실행이 끝나면 다시 원래 스레드(ctx1)로 돌아온다. 실행 결과는 다음과 같다.
1 | ## Ctx1에서 Started in ctx1 로그 출력 |
Job in the context
코루틴 컨텍스트에서 Job
을 탐색해 보자.
1 | fun main() = runBlocking<Unit> { |
위 코드를 실행하면 다음과 같이 출력된다.
1 | My job is "coroutine#1":BlockingCoroutine{Active}@573fd745 |
이건, “코루틴 컨텍스트에서 Job
엘리먼트를 꺼내 보았더니 존재(BlockingCoroutine
로)하더라.” 라는 의미이다.
Children of a coroutine
코루틴이 실행될 때 Job
사이에 부모-자식 관계가 있다는 것을 보여주는 예제이다. 하나의 새로운 코루틴이 실행되면 그 코루틴은 부모 코루틴의 자식이 된다.
단, GlobalScope.launch
는 부모-자식 관계가 없는 독립 스코프로, 별도로 Job
이 생성된다.
1 | fun main() = runBlocking<Unit> { |
부모 코루틴이 취소되면 자식 코루틴 또한 취소된다. 그래서 위 예제의 결과는
1 | job1: I run in GlobalScope and execute independently! [DefaultDispatcher-worker-1] |
위와 같이 출력되며, job2: I will not execute this line if my parent request is cancelled
로그는 출력되지 않는다.
Parental responsibilities
부모 코루틴은 모든 자식 코루틴의 실행 완료를 기다리기 때문에 join
을 사용하지 않아도 된다.
1 | fun main() = runBlocking<Unit> { |
13 라인의 request.join()
을 지우고 위의 코드를 실행해 보면 좋을 것 같다. 부모가 자식 코루틴의 실행을 기다리기 때문에 join()
없이도 정상 동작함을 확인할 수 있다.
Naming coroutines for debugging
디버깅을 위한 이름 설정 팁을 알려주는 예시이다. CoroutineName
이라는 컨텍스트 요소를 사용하면 스레드 이름과 동일하게 코루틴 이름을 지정할 수 있다. 만일 디버깅 모드라면 CoroutineName
은 코루틴 수행 중인 스레드 이름에 함께 출력된다.
1 | fun main(args: Array<String>) = runBlocking<Unit> { |
결과는 다음과 같다.
1 | [main @coroutine#1] Started main coroutine |
모든 코루틴이 main
스레드에서 실행된 사실과 함께 CoroutineName()
을 이용하여 지정해준 이름이 로그에 출력됨을 확인할 수 있었다.
Combining context elements
CoroutineContext에 여러 요소를 정의할 수 있으며, 이때 +
연산을 사용할 수 있다. 아래 예제에서는 Dispatchers
와 CoroutineName
을 함께 사용하였다.
1 | fun main() = runBlocking<Unit> { |
다음과 같은 실행 결과를 얻을 수 있다.
1 | I'm working in thread DefaultDispatcher-worker-1 @test#2 |
Coroutine Scope
생명 주기가 있는 객체에서 코루틴을 사용할 땐 메모리 누수 방지를 위해 꼭 코루틴을 취소해 주어야 한다. 아래 코드와 같이 하나의 코루틴 스코프를 생성하고, 그 스코프를 이용하여 동작하도록 하는 방식으로 개발하는 것이 유지보수에 용이하다.
1 | class Activity { |
28 라인의 activity.destroy()
를 없애고, 29 라인의 delay(1000)
을 delay(3000)
으로 변경하여 테스트해 보는 것도 의미가 있다. 3초 후에 main()
이 끝나야 하지만 코루틴 스코프를 취소하지 않기에 코루틴이 계속 동작한다. (메모리 누수 발생 위험)
참고
ktx 유저는 lifecycleScope
와 viewmodelScope
를 사용하는 것이 좋다. 취소 작업을 알아서 처리하기 때문에 별도로 작업하지 않아도 된다.
Thread-local data
가끔 스레드 로컬 데이터를 코루틴으로 전달하거나 혹은 코루틴 간에 전달하는 기능이 유용할 때가 있다. 그러나 코루틴은 특정 스레드에 국한되어 실행되지 않음으로, 이런 기능을 직접 구현하기 위해선 많은 작업이 필요하다.
ThreadLocal
을 위해 asContextElement()
확장 함수를 사용할 수 있다. 이 함수는 ThreadLocal
의 값을 저장했다가 코루틴이 속한 컨텍스트가 변경될 때마다 해당 값을 복원한다. (조금 다르지만 static
변수로 저장하는 느낌! 그런 느낌으로만 이해했다.)
1 | val threadLocal = ThreadLocal<String?>() // declare thread-local variable |
실행 결과는 다음과 같다.
1 | Pre-main, current thread: Thread[main @coroutine#1,5,main], thread local value: 'main' |
그런데 이 방식은 코루틴을 실행하는 스레드가 다를 경우 코루틴에서 접근한 스레드 로컬 변수는 예상하지 못한 값을 들고 있을 수도 있다. 이런 상황을 방지하기 위해서 ensurePresent
메서드를 사용하고, 부적절한 사용시 fail-fast
를 사용하는 것이 좋다.
스레드 로컬 변수에 다른 값을 업데이트하고 싶다면 withContext
를 사용하면 된다. 자세한 내용은 asContextElement
를 참조하자.
참고
- 코루틴 공식 문서 - Coroutine Context and Dispatchers
- 새차원의 코틀린 코루틴 강좌 #6 - Coroutine Context and Dispatchers
- 아키텍처 구성요소와 함께 Kotlin 코루틴 사용
- 코루틴 공식 가이드 자세히 읽기 - Part 5
- [Android] 코루틴 Coroutine Context and Dispatchers