Kotlin/코루틴

공식문서로 알아보는 Coroutine context and dispatchers

Junuuu 2024. 1. 29. 00:01
728x90

개요

Kotlin 공식문서를 보며 코루틴의 context와 dispatchers에 대해 알아보면서 실습해보고자 합니다.

 

 

CoroutineContext란?

CoroutineContext는 kotlin 표준 라이브러리에 포함되어 있는 interface입니다.

Coroutine은 항상 CoroutineContext 안에서 실행됩니다.

CoroutineContext는 다양한 요소들의 집합이며, 주요 요소로는 이전에 알아본 Job과 이번 시간에 알아볼 dispacther입니다.

 

조금 더 간단하게 표현해 보자면 CoroutineContext는 Coroutine을 어떻게 처리할지에 대한 정보를 가지고 있는 객체입니다.

 

이전에 CoroutineScope 내부에 코드를 작성하곤 했습니다.

CoroutineScope은 CoroutineContext 하나만 멤버 변수로 정의하고 있는 인터페이스입니다.

 

 

CoroutineDispatchers and threads

Coroutine은 여러 개의 스레드에서 실행될 수 있습니다.

어떤 스레드에서 실행될지 어떻게 결정될까요?

 

CoroutineContext에는 해당 코루틴이 실행에 사용하는 스레드를 결정하는 CoroutineDispatchers 도 포함되어 있습니다.

CoroutineDispatchers를 활용하면 코루틴 실행을 특정 스레드로 제한하거나, 스레드 풀로 할당하거나, 제한되지 않은 상태로 실행할 수 있습니다.

 

luanch와 async 같은 모든 coroutine builder들은 optional으로 CoroutineContext를 인자로 가지게 되어있고 CoroutineDispatchers도 지정할 수 있습니다.

 

예제

