Post

coroutine 알아보기 (5) - Structured Concurrency 이해하기

coroutine Structured Concurrency

Structured Concurrency

Coroutines follow a principle of structured concurrency which means that new coroutines can only be launched in a specific CoroutineScope which delimits the lifetime of the coroutine.
In a real application, you will be launching a lot of coroutines. Structured concurrency ensures that they are not lost and do not leak.

위는 코틀린 공식문서에서 발췌한 내용입니다.

즉, Structured Concurrency(구조적 동시성)는 코루틴이 명확한 범위(scope) 내에서 생성되고 관리되도록 하여, 예측 가능하고 안전한 동시 실행을 보장하는 개념입니다.

이 개념은 부모-자식 관계를 기반으로 한 코루틴 관리를 의미하며, 이를 통해 코루틴의 수명 주기를 부모가 책임지고 관리할 수 있습니다.

약간의 그림과 코드로 조금 더 다가가봅니다.

image.png

코루틴의 life cycle을 살표보면, 주어진 작업이 완료된 코루틴은 바로 completed가 되는게 아니라 completing으로 처리된 후 completed가 되는 것을 확인 할 수 있습니다.

그 이유는 자식 코루틴이 있을 경우, 자식 코루틴들이 모두 완료될 때까지 기다릴 수 있고, 자식 코루틴들 중 하나에서 예외가 발생하면 다른 자식 코루틴들에게도 취소 요청을 보내기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun main(): Unit = runBlocking {
    // 자식1
    launch {
        delay(600L)
        printWithThread("A")
    }

    // 자식2
    launch {
        delay(500L)
        throw IllegalArgumentException("코루틴 실패!")
    }
}

// Exception in thread "main" java.lang.IllegalArgumentException: 코루틴 실패!

위 코드에서는 첫 번째 코루틴이 A를 출력하고 있고, 두 번째 코루틴이 예외를 던지고 있습니다.

두 코루틴은 독립적이기 때문에 예외도 발생하고 A도 출력될 것 같지만, 실제로는 예외만 발생되는 것을 확인할 수 있습니다.

그 이유는, 두 번째 코루틴에서 발생한 예외가 runBlocking 에 의해 만들어진 부모 코루틴에게 취소 신호를 보내게 되고, 이 취소 신호를 받은 부모 코루틴이 다른 자식 코루틴인 첫 번째 코루틴까지 취소시키기 때문입니다.

부모-자식 관계의 코루틴이 한 몸 처럼 움직이는 것을 볼 수 있습니다.

이것이 위에서 언급한 “부모-자식 관계를 기반으로 한 코루틴 관리를 의미하며, 이를 통해 코루틴의 수명 주기를 부모가 책임지고 관리할 수 있습니다.” 의 내용입니다.

image.png

그렇다면 우리는 코루틴 취소와 예외를 합쳐본다면 이러한 사실을 정의할 수 있습니다.

  • 자식 코루틴에서 예외가 발생할 경우, Structured Concurrency에 의해 부모 코루틴이 취소되고, 부모 코루틴의 다른 자식 코루틴들도 취소된다.
  • 자식 코루틴에서 예외가 발생하지 않더라도 부모 코루틴이 취소되면, 자식 코루틴들이 취소된다.
  • 다만 CancellationException의 경우 정상적인 취소로 간주하기 때문에 부모 코루틴에게 전파되지 않고, 부모 코루틴의 다른 자식 코루틴을 취소시키지도 않는다.

Structured Concurrency가 필요한 이유

  • 리소스 누수 방지: 명확한 스코프가 없으면 생성된 코루틴을 추적하기 어려워 종료되지 않은 채 남을 수 있습니다. 부모-자식 관계를 통해 코루틴의 수명 주기를 효과적으로 관리하여 이러한 문제를 방지할 수 있습니다.

  • 예외 처리 일관성: 부모 코루틴이 자식 코루틴의 예외를 감지하고 처리 가능합니다. 이를 통해 예외가 체계적으로 관리되며, 필요할 경우 SupervisorScope를 활용하여 개별 코루틴이 독립적으로 동작하도록 설정할 수 있습니다.

  • 코루틴 관리 용이: 부모가 자식 코루틴을 기다리므로, 모든 작업이 완료될 때까지 안전한 종료가 보장됩니다. 또한, 부모 코루틴이 종료될 때 자식 코루틴도 함께 종료되므로 불필요한 작업이 지속되지 않습니다

