코루틴 공식 가이드 자세히 읽기 — Part 5

Myungpyo Shim
15 min readJan 23, 2019

--

공식 가이드 읽기 (5 / 8)

Coroutine Context and Dispatchers

Korean [English]

(목차로 돌아가기)

코루틴은 항상 코틀린 표준 라이브러리에 정의 된 CoroutineContext 타입의 값으로 표현되는 컨텍스트 내에서 수행 됩니다. 코루틴 컨텍스트는 다양한 컨텍스트 요소(Elements)들의 집합입니다. 주된 요소로는 전에 보았던 코루틴 잡(Job) 과 이번 섹션에 다룰 디스패처(Dispatcher) 등이 있습니다.

Disptchers and threads

코루틴 컨텍스트는 연관된 코루틴들이 실행 시 사용할 스레드 혹은 스레드풀을 결정짓는 코루틴 디스패처를 포함합니다. 코루틴 디스패처는 코루틴의 실행을 특정 스레드로 한정짓거나, 특정 스레드 풀로 전달하거나, 스레드의 제한 없이 실행되도록 할 수 있습니다.

모든 코루틴 빌더들(e.g. launch { }, async { }, …)을 사용할 때 옵셔널 파라미터로 CoroutineContext 를 전달할 수 있으며, 이를 이용하여 새로운 코루틴들을 위한 디스패쳐나 그 이외의 컨텍스트 요소들을 지정할 수 있습니다.

<결과>

Unconfined : I’m working in thread main
Default : I’m working in thread DefaultDispatcher-worker-2
newSingleThreadContext: I’m working in thread MyOwnThread
main runBlocking : I’m working in thread main

launch { … } 빌더가 파라미터 없이 호출될 경우 실행 된 코루틴 스코프(이 경우엔 runBlocking 코루틴)로부터 상속 받은 컨텍스트를 그대로 이용합니다. (물론, 디스패처 요소도 포함됩니다.)

Dispatchers.Unconfined 역시 메인 스레드에서 실행되는 것처럼 보이지만, 사실 이 특수한 디스패처는 조금 다른 매커니즘을 이용합니다. 이에 대해서는 잠시 후에 설명합니다.

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

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

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

Dispatchers.Unconfined 코루틴 디스패처는 호출 스레드에서 코루틴을 시작하지만 이는 첫번째 중단점(중단 함수 호출)을 만날때 까지만 그렇습니다. 중단점 이후에 코루틴이 재개(resume)될 때는 중단 함수를 재개한 스레드에서 수행됩니다. 비-한정 디스패처는 코루틴이 CPU 시간을 소모하지 않거나 공유되는 데이터(UI)를 업데이트 하지 않는 경우처럼 특정 스레드에 국한된 작업이 아닌 경우 적절합니다.

한편, 코루틴 컨텍스트 요소들은 부모 — 자식 코루틴 계층 구조로 정의 될 때 기본적으로 부모(바깥쪽 블록)코루틴 스코프의 컨텍스트 요소들이 상속됩니다. 물론, 디스패처도 컨텍스트 요소이므로 동일하게 동작합니다. 특히 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 executor 스레드에서 실행이 재개 됩니다.

Unconfined dispatcher 는 특수한 상황에 도움이 될 수 있는 진보된 매커니즘 입니다. 예를들면, 코루틴의 일부 작업이 즉시 수행 되어야 하기 때문에 스레드 전환을 위해 코루틴이 디스패치 되어 나중에 실행되는 것이 불합리 하거나 그렇게 실행될 경우 원치 않는 사이드 이펙트를 발생시키는 경우가 될 수 있습니다. 그러므로 일반적인 상황에서는 이러한 Unconfined dispatcher 를 사용하는 것은 지양해야 합니다.

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

코루틴들은 한 스레드에서 중단된 후 다른 스레드에서 재개될 수 있습니다. 꼭 멀티 스레드 환경이 아니더라도 단일 스레드 디스패처에서조차 어떤 코루틴이 언제, 어디서 수행중이었는지 밝혀내는 것은 어려운 일입니다. 스레드를 사용하는 애플리케이션을 디버깅 하기 위한 일반적인 방법은 각각의 로그 마다 현재 스레드 이름을 붙여 출력하는 것입니다. 이러한 기능은 보통 로깅 프레임워크에 의해서 전반적으로 지원됩니다. 코루틴을 사용할 때스레드 명 만으로는 실행 컨텍스트를 제대로 설명하진 못하므로 코루틴 프레임워크는 이것을 용이하게 하기 위한 디버깅 지원 기능을 포함하고 있습니다.