launch { // context of the parent, main runBlocking coroutine
    println("main runBlocking      : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Unconfined) { // not confined -- will work with main thread
    println("Unconfined            : I'm working in thread ${Thread.currentThread().name}")
}
launch(Dispatchers.Default) { // will get dispatched to DefaultDispatcher 
    println("Default               : I'm working in thread ${Thread.currentThread().name}")
}
launch(newSingleThreadContext("MyOwnThread")) { // will get its own new thread
    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

현재 스레드명을 출력하면서 coroutine builder인 launch를 활용하여 여러 가지 Dispacthers 옵션을 주어서 다양한 스레드에서 실행시켜 볼 수 있습니다.

 

만약 특정한 Dispacthers를 할당하지 않는 경우에는 실행 중인 CoroutineScope의 CoroutineContext를 상속받게 되며 이 경우에는 main 스레드에서 실행되는 runBlocking의 CoroutineContext를 상속받아 main 스레드에서 실행되었습니다.

 

Dispatchers.Default는 Scope에 Dispacthers가 명시적으로 지정되지 않은 경우에 사용되며, 스레드의 공유 백그라운드 풀을 사용합니다.

 

newSingleThreadContext는 코루틴이 실행될 스레드를 생성합니다.

실제 애플리케이션에서는 스레드풀을 구성해서 사용할 수 있습니다.

 

 

Debugging coroutines and threads

코루틴을 사용하지 않을 때는 단일 스레드에서 돌아가기 때문에 brake point를 찍고 스레드를 따라가며 디버깅을 하였습니다.

하지만 코루틴은 한 스레드에서 일시 중단되었다가 다른 스레드에서 재개될 수 있습니다.

자세한 건 코루틴 Debugigng 챕터에서 다루겠습니다.

 

Debugging using logging

-Dkotlinx.coroutines.debug JVM 옵션과 함께 log 메서드를 구현해서 활용하면 코루틴이 돌아가는 스레드와 어떤 코루틴이 활용 중인지 확인할 수 있습니다.

 

fun log(msg: String) = println("[${Thread.currentThread().name}] $msg")

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

log 메서드는 현재 스레드와 인자로 받은 메시지를 출력합니다.

만약 debug JVM 옵션을 주지 않는다면 @coroutine#n 부분은 출력되지 않습니다.

 

 

 

Children of a coroutine

코루틴 내부에서 코루틴이 실행되면 코루틴은 coroutineContext에 의해서 context를 상속하고, 새 코루틴 job은 부모 코루틴 job의 자식이 됩니다.

부모 코루틴이 취소되면 자식 코루틴도 재귀적으로 취소됩니다.

 

부모-자식 관계를 명시적으로 재정의하는 2가지 방법이 있습니다

  • 예를 들어 GlobalScope.launch로 명시적으로 범위 지정하기
  • Job객체를 새롭게 만들어서 전달하기

 

// launch a coroutine to process some kind of incoming request
val request = launch {
    // it spawns two other jobs
    launch(Job()) { 
        println("job1: I run in my own Job and execute independently!")
        delay(1000)
        println("job1: I am not affected by cancellation of the request")
    }
    // and the other inherits the parent context
    launch {
        delay(100)
        println("job2: I am a child of the request coroutine")
        delay(1000)
        println("job2: I will not execute this line if my parent request is cancelled")
    }
}
delay(500)
request.cancel() // cancel processing of the request
println("main: Who has survived request cancellation?")
delay(1000) // delay the main thread for a second to see what happens

//결과
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

 

job1은 Job()객체를 재정의했기 때문에 부모-자식 관계에서 벗어나서 부모의 job이 취소되더라도 해당 범위에 제외되어 취소되지 않습니다.

 

 

Parental responsibilities

import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
    val request = launch {
        repeat(3) { i -> // 약간의 자식 jobs launch
            launch  {
                delay((i + 1) * 200L) // 가변적인 지연 200ms, 400ms, 600ms
                println("Coroutine $i is done")
            }
        }
        println("request: I'm done and I don't explicitly join my children that are still active")
    }
    request.join() // 모든 자식 코루틴들을 포함하여 그것의 완료를 기다린다.
    println("Now processing of the request is complete")
}

부모의 책임이라는 예시인데 자식 Coroutine들을 일일이 join 해줄 필요가 없습니다.

부모를 join해두면 자식 coroutine이 모두 끝날 때까지 기다립니다.

 

Combining context elements

launch(Dispatchers.Default + CoroutineName("test")) {
    println("I'm working in thread ${Thread.currentThread().name}")
}

// -Dkotlinx.coroutines.debug JVM  옵션과 함께 실행시 결과
I'm working in thread DefaultDispatcher-worker-1 @test#2

때로는 CoroutineContext에 여러 요소를 정의할 필요가 있습니다.

이를 위해 + 연산자를 통해 Dispacthers와 명시적으로 코루틴의 이름을 넘긴 예제입니다.

 

 

Thread-local data

때로 코루틴간에 일부 ThreadLocal처럼 데이터를 전달할 수 있으면 편리합니다.

하지만 코루틴은 특정 스레드에 할당되지 않습니다.

수동으로 해줄경우 boilerplate 코드가 많이 생길 수 있습니다.

 

코틀린에서는 asContextElement 확장함수를 지원하여 ThreadLocal의 값을 유지하는 context element를 생성하고 코루틴이 context를 전환할 때마다 해당 값을 복원합니다

 

import kotlinx.coroutines.*

val threadLocal = ThreadLocal<String?>() // declare thread-local variable

fun main() = runBlocking<Unit> {

    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()}'")        
        delay(100)
        println("After yield, current thread: ${Thread.currentThread()}, thread local value: '${threadLocal.get()}'")
    }   
    threadLocal.set("change")
    println("delay 메서드 호출 이후 & ThreadLocal값 변경, 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'
delay 메서드 호출 이후 & ThreadLocal값 변경, current thread: Thread[main @coroutine#1,5,main], thread local value: 'change'
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: 'change'

.

코루틴 빌더인 launch를 통하여 Dispatcher.Default를 사용하여 백그라운드 스레드 풀에서 새 코루틴을 실행하므로 main과는 다른 스레드에서 동작하게 됩니다.

 

하지만 이때 Combining context elements를 활용하여 asContextElement 확장함수로 threadLocal의 값을 launch로 변경해 주었습니다.

 

하지만 중간에 threadLocal의 값을 변경해 주어도 coroutine 내부의 threadLocal의 값은 변하지 않습니다.

코루틴에서 threadLocal의 값을 갱신해주기 위해서는 withContext를 활용할 수 있습니다.

 

이외에도 class Counter(var i: Int)와 같은 변경 가능한 박스에 저장할 수 있으며, 이는 thread-local 변수에 저장됩니다. 그러나 이 경우 이 변수에 대한 수정 사항을 동기화할 책임은 전적으로 우리에게 있습니다.