Structured Concurrency를 잘 활용하면 코루틴을 안전하고 예측 가능하게 관리할 수 있으며, 동시성 프로그래밍에서 발생할 수 있는 다양한 문제를 효과적으로 해결할 수 있습니다.

Kotlin에서 Structured Concurrency를 적용하는 방법

Kotlin에서는 CoroutineScope를 사용하여 Structured Concurrency를 구현합니다.

CoroutineScope이란?
launch 혹은 async 와 같은 코루틴 빌더는 CoroutineScope 의 확장함수입니다.
runBlocking 이 코루틴과 루틴의 세계를 이어주며, CoroutineScope를 제공해주었고, runBlocking 안에서 launch 와 async 를 사용한 것입니다.
만약 우리가 직접 CoroutineScope 을 만든다면 runBlocking 이 굳이 필요하지 않습니다. main 함수를 일반 함수로 만들어 코루틴이 끝날 때까지 main 스레드를 대기시킬 수도 있고, main함수 자체를 suspend 함수로 만들어 join() 시킬 수도 있습니다.
자세한건 다음 포스트-CoroutineScope & CoroutineContext에 적어놓았습니다.

  1. runBlocking과 coroutineScope
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
     fun main() = runBlocking {  // runBlocking이 부모 스코프
         launch {
             delay(1000L)
             println("첫 번째 코루틴 완료")
         }
    
         launch {
             delay(500L)
             println("두 번째 코루틴 완료")
         }
    
         println("모든 자식 코루틴이 끝날 때까지 기다림")
     }
    

    runBlocking이 부모이므로, 자식 코루틴들이 모두 끝날 때까지 대기합니다.

    • runBlocking: 부모 코루틴이 자식 코루틴이 끝날 때까지 현재 스레드를 차단(blocking)함
    • coroutineScope: 부모 코루틴이 자식 코루틴을 기다리지만, 현재 스레드는 차단되지 않음
  2. CoroutineScope 사용
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
     fun main() = runBlocking {
         coroutineScope {  // 부모 코루틴 스코프
             launch {
                 delay(1000L)
                 println("Job 1 완료")
             }
    
             launch {
                 delay(500L)
                 println("Job 2 완료")
             }
         }
         println("모든 작업 완료")
     }
    

    coroutineScope 내부의 모든 launch가 완료될 때까지 부모는 대기합니다.

  3. SupervisorScope 사용 (예외 전파 방지)

    기본적으로 자식 코루틴에서 예외가 발생하면 부모에게 전파됩니다. 하지만 SupervisorScope를 사용하면 개별 코루틴의 예외가 다른 코루틴에 영향을 주지 않도록 할 수 있습니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
     fun main() = runBlocking {
         supervisorScope {
             launch {
                 throw IllegalArgumentException("에러 발생!") // 다른 코루틴에 영향 없음
             }
    
             launch {
                 delay(1000L)
                 println("정상 동작 코루틴")
             }
         }
         println("모든 작업 완료")
     }
    

    첫 번째 launch에서 예외가 발생해도 두 번째 launch는 정상 동작

정리

Structured Concurrency는 코루틴을 명확한 범위 내에서 생성하고 관리하여 리소스 누수 방지, 일관된 예외 처리, 안전한 종료를 보장하는 중요한 개념입니다

  • 부모-자식 관계를 기반으로 한 코루틴 관리를 통해 코루틴의 수명 주기를 효과적으로 제어할 수 있습니다.
  • 부모 코루틴은 자식 코루틴이 모두 완료될 때까지 기다리며, 자식 중 하나에서 예외가 발생하면 다른 자식 코루틴까지 취소됩니다.
  • SupervisorScope를 활용하면 특정 자식 코루틴의 예외가 다른 코루틴에 영향을 주지 않도록 개별적으로 관리할 수 있습니다.
  • runBlocking, coroutineScope, SupervisorScope 등의 스코프를 적절히 활용하면 안정적이고 예측 가능한 동시성 코드를 작성할 수 있습니다.

Structured Concurrency를 잘 이해하고 활용하면 코루틴 기반의 비동기 프로그래밍을 더욱 안전하고 효율적으로 설계할 수 있습니다

[출처]

  • https://kotlinlang.org/docs/coroutines-overview.html
  • https://kotlinlang.org/docs/coroutines-basics.html#structured-concurrency
This post is licensed under CC BY 4.0 by the author.