JVM 옵션에 -Dkotlinx.coroutines.debug 를 추가하고 아래 예제를 실행하면 Thread 이름에 추가로 코루틴의 이름까지 출력되는 것을 확인할 수 있습니다.

위 예제에는 세 개의 코루틴이 정의되어 있습니다. 메인 코루틴(#1) — runBlocking — 그리고 두개의 지연 값들을 계산하는 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 가 출력되어 있습니다. 이 ID 는 디버깅 모드가 활성화되면 연속적으로 모든 코루틴에 할당됩니다.

디버깅 기능에 대한 더 자세한 내용은 이곳에서 확인 가능합니다.

스레드 간 전환 (Jumping between threads)

다음 예제를 -Dkotlinx.coroutines.debug JVM 옵션으로 실행해봅시다.

<출력 결과>

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

이 예제는 몇가지 새로운 기술을 선보이고 있습니다. 한 가지는 runBlocking 호출 시 명시적으로 컨텍스트를 설정한 것, 그리고 다른 한 가지는 코루틴의 컨텍스트 전환을 위해 withContext() 함수를 사용한 것인데 결과에서 볼 수 있듯이 전환 중에도 여전히 기존 코루틴을 유지하고 있는 것을 확인할 수 있습니다.

이 예제에서는 코틀린 표준 라이브러리의 use() 함수를 사용하여 newSingleThreadContext 를 이용하여 생성된 스레드가 더이상 필요하지 않게 되면 해제하고 있습니다.

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

코루틴의 Job 은 그 컨텍스트의 일부입니다. 코루틴은 컨텍스트에서 coroutineContext[Job] 표현을 이용하여 Job 을 획득할 수 있습니다.
다음 코드를 디버깅 모드에서 실행 해 봅시다.

<출력 결과>

My job is BlockingCoroutine{Active}@2328c243

코루틴 스코프의 isActive 는 coroutineContext[Job]?.isActive == true 의 편의를 위한 간략한 표현임을 알아둡시다.

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

어떤 코루틴이 다른 코루틴의 코루틴 스코프 안에서 실행되면, 그것은 부모 코루틴의 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은 이 코루틴을 수행 중인 스레드 이름에 함께 나타나게 됩니다.

<수행 결과>

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

종종 우리는 코루틴 컨텍스트 요소들 중 두개 이상을 동시에 지정해야 할 경우가 있습니다. 이 경우 우리는 +(plus) 연산자를 사용하여 코루틴 요소들을 병합할 수 있습니다.
예를들어, 우리는 다음과 같이 명시적으로 코루틴의 디스패처(dispatcher)와 이름(name)을 동시에 적용할 수 있습니다.

위 예제를 -Dkotlinx.coroutines.debug JVM 옵션과 함께 수행하면 다음과 같은 결과를 얻을 수 있습니다.

<실행 결과>

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

명시적인 Job 을 통한 취소 요청 (Cancellation via explicit job)

이제 컨텍스트와 자식 코루틴 그리고 잡에 대한 우리의 지식을 한데 모아봅시다. 우리 애플리케이션에 생명주기(Lifecycle)를 갖는 오브젝트가 있고 이 오브젝트는 코루틴은 아니라고 가정해 봅시다. 안드로이드라면 액티비티나 프래그먼트 쯤이 되겠고, iOS 라면 뷰컨트롤러 정도 일 것 같습니다.

지금은 안드로이드 애플리케이션을 만들고있고, 안드로이드 액티비티 컨텍스트에서 데이터를 가져오거나 변경하고, 애니메이션을 수행하는 등의 동작을 위해 다양한 코루틴을 실행하고 있다고 생각해 봅시다. 여기서 사용된 모든 코루틴들은 액티비티가 종료될 때 함께 취소되어야 메모리 누수를 피할 수 있습니다.

우리는 액티비티의 생명주기와 매핑 된 Job 인스턴스를 생성함으로써 코루틴의 생명주기를 관리할 수 있습니다. Job 인스턴스는 액티비티가 생성될 때 Job() 팩토리 함수를 이용하여 생성되고 액티비티가 종료될 때 취소됩니다. 다음 예제를 살펴봅시다.

우리는 액티비티에서 CoroutineScope 인터페이스를 구현합니다. 이 때, 우리에게 필요한 것은 이 인터페이스의 CoroutineScope.coroutineContext 속성을 오버라이드 하여 이 스코프에서 실행될 코루틴들의 컨텍스트를 명시하는 것입니다. 우리는 이 컨텍스트에 적절한 디스패처(이 예제에서는 Dispatchers.Default)와 Job 을 연결할 것입니다.

이제 이 액티비티의 코루틴 스코프에서 명시적으로 컨텍스트를 지정하지 않고도 코루틴들을 실행할 수 있게 되었습니다. 예제에서 우리는 서로 다른 시간 간격으로 10개의 코루틴들을 실행하고 있습니다.

메인함수에서는 액티비티를 생성한 후 doSomething() 함수를 호출하고, 500ms 후에 액티비티를 종료시키고 있습니다. 이렇게 발생한 Job 의 취소는 모든 실행 중인 코루틴들을 취소 시키게 되고 우리는 코루틴의 수행 결과들이 화면에 더이상 출력하지 않는 것으로 확인할 수 있습니다.

결과는 다음과 같습니다.

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

위 결과에서 확인해 볼 수 있듯이 두 개의 코루틴만이 결과를 출력했고 나머지들은 Activity.destroy() 라이프 사이클 콜백 시점에 호출 된 job.cancel() 함수로 인해서 모두 함께 취소되었습니다.

스레드 로컬 데이터 (Thread-local data)

때때로 스레드 로컬 데이터를 코루틴으로 전달하거나 혹은 코루틴 간에 전달하는 기능이 유용할 때가 있습니다. 하지만 코루틴은 특정 스레드에 국한 되진 않으므로 이러한 기능을 직접 구현하려면 많은 번잡스러운 작업이 필요합니다.

우리는 ThreadLocal 을 위해서 asContextElement() 확장 함수를 사용할 수 있습니다. 이것은 부가적인 컨텍스트 요소를 생성하는데 주어진 ThreadLocal 의 값을 저장했다가 코루틴이 속한 컨텍스트를 변경할 때마다 복원합니다.

이것은 다음과 같이 쉽게 확인 해볼 수 있습니다.

이 예제에서 우리는 Dispatcher.Default 를 이용하여 새로운 코루틴을 백그라운드 스레드 풀에서 실행합니다. 이렇게 다른 스레드에서 실행 된 코루틴은 우리가 threadLocal.asContextElement(value = “launch”) 로 명시한 값을 가지고 있습니다. 이것은 이 코루틴이 어느 스레드에서 수행되는지와는 상관이 없습니다. 그래서 debug 모드로 이를 실행해보면 다음과 같은 결과를 얻을 수 있습니다.

<출력 결과>

Pre-main, current thread: Thread[main,5,main], thread local value: ‘main’Launch start, current thread: Thread[DefaultDispatcher-worker-1,5,main], thread local value: ‘launch’After yield, current thread: Thread[DefaultDispatcher-worker-2,5,main], thread local value: ‘launch’Post-main, current thread: Thread[main,5,main], thread local value: ‘main’

적절한 컨텍스트 요소를 설정하는 것을 잊기 쉽습니다. 이 경우 코루틴을 실행하는 스레드가 바뀌면 코루틴에서 접근하는 ThreadLocal 변수는 예상치 못한 값을 갖게 될 수 있습니다. 이러한 상황을 미연에 방지하기 위해서 ensurePresent 함수를 사용하여 값이 없을 경우 바로 실패하도록 처리하는 것이 권장 됩니다.

ThreadLoal은 first-class 지원을 하며 kotlinx.coroutine 이 제공하는 어떠한 원시(primitive) 타입이라도 사용될 수 있습니다. 한가지 중요한 제약사항은 thread-local 이 변경될 경우 새로운 값이 코루틴 호출자에게 전파되진 않으며 (컨텍스트 요소가 모든 ThreadLocal 오브젝트의 접근을 추적하진 않기 때문에), 업데이트 된 값은 다음번 중단점에서 잃어버린다는 것입니다. 코루틴에서 Thread-Local 값을 변경하기 위해서는 withContext 를 사용하면 됩니다.

대안으로, 값을 class Counter(var i: Int) 와 같이 변경 가능한 오브젝트로 Boxing 하여 ThreadLocal 에 저장할 수 있습니다. 하지만 이경우 Boxing 된 값에 동시에 접근할 경우에 대한 동기화 문제에 대해서 직접 처리해야 합니다.

logging MDC 와의 통합이나 transactional context 등의 데이터를 전달하기 위해 thread-local 을 내부적으로 사용하는 라이브러리들과 같은 좀 더 고급 사용에 대해서는 ThreadContextElement 인터페이스를 구현하는 이 문서를 참조 하면 됩니다.(https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-thread-context-element/index.html)

--

--