TIL 20240208 - 코루틴(coroutine) ? (2)

2024. 2. 9. 00:18코틀린

이어서 코루틴에 대해서 더 배워보기로 했다.

async 라는 것도 잠시나마 배워본 적이 있었는데 ㅋㅋㅋ 다시 배워서 반가웠고 좋았다..

그치만 상세하게 알지는 못하니 공부할 필요성을 느꼈고,, 큼

서론은 여기서 그만두고 공부한 내용을 정리해보고자 한다.

 

 


async 코루틴 빌더 생성

 

동시성 처리를 위한 async 코루틴

특정 지연함수들을 비동기적으로 각각 실행하기 위해 사용하는 코루틴이 async라고 한다.

 

launch(단순히 Job 객체를 반환한다.)와 다른 점은 Deferred<T>를 통해 결과값을 반환하고

지연된 결과 값을 받기 위해 await()를 사용한다.

private fun worksInParallel() {
    val one = GlobalScope.async {
        do1()
    }
    val two = GlobalScope.async {
        do2()
    }

    GlobalScope.launch {
        val combined = one.await() + "_" + two.await()
        println("kotlin Combined : $combined")
    }
}

 

one 부분에서 async를 만난 순간 새로운 코루틴이 생성하게 된다. 이때 do를 수행할 수 있게 된다.

이어서 그 다음인 two 역시 다른 async를 만나므로 또 새로운 코루틴을 생성하게 된다. 이때, 병행처리가 되기 때문에 이 두개의 실행 루틴은 순차적인 처리와 무관하게 된다.

 

각각 걸리는 시간이 다를 수 있으므로 이에 대한 수행결과를 받아와야 한다.

launch 자체는 기본적으로 논블로킹 형태로 지연을 하기 때문에 await와 만나면 일단 잠깐 멈추게 된다.

 

그러고 나서 one이나 two 어느쪽이든 먼저 수행돼서 빠져나오는 쪽이 대기하게 되고 지연 함수가 빠져 나오면서 combined를 찍게된다. 모든 결과가 끝나면 해당 launch가 재개하여 내용물을 찍고 나오게 된다.

 

즉, async는 순차적인 처리와 무관하게 비동기적으로 병행성을 제공하게 된다는 것이다.

 

 

 


Corountine Context

코루틴을 실행하기 위한 다양한 설정값을 가진 관리 정보 (코루틴 이름, 디스패처, 작업 상세사항, 예외 핸들러 등)로 이루어져 있다.

 

코루틴 이름은 코루틴을 실행할 때 사용하 이름인데, 일종에 디버깅할때 쓴다 .

디스패처는 코루틴 문맥을 보고 어떤 스레드에서 실행되고 있는지 식별이 가능해지므로 여기에 옵션을 넣어 줄 수 있다.

코루틴 문맥은 + 연산을 통해 조합할 수 있다. 

 

val someCoroutineContext = someJob + Dispatchers.IO + someCoroutineName + someExceptionHandler

 

 

 

CoroutineName 

코루틴에 이름을 주어 디버깅을 위해서 사용한다.

val someCoroutineName = coroutineName("someCoroutineName")

 

 

 

Job 

작업 객체를 지정할 수 있으며 취소 가능 여부에 따라 SupervisorJob()를 사용한다.

val parentJob = supervisorJob() // or Job()
val someJob = Job(parentJob)

 

 

 

CoroutineDispatcher

Dispatchers.Default,...IO, 등을 지정할 수 있으며 필요에 따라 스레드 생성 가능하다.

만일 스레드 풀을 직접 정의하고 싶으면 아래처럼 지정해서 할 수 있다.

val myPool = Executor.newFixedThreadPool(2).asCoroutineDispatcher

 

Dispatchers.Default 옵션은 기본으로 사용되는 디스패처로 공유 스레드풀에서 스레드가 선택되며 주로 CPU 연산을 위한 문맥에 사용된다.

 

 

 

CoroutineExceptionHandler

- 코루틴 문맥을 위한 예외처리를 담당하며 코루틴에서 예외가 던져지면 처리한다.

- 예외가 발생한 코루틴은 상위 코루틴에 전달되어 처리될 수 있다. 스코프를 가지는 경우 예외 에러를 찾아서 처리할 수 있다. 

- 혹여라도 예외처리가 자식에만 있고 부모에 없는 경우 부모에도 마찬가지로 예외가 전달이 되므로 주의해야한다. 이 경우 앱이 깨지게 된다고 한다.(crash)

- 예외가 다중으로 발생할 경우 최초 하나만 처리되고 나머지는 무시된다.

 

val someExceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    val coroutineName = coroutineContext[CoroutineName]?.name ?: "default coroutine name"
    println("Error in $coroutineName: ${throwable.localizedMessage}")
}

 

 

 

GlobalScope

독립형(Standalone) 코루틴을 구성하고 생명주기는 프로그램 전체 (top - level) 에 해당하는 범위를 가져 main의 생명 주기가 끝나면 같이 종료된다.

Dispatchers.Unconfined와 함께 작업이 서로 무관한 전역 범위에서 실행되며 보통 GlobalScope 상에서는 launch나 async 사용이 권장되지 않는다.

