Coroutine 탐험일지(3)

LeeChangMin
쓱싹팀 이야기
13 min readJul 24, 2022

Coroutine Context and Dispatchers

Photo by Joel Holland on Unsplash

공식 가이드코루틴 공식 가이드 자세히 읽기— Part 5 을 따라 코루틴을 탐험해봅시다.
코루틴 적용 버전은 1.6.3입니다.

코루틴은 항상 코틀린 표준 라이브러리에 정의된 Coroutine Context 타입의 값으로 표시되는 컨텍스트 내에서 실행됩니다. 코루틴 컨텍스트는 다양한 요소의 집합입니다. 주요 요소는 이전에 본 코루틴 Job과 오늘 다룰 Dispatcher 가 있습니다.

Dispatchers and Threads

코루틴 컨텍스트는 해당 코루틴이 실행을 위해 사용하는 스레드를 결정하는 코루틴 Dispatcher를 포함합니다.

이 Dispathcer는 코루틴 실행을 특정 스레드로 제한하거나 스레드 풀에 전달하거나 제한 없이 실행되도록 할 수 있습니다.

모든 코루틴 빌더(ex.. launch{}, async{}…)는 새 코루틴 및 기타 컨텍스트 요소에 대한 디스패처를 명시적으로 지정하는 데 사용할 수 있도록 매개변수로 CoroutineContext를 전달할 수 있습니다.

Unconfined            : I'm working in thread main
Default : I'm working in thread DefaultDispatcher-worker-1
main runBlocking : I'm working in thread main
newSingleThreadContext: I'm working in thread MyOwnThread

위 코드에서 보듯.. launch{}의 파라미터에 컨텍스트(및 디스패처)를 전달해 사용할 수 있다.

파라미터로 전달되지 않은 경우 코루틴 스코프로부터 컨텍스트를 상속받아 사용한다.

Dispatchers.Unconfined는 메인 스레드에서 사용되는 것 처럼 보이지만 특수한 경우라 다릅니다. 잠시 후 설명합니다.

Dispatchers.Default는 코루틴이 GlobalScope에서 사용될 경우 사용되며 공통으로 사용되는 백그라운드 스레드풀을 사용합니다.
즉, launch(Dispathers.Default){}GlobalScope.launch{}는 동일한 디스패처를 사용합니다.

newSingleThreadContext는 코루틴이 실행할 스레드를 생성합니다. 전용 스레드를 생성하는 것은 매우 비싼 리소스이기 때문에 더 이상 사용되지 않을 경우 close()를 이용해 해제하거나 Top-level변수에 저장해 어플리케이션 전반에 걸쳐 재사용되어야 합니다.

스레드 비한정 디스패처와 한정 디스패처(Unconfined vs confined dispatcher)

