이동식 저장소

[Kotlin] Coroutines - Exception Handling and Supervision 본문

Primary/Kotlin

[Kotlin] Coroutines - Exception Handling and Supervision

해스끼 2021. 1. 25. 11:27

목차

코루틴이 취소되면 CancellationException이 발생한다. 이번 글에서는 코루틴이 취소되는 다양한 경우에 대해 알아본다.

예외 전달

코루틴 빌더는 launchactor처럼 예외를 자동으로 전달하는 종류와 asyncproduce처럼 사용자에게 의존하는 종류로 나눌 수 있다. 루트 코루틴(어느 코루틴의 자식도 아닌 코루틴)을 만들 때, launch 등은 예외를 Thread.uncaughtExceptionHandler에서 즉시 처리한다. 예외가 발생하면 자체적으로 처리한다는 뜻이다. 그러나 async 등은 부모에게 예외를 전달할 수 있을 때까지 기다린다. await 등이 호출되기 전까지는 예외가 발생했는지 알려주지 않는다.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = GlobalScope.launch {        // 루트 코루틴
        println("Throwing exception from launch")
        throw IndexOutOfBoundsException() // Thread.defaultUncaughtExceptionHandler가 처리
    }
    job.join()
    println("Joined failed job")
    val deferred = GlobalScope.async { // 루트 코루틴
        println("Throwing exception from async")
        throw ArithmeticException()   // await 등을 호출할 때까지 기다렸다가 전달
    }
    try {
        deferred.await()
        println("Unreached")
    } catch (e: ArithmeticException) {
        println("Caught ArithmeticException")
    }
}

launch 안에서 발생한 예외는 throw 즉시 처리된다. 하지만 async 안에서 발생한 예외는 await이 호출되기 전에는 전달되지 않으며, 예외 처리 역시 우리가 직접 해야 한다.

Throwing exception from launch
Exception in thread "DefaultDispatcher-worker-2 @coroutine#2" java.lang.IndexOutOfBoundsException
Joined failed job
Throwing exception from async
Caught ArithmeticException

CoroutineExceptionHandler

물론 launch의 핸들러를 직접 정의할 수도 있다. 코루틴 문맥의 요소 중 CoroutineExceptionHandler를 통해 루트와 자식 코루틴에서 사용될 핸들러를 정의할 수 있다. 하지만 코루틴은 예외가 발생한 순간 종료되므로, 핸들러에서는 단지 예외의 사후 처리만을 할 수 있다. 예를 들어 로그를 거나 코루틴을 재시작하는 등의 작업을 할 수 있다.

JVM 자체의 기본 핸들러를 재정의할 수도 있다. ServiceLoader를 통해 CoroutineExceptionHandler를 등록하면 된다. 기본적으로는 Thread.defaultUncaughtExceptionHandler가 등록되어 있으며, 안드로이드에서는 uncaughtExceptionPreHandler가 기본으로 등록되어 있다.

CoroutineExceptionHandler는 어떤 방식으로든 처리되지 않은 예외만을 처리한다. 예를 들어 모든 자식이 예외를 부모에게 전달한다면, 자식에서 발생한 예외가 부모를 거쳐 루트까지 전달된다 해도 이미 어떤 방식으로든 처리되었으므로(전달됨) CoroutineExceptionHandler가 호출되지 않는다. 또, async는 모든 예외를 Deferred 객체에 표현하기 때문에 CoroutineExceptionHandler가 아무 소용이 없다.

val handler = CoroutineExceptionHandler { _, exception -> 
    println("CoroutineExceptionHandler got $exception") 
}
val job = GlobalScope.launch(handler) { // 루트 코루틴
    throw AssertionError()              // 아무도 처리하지 않음; CoroutineExceptionHandler가 처리
}
val deferred = GlobalScope.async(handler) { // 루트 코루틴
    throw ArithmeticException()             // deferred.await()을 호출해야만 전달됨
}
joinAll(job, deferred)
CoroutineExceptionHandler got java.lang.AssertionError