val scope1 = GlobalScope
scope1.launch {...}
scope1.async {...}

or
        
GlobalScope.launch {...}
val job1 = GlobalScope.launch {...} // Job 객체 -> Job.join()으로 기다린다.
val job2 = GlobalScope.async {...} // Deferred 객체 -> Deferred.await()으로 기다린다. 결과 반환한다.

 

위에는 독립적으로 작동하는 형태로 Standalone이다. 또한 프로그램 생명주기동안 작동하게 되므로 권장하지 않는다.

아래에는 Job 객체를 받아서 코루틴의 생명주기를 제어할 수 있다.

 

 

 

CoroutineScope

특정 목적의 디스패처를 지정한 범위를 블록으로 구성할 수 있다.

모든 코루틴 빌더는 CoroutineScope의 인스턴스를 갖는다.

launch {..} 와 같이 인자가 없는 경우에는 CoroutineScope에서 상위의 문맥이 상속되어 결정한다.

launch(Dispatchers.옵션인자) {...}와 같이 디스패처의 스케줄러를 지정 가능하다.

(Dispatchers.Default는 GlobalScope에서 실행되는 문맥과 동일하게 사용된다.

자동으로 종료처리를 할 것인지 말것인지에 따라 조금의 차이는 있을 수 있다.)

val scope2 = CoroutineScope(Dispatchers.Default)
val routine1 = scope2.launch {...}
val routine2 = scope2.async {...}

or


launch(Dispatchers.Default) {...}
async(Dispatchers.Default) {...}

 

 

 


스레드풀(thread pool)의 사용

보통 CommonPooll이 지정되어 코루틴이 사용할 스레드의 공동 풀(pool)을 사용한다. 이미 초기화되어 있는 스레드 중 하나 혹은 그 이상이 선택되며 초기화하기때문에 스레드를 생성하는 오버헤드가 없어서 빠르다. 하나의 스레드에 다수의 코루틴이 동작할 수 있다.

 

 

특정 스레드 개수를 직접 지정하는 경우

val threadPool = Executors.newFixedThreadPool(4)

val MyContext = threadPool.asCoroutineDispatcher()

...

async(MyContext) {...}

...

 

 

 

부모 - 자식 및 독립적인 스코프의 코루틴은!

fun main() = runBlocking<Unit> {
    val request = launch {
        GlobalScope.launch { // 프로그램 전역으로 독립적인 수행으로 부모와 자식간의 관계가 없다.
            println("job1: before suspend function")
            delay(1000)
            println("job1: after suspend function") // 작업 취소에 영향을 받지 않는다.
        }
        launch { // 부모의 문맥을 상속했다(상위 launch의 자식이다.)
            // launch(Dispatchers.Default) { // 부모의 문맥을 상속했다.(상위 launch의 자식), 분리된 작업이다.
            // CoroutineScope(Dispatchers.Default).launch {// 새로운 스코프가 구성되어 request와 무관하다.
            dalay(100)
            println("job2: before suspend function")
            delay(1000)
            println("job2: after suspend function") //request(부모)가 취소가 되면 수행되지 않는다.
        }
    }
    delay(500)
    request.cancel()//부모 코루틴의 취소하는 것이다.
    delay(1000)
}

 

 

 

시작 시점에 대한 속성 - launch의 원형

public fun launch(
    context: CoroutineContext,
    start: CoroutineStart,
    parent: Job?,
    onCompletion: CompletionHandler?,
    block: suspend CoroutionScope.() -> Unit): Job{
    ...
}
....
)

 

parent는 부모와 자식간의 관계를 만들 경우에 사용하며, 부모가 없는 경우도 있으므로 null 이 들어올 수도 있다.

onCompletion은 완료처리에 관한 것이므로 이 역시 null 값으로  될 수 있다.

 

 

CoroutineStart

DEFAULT: 즉시 시작(해당 문맥에 따라 즉시 스케줄링이 된다. = 준비가 됐다!

즉, 실행전 준비 큐에 넣고 바로 꺼내 실행하는 개념이다. 코루틴이 실행 전에 취소 가능하다.)

 

LAZY: 코루틴을 느리게 시작한다. (처음에는 중단된 상태이며 start()나 async일 경우는 await() 등으로 시작한다.)

 

ATOMIC: 원자적으로 즉시 시작한다. (DEFAULT와 비슷하나 코루틴을 실행전에는 취소불가, 실행 후 취소할 수 있다.

일단 실행하는 것을 보장하는 것이다.)

 

UNDISPATCHED: 현재 스레드에서 즉시 시작한다. (따로 분리해서 시작하는 것이 아니라  현재 호출되는 스레드에서 시작되는 것으로  첫 지연함수까지, 이후 재개시 다 디스패치(분리) 된다.)

 

start() 혹은 await()가 호출 될 때 실제로 시작한다.

launch나 async는 즉시 실행되지만 start 옵션에 따라 실행시점을 늦출 수 있다.

val job = async(start = coroutineStart.LAZY) { do1() }
...
job.start() // 실제 시작 시점으로 또는 job.await()으로 시작된다.        

 

 

 

다음 편에도 계속,,,