깨알 개념/Kotlin

[Kotlin] Coroutines - 한 Scope 내에서의 계층 관계

interfacer_han 2024. 7. 31. 00:43

#1 이전 글

 

[Kotlin] Coroutines - Coroutine builder

#1 Coroutine builder kotlinx-coroutines-coreCore primitives to work with coroutines. Coroutine builder functions: Coroutine dispatchers implementing CoroutineDispatcher: More context elements: Synchronization primitives for coroutines: Top-level suspendin

kenel.tistory.com

위 게시글의 CoroutineScope의 생략에 대해 다룬 #6-4에서 이어지는 글이다. 이전 글에선 CoroutineScope을 생략하는 게, 가독성 이외의 의미가 있다고 했다. 그것은 바로 하나의 CoroutineScope 내 Coroutine 간 부모-자식 계층의 구현이다.

 

그리고 이 하나의 CoroutineScope 내 Coroutine 간 부모-자식 계층의 구현은 join() 및 cancel()에서의 이점을 가진다 (join()에서의 이점은 join()과 같은 맥락의 함수인 await()나 receive()에도 적용된다).

 

이 이점들을 설명하기 앞서, Coroutine 간 부모-자식 계층이 어떤 모양으로 표현되는지를 먼저 짚고 넘어갈 필요가 있다.

 

#2 단일 Scope 내 복수의 Coroutines 간 부모-자식 계층 형성의 양상

#2-1 CoroutineScope를 생략하지 않은 코드

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main() {
    val coroutine1 = CoroutineScope(Dispatchers.Default).launch {

        val coroutine2 = CoroutineScope(Dispatchers.Default).launch {
            delay(1000)
            println("coroutine2 is done.")
        }

        val coroutine3 = CoroutineScope(Dispatchers.Default).launch {
            delay(8000)
            println("coroutine3 is done.")
        }

        val coroutine4 = CoroutineScope(Dispatchers.Default).launch {
            delay(6000)
            println("coroutine4 is done.")
        }

        delay(4000)
        println("coroutine1 is done.")
    }

    coroutine1.join()
    println("End")
}

suspend 키워드에 대해 모른다면 이 게시글을 참조한다. 위 코드는 CoroutineScope 안에 또 다른 CoroutineScope가 위치한 코드다. 이 코드를 도식도로 표현하면 아래와 같다.

 

총 4개의 코루틴이 수행되며, 그 코루틴은 각각 다른 코루틴 스코프에 들어있게 된다. 이 도식도에서 coroutine2 ~ 4가 coroutine1와 계층 구조를 이룬다고 오해해서는 안 된다. coroutine2 ~ 4은 coroutine1의 코드 내용에 의해 생성되었을 뿐 아무런 관련이 없다 (물론 호출함-호출됨이라는 관점에서는 계층 구조를 이룬다고 볼 수 있지만, 일반적으로 계층 관계를 의미하는 부모-자식 관계는 아니라는 말이다).

 

#2-2 CoroutineScope를 생략한 코드

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

suspend fun main() {
    val coroutine1 = CoroutineScope(Dispatchers.Default).launch {

        val coroutine2 = launch {
            delay(1000)
            println("coroutine2 is done.")
        }

        val coroutine3 = launch {
            delay(8000)
            println("coroutine3 is done.")
        }

        val coroutine4 = launch {
            delay(6000)
            println("coroutine4 is done.")
        }

        delay(4000)
        println("coroutine1 is done.")
    }

    coroutine1.join()
    println("End")
}