async에 등록한 handler가 호출되지 않음을 알 수 있다. 루트 async에서 발생한 처리되지 않은 예외는 awaittry-catch 블럭으로 묶어서 처리해야 한다.

코루틴의 취소와 예외

계속 말했지만 코루틴의 취소는 항상 예외를 동반한다. 코루틴이 취소되면 CancellationException이 발생하고, 이 예외는 따로 핸들러를 지정하지 않으면 어떠한 핸들러도 처리하지 않는다. 따라서 CancellationException은 디버깅 용도로만 사용하는 것이 좋다.

코루틴이 CancellationException에 의해 취소된다고 해도 부모까지 취소되는 것은 아니다.

val job = launch {
    val child = launch {
        try {
            delay(Long.MAX_VALUE)
        } finally {
            println("Child is cancelled")
        }
    }
    yield()
    println("Cancelling child")
    child.cancel()
    child.join()
    yield()
    println("Parent is not cancelled")
}
job.join()
Cancelling child
Child is cancelled
Parent is not cancelled

코루틴 실행 중 CancellationException을 제외한 다른 예외가 발생하면, 부모 코루틴도 동일한 예외에 의해 취소된다. Structured concurrency를 보장하려면 이렇게 해야만 한다. CoroutineExceptionHandler를 구현했다 하더라도 자식에서는 사용되지 않는다.

자식에서 발생한 예외는 모든 자식이 종료된 후에 부모 코루틴에서 처리된다. 물론 예외를 처리한 후에는 부모 코루틴 역시 종료될 것이다.

val handler = CoroutineExceptionHandler { _, exception -> 
    println("CoroutineExceptionHandler got $exception") 
}
val job = GlobalScope.launch(handler) {
    launch { // 자식 1
        try {
            delay(Long.MAX_VALUE)
        } finally {
            withContext(NonCancellable) {
                println("Children are cancelled, but exception is not handled until all children terminate")
                delay(100)
                println("The first child finished its non cancellable block")
            }
        }
    }
    launch { // 자식 2
        delay(10)
        println("Second child throws an exception")
        throw ArithmeticException()
    }
    delay(1000)
    println("Will this be executed?") // 실행되지 않음 (예외에 의해 부모도 종료)
}
job.join()
Second child throws an exception
Children are cancelled, but exception is not handled until all children terminate
The first child finished its non cancellable block
CoroutineExceptionHandler got java.lang.ArithmeticException

예외가 여러 개라면

여러 개의 자식이 예외에 의해 취소된다면, 일반적으로 가장 먼저 발생한 예외가 처리된다. 다른 모든 예외는 suppressed된 예외로 간주되어 첫 번째 예외에 붙어서 전달된다.

import kotlinx.coroutines.*
import java.io.*

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception with suppressed ${exception.suppressed.contentToString()}")
    }
    val job = GlobalScope.launch(handler) {
        launch {
            try {
                delay(Long.MAX_VALUE) 
            } finally {
                throw ArithmeticException() // 또다른 예외: suppressed
            }
        }
        launch {
            delay(100)
            throw IOException()             // 최초의 예외
        }
        delay(Long.MAX_VALUE)
    }
    job.join()  
}

참고로 이 코드는 JDK7 이상을 지원하는 환경에서만 작동한다. JS와 Native 환경에서는 2021. 01. 25. 기준으로 작동하지 않는다고 한다.

CoroutineExceptionHandler got java.io.IOException with suppressed [java.lang.ArithmeticException]

CancellationException은 다른 예외와 별도로 취급된다.

val handler = CoroutineExceptionHandler { _, exception ->
    println("CoroutineExceptionHandler got $exception")
}
val job = GlobalScope.launch(handler) {
    val inner = launch {            // 여기서 실행한 모든 코루틴은 종료될 것
        launch {
            launch {
                throw IOException() // 최초의 예외
            }
        }
    }
    try {
        inner.join()
    } catch (e: CancellationException) {
        println("Rethrowing CancellationException with original cause")
        throw e // CancellationException을 던지지만 최초의 예외(IOException)은 핸들러에 전달됨
    }
}
job.join()
Rethrowing CancellationException with original cause
CoroutineExceptionHandler got java.io.IOException