Dispatchers.Unconfined코루틴 디스패처는 호출 스레드에서 코루틴을 시작하지만 첫 번째 중단점까지만 까지만 그렇습니다. 중단점 이후(일시 중단 이후) 코루틴이 재개될 때는 중단함수를 재개한 스레드에서 수행됩니다.
비한정 디스패처는 코루틴이 CPU 시간을 소모하지 않고 특정 스레드에 국한된 공유 데이터(ex.. 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가 사용되는 DefaultExecutor스레드에서 실행이 재개됩니다.

Dispatchers.Unconfined는 특정 상황에 도움이 될 수 있는 고급 메커니즘입니다. 예를 들어, 코루틴의 일부 작업이 즉시 수행되어야 하기 때문에 스레드 전환을 위해 코루틴이 디스패치되어 나중에 실행되는 경우 부작용이 발생할 수 있습니다. 이 디스패처는 일반적인 상황에서 사용되면 안됩니다.

코루틴과 스레드 디버깅(Debugging coroutines and threads)

코루틴은 한 스레드에서 일시 중지되고 다른 스레드에서 재개될 수 있습니다.
단일 스레드 디스패처를 사용하더라도 코루틴이 언제, 어디서, 어떻게, 무엇을, 누구와.. 일하는지 파악하기 어려울 수 있습니다.

이를 파악하는 가장 일반적인 방법은 디버깅입니다.

디버깅을 위해 intelliJ 에서 JVM옵션에 -Dkotlinx.coroutines.debug을 추가하고 아래 코드를 실행해봅니다.

[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

디버깅을 하면 위와 같이 세 개의 코루틴이 정의된 것을 확인할 수 있습니다.
메인 코루틴(#1), a(#2), b(#3) 이들 모두 runBlocking{}컨텍스트에서 실행되고 메인스레드에 한정되어 있습니다.

스레드 전환(Jumping between threads)

코루틴 디버깅옵션을 JVM에 추가해 아래 코드를 실행합니다.

[Ctx1 @coroutine#2] Started in ctx1
[Ctx2 @coroutine#2] Working in ctx2
[Ctx1 @coroutine#2] Back to ctx1

위 코드는 새로운 기술들을 보여줍니다. runBlockingwithContext호출 시 명시적으로 다른 컨텍스트를 호출하는 것을 볼 수 있습니다.

다른 컨텍스트를 호출함에도 여전히 기존의 같은 코루틴을 유지하는 것을 볼 수 있습니다. use함수를 사용해 생성된 스레드가 필요없으면 자동으로 해제하는 것도 볼 수 있습니다.

컨텍스트 상의 Job (Job in the context)

코루틴의 job은 컨텍스트의 일부입니다. 이는 coroutineContext[Job]표현식을 사용해 획득할 수 있습니다.

fun main(args: Array<String>) = runBlocking {
println("My Job is ${coroutineContext[Job]}")
}
// My Job is "coroutine#1":BlockingCoroutine{Active}@3701eaf6

코루틴 스코프의 isActive는 coroutineContext[Job]?.isActive == ture의 간략한 표현입니다.

코루틴의 자식(Children of a coroutine)

코루틴이 다른 코루틴의 코루틴 스코프안에서 실행될 때, 그 코루틴은 부모 코루틴의 CoroutineScope.coroutineContext를 통해 컨텍스트를 상속받고 생성된 코루틴의 Job은 부모 코루틴의 Job의 자식이 됩니다.
그 결과 부모 코루틴이 취소되면 모든 자식들 또한재귀적으로 취소됩니다.

But!.. GlobalSceop에서 코루틴이 실행되면 부모 스코프로부터 상속받지 않습니다. 즉, 실행 스코프와 연관되지 않고 독립적으로 동작합니다.

job1: I run in my own Job and execute independently!
job2: I am a child of the request coroutine
main: Who has survived request cancellation?
job1: I am not affected by cancellation of the request

부모 코루틴의 의무 (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컨텍스트 요소는 스레드 이름과 같은 목적으로 사용됩니다. 디버깅 모드에서 이 코루틴을 실행하는 스레드의 이름을 포함해 볼 수 있습니다.

[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)

코루틴 컨텍스트의 여러 요소를 합칠 때는 + 를 사용하면 된다.

launch(Dispatchers.Default + CoroutineName("test")) {
println("I'm working in thread ${Thread.currentThread().name}")
}
// I'm working in thread DefaultDispatcher-worker-1 @test#2

Coroutine Scope

어플리케이션에서 라이프사이클을 갖는 객체가 있지만 코루틴이 아니라고 가정해봅시다. 안드로이드 기준 액티비티나 프래그먼트정도로 생각할 수 있습니다.

예를 들어 안드로이드 어플리케이션을 만들고 안드로이드 액티비티 context에서 다양한 코루틴을 launch해 데이터를 가져오거고 업데이트 하며 애니메이션 등을 위한 비동기 작업을 수행하는 경우, 모든 코루틴들은 액티비티가 종료될 때 메모리 누출 방지를 위해 취소해야 합니다. 물론 우리는 액티비티 라이프사이클과 코루틴을 연결하기 위해 수동으로 context와 job을 조작할 수 있지만, kotlinx.corouteins는 추상적인 캡슐화 도구인 CoroutineScope를 제공합니다.

액티비티 라이프사이클에 연결된 CoroutineScope인스턴스를 만들어 코루틴 라이프사이클을 관리할 수 있습니다. CoroutineScope인스턴스는 두가지 방법으로 만들 수 있습니다.
1. CoroutineScope()일반적인 목적의 scope 를 위해 사용
2. MainScope()UI 어플리케이션과 default dispatcher인 Dispatchers.Main을 위해 사용

Launched coroutines
Coroutine 0 is done
Coroutine 1 is done
Destroying activity!

메인 함수에서 activity를 만들고 doSomething을 호출한 후 500ms 후에 activity를 종료시킵니다. activity가 종료된 이후 메시지가 출력되지 않는 것을 볼 수 있어 모든 코루틴이 취소된 것을 알 수 있습니다.

Thread-local Data

가끔 스레드 로컬 데이터를 코루틴으로 전달하거나 코루틴 간 전달해야할 필요가 있을 수 있습니다. 하지만 이런 경우 특정 스레드에 제한되어 있지 않아야하기 때문에 이를 수동으로 작업하는 경우 boilerplate로 이어질 가능성이 높습니다.

이런 경우를 해결하기 위해 ThreadLocal에는 asContextElement확장 함수가 존재합니다. 주어진 ThreadLocal값을 유지하는 추가 컨텍스트 요소를 생성하고 코루틴이 컨텍스트를 전환할 때마다 이 요소를 복원합니다.

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-1 @coroutine#2,5,main], thread local value: 'launch'
Post-main, current thread: Thread[main @coroutine#1,5,main], thread local value: 'main'

Dispather.Default를 이용해 새로운 코루틴을 백그라운드 스레드풀에서 실행합니다. 다른 스레드에서 실행할 코루틴은 threadlocal.asContextElement(value = "launch")로 어떤 스레드든 상관없이 변수 값을 유지할 수 있습니다.

해당 컨텍스트 요소를 설정하는 것을 잊기 쉽습니다.
코루틴을 실행하는 스레드가 다른 경우 코루틴에서 접근하는 스레드-로컬 변수는 예상 못한 값을 가질 수 있습니다. 이런 상황을 방지하기 위해 ensurePresent함수를 사용하는 것이 권장됩니다.

ThreadLocal은 값이 변경될 경우 새로운 값이 코루틴 호출자에게 전파되지 않고(컨텍스트 요소가 모든 ThreadLocal 객체의 접근을 추적할 수 없어서) , 업데이트 된 값은 다음 중단점에서 유실됩니다. 코루틴에서 ThreadLocal 값을 업데이트하려면 withContext를 사용하면 됩니다. 자세한 내용은 asContextElement를 참고하면 됩니다.

대안으로, class Counter(var i:Int)와 같이 변경 가능한 객체로 Boxing해 thread-local에 저장할 수 있습니다. 그러나 이런 경우 boxing된 값의 동기화(ex.. 수정되는 값)은 직접 처리해야 합니다.

--

--