Coroutine Context and Dispatchers
코루틴은 코틀린 표준 라이브러리에서 CoroutineContext 타입으로 정의된 몇가지 컨텍스트에서 수행됩니다.
코루틴 컨텍스트는 다양한 요소의 집합입니다. 주요 요소로는 이전에 확인했던 Job 이라는 코루틴과, 이 장에서 다루게 되는 Dispatcher 입니다.
Dispatchers and threads
코루틴 컨텍스트는 관련 코루틴들이 실행 시 사용할 스레드 혹은 스레드풀을 결정짓는 코루틴 디스패처를 포함합니다. (CoroutineDispatcher를 참조하세요.) 코루틴 디스패처는 코루틴의 실행을 특정 스레드로 제한하거나, 특정 스레드풀로 전달하거나, 혹은 스레드의 제한 없이 실행되도록 할 수 있습니다.
launch{...}, async{...} 같은 모든 코루틴 빌더를 사용할 때 선택적으로 CoroutineContext 를 파라미터로 전달할 수 있으며, 새로운 코루틴들을 위한 디스패처나 그 이외의 컨텍스트 요소들을 지정할 수 있습니다.
다음 예제 코드를 확인하세요.
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
launch{...} 을 매개변수 없이 사용할 때는 해당 코루틴을 실행 시킨
CoroutineScope 의 컨텍스트(즉, 디스패처)를 상속합니다. 예제 코드에서는
메인 스레드에서 실행되는 메인 runBlocking 코루틴의 컨텍스트를 상속받습니다.
Dispatchers.Unconfined 는 메인 스레드에서 동작하지만, 사실 약간 다른 메커니즘으로 동작되는 특별한 Dispatcher 입니다. 자세한 내용은 후에 설명합니다.
Dispatcher.Default 는 코루틴이 GlobalScope 에서 실행될 경우에 사용히며
공통으로 사용되는 백그라운드 스레드풀을 이용합니다. 따라서, launch(Dispatchers.Default){...} 과 GlobalScope.launch{...} 는
동일한 디스패처를 사용합니다.
newSingleThreadContext 는 코루틴을 실행시키기 위한 스레드를 생성합니다. 이 코루틴을 위한 스레드(=전용 스레드)는 매우 리소스 비용이 큽니다. 실제 어플리케이션에서는 close 함수를 활용하여 반드시 해제하거나, 최상위 변수에 저장하여 어플리케이션 전체에 걸쳐 사용해야 합니다.
Unconfined vs confined dispatcher
Dispatcher.Unconfined 코루틴 디스패처는 호출한 스레드에서부터 실행하지만, 첫 번째 중단 함수를 만날 때 까지만 그렇습니다. 중단점 이 후 코루틴이 재개될 때는 중단 함수를 재개한 스레드에서 수행됩니다. 한정하지 않은(Unconfined) Dispatcher 는 CPU 시간을 소비하지 않거나 한정적으로 공유된 데이터를 업데이트(=UI와 같은) 하지 않는 특정 스레드에 국한된 작업이 아닌 경우 적절합니다.
디스패처의 경우 외부의 CoroutineScope 로부터 상속되는 것이 기본 동작입니다. runBlocking 코루틴의 기본 디스패처는 호출된 스레드에 국한되기 때문에, 디스패처를 상속하는 것은 예측 가능한 FIFO 스케쥴링이 가능하므로 이 스레드의 실행을 국한시키는데 효과가 있습니다.
예제 코드는 다음과 같이 출력됩니다.
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{...} 의 코루틴 컨텍스트를 상속받은 코루틴은 메인 스레드에서 실행되는 반면, Unconfined 코루틴 컨텍스트는 delay 함수를 사용하는 default executer 스레드에서 다시 시작됩니다.
Unconfined dispatcher 는 코루틴의 일부 작업이 즉시 수행되어야 할 경우 스레드 전환을 위해 코루틴이 디스패치되어 나중에 실행되는 것이 불합리 하거나 그렇게 실행될 경우 원치않는 사이드 이펙트가 발생되기 때문에,특수한 상황에 도움이 되는 향상된 메커니즘입니다.
일반 코드에서는 unconfined dispatcher 에서는 사용되면 안됩니다.
Debugging coroutines and threads
코루틴은 특정 스레드에서 실행 되었다가 다른 스레드에서 재개될 수 있습니다. 단일 스레드 디스패처 조차 코루틴이 어디서 무엇을 수행하는지 확인해 내는 것은 특별한 도구사용이 없다면 확인이 어렵습니다.
Debugging with IDEA
코틀린 플러그인의 코루틴 디버거는 IntelliJ IDEA 에서 코루틴 디버깅을 단순화 합니다.
디버깅은 kotlinx-coroutines-core 버전 1.3.8 이상에서 동작됩니다.
디버그 도구 창은 Coroutines 탭을 포함하고 있습니다. 이 탭에서는 현재 실행중인 코루틴과 일시 중단(suspended) 되어있는 코루틴 정보를 찾을 수 있습니다. 코루틴들은 실행되는 디스패처에 그룹화 되어집니다.
코루틴 디버거를 통해 다음을 할 수 있습니다 :
- 각 코루틴의 상태를 쉽게 체크할 수 있습니다.
- 실행 중인 코루틴과 일시 중단된 코루틴의 지역 변수 및 캡쳐된 변수 를 볼 수 있습니다.
- 전체 코루틴 생성 스택 확인이 가능하며, 코루틴 내부의 콜 스택 확인이 가능합니다. 스택은 표준 디버깅 중 손실되는 다양한 값을 가진 프레임들을 포함하고 있습니다.
- 각 코루틴의 상태와 그 스택의 전체 리포트가 필요할 경우, 코루틴 탭 내 마우스 우측을 선택 한 뒤, Get coroutines dump 를 선택하면 됩니다.
관련된 내용에 대한 좀 더 많은 내용은 아래 문서를 확인 해보세요.
https://blog.jetbrains.com/kotlin/2020/07/kotlin-1-4-rc-debugging-coroutines
https://www.jetbrains.com/help/idea/debug-kotlin-coroutines.html
Debugging using logging
코루틴 디버거를 사용하지 않고 어플리케이션의 스레드를 디버깅 하는 다른 방법은 각 로그 메시지에 스레드 이름을 출력하는 방법입니다. 이 기능은 로깅 프레임워크에서 지원합니다. 만약 코루틴을 사용한다면, 스레드의 이름 만으로는 컨텍스트에 대한 정보를 정확히 알 수 없으며 kotlinx.coroutines 는 디버깅 지원을 위한 도구를 포함하고 있어 좀 더 쉽게 디버깅이 가능합니다.
'Dkotlinx.coroutines.debug' jvm 옵션을 설정하고 아래 코드를 실행 해 보세요.
세 개의 코루틴이 있습니다. runBlock 내부의 코루틴(#1, 메인 코루틴) 과
지연된 값을 처리하는 두 개의 코루틴 a(#2), b(#3) 입니다. 이 코루틴들은 모두 runBlocking 컨텍스트에서 실행되며 메인 스레드에 국한되어 실행되어 집니다. 이 코드의 실행 결과는 아래와 같습니다.
[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
log 함수는 스레드 이름을 대괄호('[]') 안에 표시되며, 출력 결과를 보면 현재 메인 스레드에서 실행 중인 것을 알 수 있고, 추가적으로 현재 실행중인 코루틴의 ID가 출력 되는것을 확인할 수 있습니다. 이 식별자는 디버깅 모드가 켜져 있을 때 생성되는 모든 코루틴에 연속적으로 할당됩니다.
'-ea' 옵션으로 JVM을 실행할 때도 디버깅 모드는 실행됩니다.
디버깅 기능에 대한자세한 내용은 DEBUG_PROPERTY_NAME 속성 설명 문서를 참조하세요.
Jumping between threads
예제 코드를 '-Dkotlinx.coroutines.debug' JVM 옵션으로 실행해 봅니다. (디버깅 내용 참조)
이 코드는 몇 가지 새로운 기법을 보여줍니다. 하나는 runBlocking 에 명시적으로 context 를 지정하는 것이고, 다른 하나는 withContext 에 명시적으로 context를 선언함으로써 동일한 코루틴을 유지하면서 context를 변경하는 것입니다.
[Ctx1 @coroutine#1] Started in ctx1
[Ctx2 @coroutine#1] Working in ctx2
[Ctx1 @coroutine#1] Back to ctx1
이 예제는 newSingleThreadContext 로 만든 스레드가 더 이상 필요하지 않을 경우 해제 하기 위해 Kotlin 표준 라이브러리의 use 함수를 사용한 것에 유의하세요.
Job in the context
코루틴의 Job은 컨텍스트의 일부로서, ‘coroutineContext[Job]’ 표현을 사용하여 그 코루틴의 Job 을 획득할 수 있습니다.
디버그모드에서 실행하면, 다음과 같은 결과를 확인할 수 있습니다.
My job is "coroutine#1":BlockingCoroutine{Active}@4f2410ac
CoroutineScope의 isActive 는
coroutineContext[Job]?.isActive == true
표현의 편의를 위한 간략한 표현임을 확인해 둡시다.
Children of a coroutine
코루틴이 다른 코루틴의 CoroutineScope 에서 실행되면, 해당 코루틴은 부모 코루틴의 CoroutineScope.coroutineContext 를 통해 컨텍스트를 상속 받으며,새로운 코루틴의 Job 역시 부모 코루틴의 Job 의 자식이 됩니다. 부모 코루틴이 취소될 때, 모든 자식들 또한 재귀적으로 취소됩니다.
그러나, GlobalScope 을 활용하여 코루틴을 실행하면 새로운 코루틴의 작업은 부모가 없습니다. 따라서 GlobalScope 에서 실행된 코루틴은 실행된 스코프와 독립적으로 운용됩니다.
예제 코드는 아래와 같이 출력됩니다.
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?
Parental responsibilities
부모 코루틴은 항상 모든 자식 코루틴의 완료를 기다립니다. 부모가 실행중인 모든 자식을 명시적으로 추척할 필요는 없으며, 자식들의 종료를 기다리기 위하여 Job.join 을 반드시 사용하지 않아도 됩니다.
결과는 아래와 같이 출력될 것입니다.
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
Naming coroutines for debugging
자동으로 할당된 ID는 동일한 코루틴에서 발생한 로그인지 확인하는 작업에서는 종종 쓸만합니다. 하지만, 특정 요청의 처리 작업이나 백그라운드 작업을 특정하고자 한다면, 디버깅을 위해 명시적으로 코루틴 이름을 지정하는 것이 낫습니다. CoroutineName 이라는 컨텍스트 요소는 스레드 이름과 동일한 목적으로 기능이 제공됩니다. 디버깅 모드이면 CoroutineName 은 코루틴을 수행중인 스레드 이름과 함께 나타나게 됩니다.
다음 예제 코드는 이러한 개념을 확인할 수 있습니다.
예제 코드를 ‘-Dkotlinx.coroutines.debug’ JVM 옵션을 활용하여 실행하면 다음과 같은 출력 결과를 확인할 수 있습니다.
[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
Combining context elements
떼떼로 우리는 코루틴 컨텍스트를 위하여 여러 요소를 정의할 필요가 있습니다. ‘+’ 연산자를 사용하여 그것을 해결할 수 있습니다. 예를들어, 명시적으로 코루틴 디스패처와 코루틴 이름을 동시에 지정하여 코루틴을 실행 시킬 수 있습니다.
‘-Dkotlinx.coroutines.debug’ 옵션으로 예제 코드를 실행하면 다음과 같이 출력됩니다.
I'm working in thread DefaultDispatcher-worker-1 @test#2
Coroutine Scope
Context, 자식(서브루틴), Jobs 을 함께 생각해 봅시다. 우리의 어플리케이션이 라이프사이클을 갖지만 코루틴이 아닌 객체가 있다고 가정해 봅시다. 안드로이드 어플리케이션에서 비동기적으로 데이터를 가져오거나 업데이트하고, 애니메이션 처리를 하는 등 다양한 코루틴을 Activity 에서 실행하는 것을 예로 들 수 있습니다. 이러한 모든 코루틴들은 반드시 Activity가 종료될 때 메모리 누수를 방지하기 위하여 취소되어야 합니다. 물론 우리는 수동으로 context와 job을 activity와 라이프사이클간 연결하여 조작이 가능하지만, kotlinx.coroutines 은 추상화된 캡슐, CoroutineScope 을 제공합니다.
이미 CoroutineScope 는 확장함수로 제공되는 코루틴 빌더를 통해 친숙할 것입니다.
우리는 코루틴들을 CoroutineScope 의 인스턴스로 만들어서 코루틴의 라이프사이클을 Activity의 라이프사이클과 연결하여 관리할 수 있습니다. CoroutineScope 객체는 CoroutineScope() 또는 MainScope() 팩토리함수를 통해 생성될 수 있습니다. 전자(=CoroutineScope)의 경우 범용적으로 사용되기 위한 목적으로 생성하는 반면, 후자(=MainScope)의 경우 UI애플리케이션을 위해 만들고 기본 디스패처로 Dispatchers.Main 을 사용합니다.
이제 Scope를 활용하여 Activity 내에 코루틴을 실행 할 수 있습니다. 예제 코드에서 Activity 는 10개의 코루틴이 각각 다른 시간동안 지연되는 동작을 실행합니다.
메인 함수에서는 activity 인스턴스를 생성하고, 테스트를 위해 doSomething() 을 호출한 후, 500ms 이 후 activity 를 종료합니다. 이것은 doSomething() 에서 실행시킨 모든 코루틴을 취소할 수 있습니다. 메인에서 1000ms 만큼 조금 더 기다려 주었음에도 불구하고 activity 가 종료 되었기 때문에 이러한 사실을 확인할 수 있습니다.
예제 코드를 실행시키면 다음과 같은 결과를 확인할 수 있습니다.
Launched coroutines
Coroutine 0 is done
Coroutine 1 is done
Destroying activity!
보는 것 처럼 처음 두 개의 코루틴만 메시지를 출력하고 나머지는 ‘Activity.destory()’ 에서 job.cancel() 한번의 호출로 코루틴이 취소됩니다.
참고로 안드로이드에서는 우선적으로 라이프사이클을 가지고 있는 요소들에 대하여 scope 를 지원하고 있습니다. 자세한 내용은 안드로이드 문서를 참조하세요.
Thread-local data
때때로 코루틴 간에 스레드-로컬 데이터를 전달하는 것이 유용할 수 있습니다. 하지만 그것들은 스레드 영역 안에 포함되지 않기 때문에 스레드-로컬 데이터를 수동으로 처리할 경우 보일러 플레이트가 생길 수 있습니다. (=번잡한 코드로 이어질 가능성이 높습니다.)
이러한 문제를 돕기 위하여 ThreadLocal 을 지원하기 위하여 asContextElement 확장함수를 제공합니다. 해당 함수는 스레드-로컬 데이터를 저장하고 코루틴이 속한 컨텍스트가 변경 시 다시 복원하는 추가적으로 컨텍스트를 생성합니다.
예제에서는 새로운 코루틴을 백그라운드 스레드풀에서 Dispatchers.Default 를 이용하여 동작되는 새로운 코루틴을 실행합니다. 따라서, 해당 코드는 스레드풀의 서로다른 스레드에서 실행되지만 threadLocal.asContextElement(value = "launch") 을 사용함으로써 지정된 스레드-로컬 변수의 값은 그대로 유지됩니다. 따라서, 실행결과는 아래와 같습니다. (디버그 옵션으로 실행합니다.)
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'
ThreadLocal 은 1급 객체를 지원하며, kotlinx.coroutines 에서 제공되는 어떠한 원시타입(primitive)이라도 사용이 가능합니다. 한 가지 중요한 제약사항이 있는데, thread-local 값이 변경될 경우 새로운 값은 코루틴 호출자에게 전파되지 않으며, 업데이트 된 값은 다음 중단점에서 잃어버린 다는 것입니다. (컨텍스트의 요소가 모든 ThreadLocal 객체에 접근하는 것을 추적하지 않기 때문입니다.) 코루틴에서 ThreadLocal 값을 변경하기 위해서는 withContext를 사용하면 됩니다. 좀 더 자세한 내용은 asContextElement를 참조하세요.
대신, 값을 `class Counter(var i: Int)` 와 같은 변경 가능한 오브젝트로 Boxing 하여 ThreadLocal 에 저장 할 수 있습니다. 하지만, 이 경우에는 Boxing 되어있는 값에 동시에 접근할 경우에 대한 동기화 문제에 대하여 직접 처리를 해주어야 합니다.
로깅 MDC 와의 통합이나 transactional context, 내부적으로 ThreadLocal 을 이용하는 라이브러리와 같은 경우 등 좀 더 고급목적으로 활용하기 위해서는 ThreadContextElement 인터페이스를 구현하는 문서를 참조하시면 됩니다.