Supervision

방금 봤듯이 자식 코루틴이 취소되면 연관된 모든 코루틴(부모, 부모의 다른 자식 등)이 종료된다. 이것을 양방향 취소라고 한다. 하지만 양방향이 아닌 단방향 취소가 필요한 경우도 있다.

예를 들어 UI를 그리는 코루틴이 여러 개 있다고 하자. 코루틴 하나가 취소됐다고 해서 다른 모든 코루틴이 취소될 필요는 없다. 최소한 성공한 부분만이라도 보여줘야 하지 않겠는가? 물론 UI 컴포넌트 자체가 종료될 때에는 모든 UI 코루틴이 종료되어야 한다. 이런 상황에서는 단방향 취소를 사용해야 한다.

Supervision Job

이러한 맥락에서 SupervisorJob을 사용할 수 있다. SupervisorJobJob과 거의 유사하지만, 예외에 의한 취소가 자식에게만 전달되고 부모에게는 전달되지 않는다. 실제로 그런지 실험해 보자.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val supervisor = SupervisorJob()
    with(CoroutineScope(coroutineContext + supervisor)) {
        // 실제로는 핸들러를 이따구로 구현하면 안 된다. 따라하지 마시오.
        val firstChild = launch(CoroutineExceptionHandler { _, _ ->  }) {
            println("The first child is failing")
            throw AssertionError("The first child is cancelled")
        }
        val secondChild = launch {
            firstChild.join()
            // firstChild가 취소되어도 secondChild는 취소되지 않음
            println("The first child is cancelled: ${firstChild.isCancelled}, but the second one is still active")
            try {
                delay(Long.MAX_VALUE)
            } finally {
                // 하지만 부모가 취소되면 secondChild도 취소됨 (자식이니까)
                println("The second child is cancelled because the supervisor was cancelled")
            }
        }
        firstChild.join()
        println("Cancelling the supervisor")
        supervisor.cancel()
        secondChild.join()
    }
}
The first child is failing
The first child is cancelled: true, but the second one is still active
Cancelling the supervisor
The second child is cancelled because the supervisor was cancelled

Supervision scope

Scoped concurrency를 얻기 위해 coroutioneScope 대신 supervisorScope을 사용할 수도 있다. 이 스코프는 coroutineScope처럼 모든 자식이 종료될 때까지 기다리며, 예외를 아래 방향으로만 전달한다.

fun main() = runBlocking {
    try {
        supervisorScope {
            val child = launch {
                try {
                    println("The child is sleeping")
                    delay(Long.MAX_VALUE)
                } finally {
                    println("The child is cancelled")
                }
            }
            // yield를 사용하여 자식이 뭔가 출력할 수 있도록 함
            yield()
            println("Throwing an exception from the scope")
            throw AssertionError()
        }
    } catch(e: AssertionError) {
        println("Caught an assertion error")
    }
}
The child is sleeping
Throwing an exception from the scope
The child is cancelled
Caught an assertion error

Supervisor job에서의 예외 처리

Supervisor job에서는 예외가 부모로 전달되지 않는다고 했다. 따라서 모든 자식은 스스로 예외를 처리해야 하며, 이 과정에서 CoroutineExceptionHandler를 사용해야 한다.

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, exception -> 
        println("CoroutineExceptionHandler got $exception") 
    }
    supervisorScope {
        val child = launch(handler) {
            println("The child throws an exception")
            throw AssertionError() // child에서 자체 처리, scope에는 전달되지 않음
        }
        println("The scope is completing")
    }
    println("The scope is completed")
}
The scope is completing
The child throws an exception
CoroutineExceptionHandler got java.lang.AssertionError
The scope is completed

참고 문헌

Exception Handling and Supervision - kotlinlang

Comments