이전 글(#1)의 내용에 기반해  Coroutine Builder의 생략을 구현한 코드다. 첫 CoroutineScope 외에는 추가적인 CoroutineScope가 쓰이지 않았다. 이 코드를 도식도로 표현하면 아래와 같다.

 

#2-1의 도식도와 분명한 차이가 보인다. 4개의 코루틴이 수행되는 것은 동일하지만 모두 하나의 CoroutineScope를 공유하고 있다. 즉, CoroutineScope를 생략하는 것의 의미는 단순히 가독성 향상에 그치지 않으며, 이렇게 다른 구조를 만들어내는 것이다. 그리고 이렇게 어떤 코루틴 스코프 내에서 또 다른 코루틴을 CoroutineScope을 생략해 호출하면, 호출된 coroutine은 호출한 coroutine의 자식 coroutine이 된다.

 

그렇다면, 코루틴에서 부모-자식의 계층화를 형성하는 것은 어떤 이점이 있는가?

 

#3 코루틴 계층화의 이점

#3-1 일괄적 join()

CoroutineScope를 생략하지 않은 #2-1의 출력 결과
coroutine2 is done.
coroutine1 is done.
End

CoroutineScope를 생략한 #2-2의 출력 결과
coroutine2 is done.
coroutine1 is done.
coroutine4 is done.
coroutine3 is done.
End

CoroutineScope를 생략하지 않은 #2-1의 출력 결과와, CoroutineScope를 생략한 #2-2의 출력 결과다. 두 코드 모두 corotuine1.join()을 수행했음에도, 후자의 코드와 달리 전자는 완료되지 않은 코루틴이 존재함을 출력 결과를 통해 알 수 있다 (프로그램이 종료될 때까지 완료되지 않은 코루틴은 출력이 불가능하다). 이는 부모 코루틴의 join()을 수행하면, 자식 코루틴도 암시적으로 join()이 수행되기 때문이다. 즉 코루틴의 계층화를 작업해두면, 일일히 모든 하위(자식) 코루틴의 join()을 수행할 필요가 없어진다 (이 join()에서의 이점은 join()과 같은 맥락의 함수인 await()나 receive()에도 적용된다).

 

#3-2 일괄적 cancle()

import kotlinx.coroutines.*

suspend fun main() {
    val coroutine1 = CoroutineScope(Dispatchers.Default).launch {

        // coroutine1의 자식
        val coroutine2 = launch {
            while (isActive) {
                println("coroutine2 is running... on ${Thread.currentThread().name}")
                delay(1000)
            }
        }

        // coroutine1의 자식
        val coroutine3 = launch {
            while (isActive) {
                println("coroutine3 is running... on ${Thread.currentThread().name}")
                delay(1000)
            }
        }

        // 독립적인 코루틴 (coroutine1의 자식 아님)
        val coroutine4 = CoroutineScope(Dispatchers.Default).launch {
            while (isActive) {
                println("coroutine4 is running... on ${Thread.currentThread().name}")
                delay(1000)
            }
        }

        while (isActive) {
            println("coroutine1 is running... on ${Thread.currentThread().name}")
            delay(1000)
        }
    }

    delay(5000)
    coroutine1.cancel()

    delay(10000)
    println("End")
}

/* 출력 결과
coroutine2 is running... on DefaultDispatcher-worker-3
coroutine1 is running... on DefaultDispatcher-worker-1
coroutine3 is running... on DefaultDispatcher-worker-4
coroutine4 is running... on DefaultDispatcher-worker-1
coroutine2 is running... on DefaultDispatcher-worker-2
coroutine1 is running... on DefaultDispatcher-worker-3
coroutine3 is running... on DefaultDispatcher-worker-1
coroutine4 is running... on DefaultDispatcher-worker-4
coroutine2 is running... on DefaultDispatcher-worker-4
coroutine3 is running... on DefaultDispatcher-worker-3
coroutine1 is running... on DefaultDispatcher-worker-1
coroutine4 is running... on DefaultDispatcher-worker-2
coroutine2 is running... on DefaultDispatcher-worker-2
coroutine1 is running... on DefaultDispatcher-worker-3
coroutine4 is running... on DefaultDispatcher-worker-4
coroutine3 is running... on DefaultDispatcher-worker-1
coroutine2 is running... on DefaultDispatcher-worker-1
coroutine4 is running... on DefaultDispatcher-worker-3
coroutine3 is running... on DefaultDispatcher-worker-2
coroutine1 is running... on DefaultDispatcher-worker-4
coroutine4 is running... on DefaultDispatcher-worker-2
coroutine4 is running... on DefaultDispatcher-worker-2
coroutine4 is running... on DefaultDispatcher-worker-2
coroutine4 is running... on DefaultDispatcher-worker-2
coroutine4 is running... on DefaultDispatcher-worker-2
coroutine4 is running... on DefaultDispatcher-worker-2
coroutine4 is running... on DefaultDispatcher-worker-2
coroutine4 is running... on DefaultDispatcher-worker-2
coroutine4 is running... on DefaultDispatcher-worker-2
coroutine4 is running... on DefaultDispatcher-worker-2
End
*/

일괄적 join()과 같은 맥락으로, 부모 코루틴을 cancle()하면 자식 코루틴들 또한 일괄적으로 cancle()된다.


coroutine2 ~ 3은 coroutine1의 자식으로 뒀지만, coroutine4는 별도의 독립적인 코루틴으로 뒀다. 출력 결과는 보면 coroutine1이 취소되기 전까지 즉 5초 동안 coroutine1 ~ 4 모두가 실행된다. 하지만 그 후 coroutine1.cancel()이 실행되고, coroutine1과 그 자식인 coroutine2 ~ 3이 암시적으로 취소된다. 이후 coroutine4만 실행되는 모습을 확인할 수 있다. coroutine4를 호출한 것은 분명 coroutine1였으나, 그렇다고 coroutine4가 coroutine1의 자식이 되는 건 아니기 때문이다.

 

여담으로, 스레드풀의 스레드답게 코루틴 하나가 스레드 하나를 계속 점유하는 게 아닌, 반납과 재사용을 반복하는 깨알같은 모습도 출력 결과에 보인다.

 

#4 비슷한 글

 

[Kotlin] Coroutines - Structured Concurrency

#1 Unstructured Concurrency코루틴 코드를 설계할 때 조심해야 하는 점이 있다. 다음 코드를 보자. #1-1 코드import kotlinx.coroutines.*class MyClass { suspend fun getMyCount(): Int { var count = 0 CoroutineScope(Dispatchers.IO).lau

kenel.tistory.com

위 게시글은 본 게시글과 비슷한 내용을 다룬다. 바로, 암시적 join()의 수행에 대한 내용이다. 차이점이라면, 본 게시글에선 자식 코루틴의 암시적 join()에 대해 다뤘다면, 위 게시글에선 부모 코루틴의 암시적 join()에 대해 다룬다.