Search

Coroutine context and dispatchers

코루틴은 항상 CoroutineContext 타입의 값으로 표현되는 특정 컨텍스트에서 실행된다.
이 컨텍스트는 다양한 요소들로 구성되어 있으며 주요 요소로는 코루틴의 JobCoroutineDispatcher가 있다.

디스패처와 스레드

코루틴 컨텍스트에는 코루틴이 실행될 스레드를 결정하는 CoroutineDispatcher가 포함된다.
디스패처는 코루틴 실행을 특정 스레드에 제한할 수 있고 스레드 풀로 전달하거나 구속받지 않고 실행되도록 할 수 있다.
모든 코루틴 빌더(launch, async 등)는 CoroutineContext 파라미터를 선택적으로 받아서 새로운 코루틴에 디스패처나 다른 컨텍스트 요소를 명시적으로 지정할 수 있다.
launch { // 부모 컨텍스트, main runBlocking 코루틴의 컨텍스트 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
Kotlin
복사
파라미터 없는 launch { … }
실행 중인 코루틴 스코프에서 컨텍스트(디스패처 포함)를 상속받는다.
이 경우 main runBlocking 코루틴의 컨텍스트를 상속받아 메인 스레드에서 실행된다.
Dispatchers.Unconfined
메인 스레드에서 실행되는 것처럼 보이지만 사실은 다른 메커니즘이다.
Dispatchers.Default
명시적인 디스패처가 지정되지 않은 경우 사용되는 기본 디스패처이다.
백그라운드의 공유 스레드 풀을 사용한다.
newSingleThreadContext
코루틴이 실행될 새로운 스레드를 생성한다.
전용 스레드는 매우 비싼 자원이므로 실제 어플리케이션은 더 이상 필요하지 않을 때 close 함수로 해제하거나 최상위 변수로 저장하여 어플리케이션 전반에 걸쳐 재사용해야 한다.

제한되지 않은 디스패처 vs 제한된 디스패처

Dispatchers.Unconfined 코루틴 디스패처는 호출자 스레드에서 코루틴을 시작하지만 첫 번째 중단 지점까지만 실행된다.
중단 후에는 중단된 함수를 호출한 스레드에 따라 코루틴이 재개된다.
Unconfined 디스패처는 CPU 시간을 소비하지 않거나 특정 스레드에 구속된 공유 데이터를 업데이트하지 않는 코루틴에 적합하다. (UI 업데이트 등)
기본적으로 디스패처는 외부 CoroutineScope에서 상속받는다.
특히 runBlocking 코루틴의 기본 디스패처는 호출자 스레드에 구속되므로 이를 상속받으면 해당 스레드에서 예측 가능한 FIFO(선입선출) 방식으로 실행된다.
launch(Dispatchers.Unconfined) { // 구속되지 않음 -- 메인 스레드에서 실행 println("Unconfined : I'm working in thread ${Thread.currentThread().name}") delay(500) println("Unconfined : After delay in thread ${Thread.currentThread().name}") } launch { // 부모 컨텍스트, main runBlocking 코루틴의 컨텍스트 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
Kotlin
복사
runBlocking { … } 의 컨텍스트를 상속받은 코루틴은 계속해서 메인 스레드에서 실행되지만 Unconfined 디스패처를 사용한 코루틴은 delay 함수가 사용되는 기본 실행자 스레드에서 재개된다.
Unconfined 디스패처는 코루틴의 실행을 나중에 재개할 필요가 없거나 재개하는 것이 바람직하지 않은 부작용을 초래하는 특정 상황에서 유용한 고급 메커니즘이다.
그러나 일반적인 코드에서는 Unconfined 디스패처를 사용하는 것은 권장되지 않는다.

코루틴과 스레드 디버깅

코루틴은 한 스레드에서 중단되고 다른 스레드에서 재개될 수 있다.
단일 스레드 디스패처를 사용하더라도 특별한 도구가 없으면 코루틴이 무엇을 하고 있었는지, 어디에서, 언제 실행되고 있었는지 파악하기 어려울 수 있다.

IDEA로 디버깅하기

Kotlin 플러그인의 코루틴 디버거는 IntelliJ IDEA에서 코루틴 디버깅을 간소화한다. 디버깅은 kotlinx-coroutines-core의 버전 1.3.8 이상에서 작동한다.
코루틴 디버깅
각 코루틴의 상태를 확인할 수 있다.
실행 중인 코루틴과 중단된 코루틴의 로컬 변수와 캡쳐된 변수의 값을 볼 수 있다.
코루틴 생성 스택코루틴 내부의 호출 스택을 볼 수 있다.
이 스택은 변수 값이 포함된 모든 프레임을 포함하며 일반 디버깅 시 잃어버릴 수 있는 값도 포함된다.
각 코루틴의 상태와 스택을 포함하는 전체 보고서를 얻을 수 있다.
이를 얻으려면 코루틴 탭 내에서 마우스 우클릭을 하고 Get Coroutines Dump를 클릭하라.

로깅을 통한 디버깅

코루틴 디버거 없이 스레드가 포함된 어플리케이션을 디버깅하는 또 다른 방법은 각 로그 문에 대해 로그 파일에 스레드 이름을 출력하는 것이다.
이 기능은 모든 로깅 프레임워크에서 보편적으로 지원된다.
코루틴을 사용할 때는 스레드 이름만으로는 많은 맥락을 제공하지 않으므로 kotlinx.coroutines에서는 이를 쉽게 해주는 디버깅 기능을 포함하고 있다.
디버깅 기능을 사용하려면 JVM 옵션으로 -Dkotlinx.coroutines.debug 를 사용하라.
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
Kotlin
복사
log 함수는 대괄호 안에 스레드 이름을 출력하며 현재 실행 중인 코루틴의 식별자가 추가된 메인 스레드를 볼 수 있다.
이 식별자는 디버깅 모드가 활성화되면 생성된 모든 코루틴에 연속적으로 할당된다.
디버깅 모드는 JVM을 -ea 옵션으로 실행할 때도 활성화된다.

스레드 간 점프

import kotlinx.coroutines.* 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") } } } } // 출력: (디버깅 모드로) // [Ctx1 @coroutine#1] Started in ctx1 // [Ctx2 @coroutine#1] Working in ctx2 // [Ctx1 @coroutine#1] Back to ctx1
Kotlin
복사
newSingleThreadContext를 통해 명시적으로 지정된 컨텍스트와 함께 runBlocking을 사용하여 코루틴 내에서 코루틴의 컨텍스트를 변경한다.
또한, withContext 함수를 사용하여 동일한 코루틴 내에서 코루틴의 컨텍스트를 변경한다.
Kotlin 표준 라이브러리의 use 함수를 사용하면 newSingleThreadContext로 생성된 스레드를 더 이상 필요하지 않을 때 해제할 수 있다.

컨텍스트의 Job

println("My job is ${coroutineContext[Job]}") // 출력: // My job is "coroutine#1":BlockingCoroutine{Active}@6d311334
Kotlin
복사
코루틴의 Job은 코루틴 컨텍스트의 일부이며 coroutineContext[Job] 표현식을 사용하여 이를 가져올 수 있다.
CoroutineScope에서 isActivecoroutineContext[Job]?.isActive == true의 편리한 축약이다.

코루틴의 자식

다른 코루틴의 CoroutineScope에서 코루틴이 시작되면 CoroutineScope.coroutineContext를 통해 부모 코루틴의 컨텍스트를 상속받으며 새 코루틴의 Job은 부모 코루틴의 Job의 자식이 된다.
부모 코루틴이 취소되면 그 자식 코루틴들도 재귀적으로 모두 취소된다.
// 어떤 요청을 처리하기 위해 코루틴을 시작 val request = launch { // 두 개의 다른 작업을 생성함 launch(Job()) { println("job1: 나는 내 자신의 Job에서 실행되고 독립적으로 실행돼!") delay(1000) println("job1: 나는 요청이 취소되어도 영향을 받지 않아") } // 다른 하나는 부모 컨텍스트를 상속받음 launch { delay(100) println("job2: 나는 요청 코루틴의 자식이야") delay(1000) println("job2: 부모 요청이 취소되면 나는 이 줄을 실행하지 않을 거야") } } delay(500) request.cancel() // 요청 처리 취소 println("main: 요청 취소 후 누가 살아남았을까?") delay(1000) // 무슨 일이 일어나는지 확인하기 위해 메인 스레드를 1초간 지연 // 출력: // job1: 나는 내 자신의 Job에서 실행되고 독립적으로 실행돼! // job2: 나는 요청 코루틴의 자식이야 // main: 요청 취소 후 누가 살아남았을까? // job1: 나는 요청이 취소되어도 영향을 받지 않아
Kotlin
복사
코루틴의 부모-자식 관계는 두 가지 방식으로 명시적으로 재정의될 수 있다.
1.
코루틴을 시작할 때 다른 스코프를 명시적으로 지정하면 부모 스코프의 Job을 상속받지 않는다.
2.
새로운 코루틴의 컨텍스트로 다른 Job 객체를 전달하면 부모 스코프의 Job을 재정의한다.
두 경우 모두 시작된 코루틴은 시작된 스코프에 묶이지 않고 독립적으로 동작한다.

부모 코루틴의 책임

// 어떤 요청을 처리하기 위해 코루틴을 시작 val request = launch { repeat(3) { i -> // 여러 자식 작업을 시작 launch { delay((i + 1) * 200L) // 200ms, 400ms, 600ms의 가변 지연 시간 println("Coroutine $i is done") } } println("request: 나는 끝났고, 여전히 활동 중인 자식들을 명시적으로 join하지 않아도 돼") } request.join() // 요청과 그 자식들이 완료될 때까지 기다림 println("이제 요청 처리가 완료되었습니다") // 출력: // request: 나는 끝났고, 여전히 활동 중인 자식들을 명시적으로 join하지 않아도 돼 // Coroutine 0 is done // Coroutine 1 is done // Coroutine 2 is done // 이제 요청 처리가 완료되었습니다
Kotlin
복사
부모 코루틴은 항상 모든 자식이 완료될 때까지 기다린다.
부모 코루틴은 시작한 자식 코루틴들을 명시적으로 추적할 필요가 없으며 자식들이 끝날 때 Job.join을 사용하여 기다릴 필요도 없다.

코루틴 이름 지정하기

log("Started main coroutine") // 두 개의 백그라운드 값 계산 실행 val v1 = async(CoroutineName("v1coroutine")) { delay(500) log("Computing v1") 6 } val v2 = async(CoroutineName("v2coroutine")) { delay(1000) log("Computing v2") 7 } 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
Kotlin
복사
자동으로 할당된 ID는 코루틴이 자주 로그를 남길 때 유용하며 동일한 코루틴에서 발생한 로그 기록을 연관시키는데 도움이 된다.
하지만 특정 요청을 처리하거나 특정 백그라운드 작업을 수행하는 코루틴이라면 디버깅 목적으로 명시적인 이름을 지정하는 것이 더 좋다.
CoroutineName 컨텍스트 요소는 스레드 이름과 같은 역할을 하며 디버깅 모드가 켜져 있을 때 이 코루틴을 실행하는 스레드 이름에 포함된다.

컨텍스트 요소 결합

launch(Dispatchers.Default + CoroutineName("test")) { println("I'm working in thread ${Thread.currentThread().name}") } // 출력: // I'm working in thread DefaultDispatcher-worker-1 @test#2
Kotlin
복사
때로는 코루틴 컨텍스트에 여러 요소를 정의해야 할 때가 있다.
이를 위해 + 연산자를 사용할 수 있다.
예를 들어, 명시적으로 지정된 디스패처와 이름을 동시에 사용하여 코루틴을 시작할 수 있다.

CoroutineScope

코루틴의 컨텍스트, 자식, 그리고 작업에 대한 지식을 바탕으로 CoroutineScope를 살펴보자.
class Activity { private val mainScope = MainScope() fun destroy() { mainScope.cancel() } // 계속 ...
Kotlin
복사
예를 들어, 안드로이드 어플리케이션에서 Activity라는 객체는 코루틴이 아니지만 해당 객체의 수명 동안 데이터를 가져오거나 업데이트하는 비동기 작업, 애니메이션 처리 등 다양한 코루틴을 실행할 수 있다.
하지만 Activity가 파괴될 때 이러한 코루틴들이 메모리 누수를 방지하기 위해 함께 취소되어야 한다.
물론, 수동으로 컨텍스트와 작업을 관리하여 Activity와 코루틴의 수명을 연결할 수 있다.
하지만 kotlinx.coroutines는 이 작업을 추상화한 CoroutineScope를 제공한다.
모든 코루틴 빌더는 CoroutineScope의 확장 함수로 선언되므로 CoroutineScope에 익숙할 것이다.
우리는 Activity의 수명에 맞춰 코루틴의 수명을 관리하기 위해 CoroutineScope 인스턴스를 생성한다.
이 인스턴스는 CoroutineScope() 또는 MainScope() 팩토리 함수로 생성할 수 있다.
CoroutineScope일반 용도의 스코프를 생성하고 MainScopeUI 어플리케이션에 적합한 스코프를 생성하며 기본 디스패처로 Dispatchers.Main을 사용한다.
// class Activity 계속 fun doSomething() { // 데모를 위해 10개의 코루틴 실행, 각각 다른 시간 동안 작업 수행 repeat(10) { i -> mainScope.launch { delay((i + 1) * 200L) // 가변 딜레이: 200ms, 400ms, ... 등 println("Coroutine $i is done") } } } } // class Activity 끝
Kotlin
복사
이제 정의된 mainScope를 사용해 이 Activity의 스코프에서 코루틴을 실행할 수 있다.
val activity = Activity() activity.doSomething() // 테스트 함수 실행 println("Launched coroutines") delay(500L) // 반초 대기 println("Destroying activity!") activity.destroy() // 모든 코루틴 취소 delay(1000) // 더 이상 동작하지 않는 것을 시각적으로 확인 // 출력: // Launched coroutines // Coroutine 0 is done // Coroutine 1 is done // Destroying activity!
Kotlin
복사
main 함수에서는 Activity를 생성하고 doSomething 함수를 호출한 뒤 500ms 후에 Activity를 파괴하여 모든 코루틴을 취소한다.
Activity가 파괴된 후 더 기다려봐도 더 이상 메세지가 출력되지 않는 것을 확인할 수 있다.
안드로이드에서는 모든 생명 주기를 가진 엔티티에 대해 코루틴 스코프에 대한 1급 지원이 제공된다.

Thread-local 데이터

때로는 코루틴 간에 스레드 로컬 데이터를 전달하는 것이 편리할 때가 있다.
그러나 코루틴은 특정 스레드에 바인딩되지 않기 때문에 이를 수동으로 처리하면 코드가 복잡해질 수 있다.
ThreadLocal을 사용할 때 asContextElement 확장 함수를 사용하면 이를 쉽게 처리할 수 있다.
이 함수는 ThreadLocal 값을 유지하고 코루틴이 컨텍스트를 전환할 때마다 이를 복원하는 추가 컨텍스트 요소를 생성한다.
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()}'") // 출력: // 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'
Kotlin
복사
Dispatchers.Default를 사용하여 백그라운드 스레드 풀에서 새로운 코루틴을 실행한다.
이는 스레드 풀에서 다른 스레드에서 실행되지만 코루틴이 실행되는 스레드에 상관없이 스레드 로컬 변수의 값이 threadLocal.asContextElement(value = “launch”)로 지정된 값을 유지한다.
스레드 로컬 변수를 설정하는 것을 잊기 쉽다.
코루틴이 실행되는 스레드가 다를 경우 스레드 로컬 변수에서 예기치 않은 값을 가질 수 있다.
이러한 상황을 피하려면 ensurePresent 메서드를 사용하여 부적절한 사용에 대해 빠르게 실패하도록 하는 것이 좋다.
ThreadLocal은 kotlinx.coroutines에서 제공하는 모든 기본 요소와 함께 사용할 수 있다.
그러나 중요한 제한 사항이 있는데 컨텍스트 요소는 모든 ThreadLocal 객체 엑세스를 추적할 수 없기 때문에 ThreadLocal이 변경되면 새 값이 코루틴 호출자에게 전달되지 않는다는 점이다.
즉, 값이 변경되면 다음 중단점에서 업데이트된 값이 사라진다.
코루틴 내에서 ThreadLocal 값을 업데이트하려면 withContext를 사용해야 한다.