ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Coroutine exceptions handling
    프로그래밍/Kotlin 2022. 6. 22. 00:14
    반응형
    • 결론
      • When a thread throws an exception, a process will be killed.
      • When a coroutine throws an exception, the process will be killed too?
        • When coroutine throws an exception, the process will be killed too. 
        • When children coroutines throw an exception, children coroutines will be canceled.
        • 기본적으로는 child coroutine에서 Exception이 발생하면 부모 coroutine에게 자신의 실패 상태를 전파하기 때문에 나머지 children job들이 취소된다.
          • 예외 적용
            • CancellationException 은 코루틴 내부적으로 무시된다.
            • 전체 취소가 되지 않길 바라면, 모든 children job에 try-catch를 넣거나 CoroutineExceptionHandler, SupervisionJob을 사용해본다.
    • Exception handling이 필요하면, 내부적으로 try-catch나 CoroutineExceptionHandler을 사용하고 
      • Child coroutine이 취소되지 않길 바라면 SupervisionJob을 사용한다. 

     

    Why use children coroutines?

    더보기
    import kotlinx.coroutines.*
    
    fun main() = runBlocking { /* this: CoroutineScope */
        launch { /* ... */ }
        // the same as:    
        this.launch { /* ... */ }
    }
    • We can say that the nested coroutine (started by launch in this example) is a child of the outer coroutine (started by runBlocking). This "parent-child" relationship works through scopes: the child coroutine is started from the scope corresponding to the parent coroutine.

    It's also possible to start a new coroutine from the global scope using GlobalScope.async or GlobalScope.launch. This will create a top-level "independent" coroutine.

    The mechanism providing the structure of the coroutines is called "structured concurrency". Let's see what benefits structured concurrency has over global scopes:
    • The scope is generally responsible for child coroutines, and their lifetime is attached to the lifetime of the scope.
    • The scope can automatically cancel child coroutines if something goes wrong or if a user simply changes their mind and decides to revoke the operation.
    • The scope automatically waits for completion of all the child coroutines. Therefore, if the scope corresponds to a coroutine, then the parent coroutine does not complete until all the coroutines launched in its scope are complete.

    자식 코루틴들은 Scope이나 부모 코루틴에 의해서 취소 가능하다. 

    https://play.kotlinlang.org/hands-on/Introduction%20to%20Coroutines%20and%20Channels/06_StructuredConcurrency

     

    Cancelation exception

    @OptIn(DelicateCoroutinesApi::class)
    @Test
    fun test_cancel() = runBlocking {
        val scope = GlobalScope
        val job = scope.launch {
            try {
                delay(Long.MAX_VALUE)
            } catch (t: Throwable) {
                println("exception: $t.")
            } finally {
                println("finally: canceled.")
            }
        }
        job.cancel()
        job.join()
    }
    
    // Outputs
    // 코루틴 취소 시, JobCancellationException이 던져짐. try-catch로 확인 가능.
    // exception: kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job="coroutine#2":StandaloneCoroutine{Cancelling}@401f4551.
    // finally: canceled.
    
    @OptIn(DelicateCoroutinesApi::class)
    @Test
    fun test_cancel() = runBlocking {
        val scope = GlobalScope
        val job = scope.launch {
            try {
                delay(Long.MAX_VALUE)
            } finally {
                println("finally: canceled.")
            }
        }
        job.cancel()
        job.join()
    }
    
    // Outputs 
    // 딱히 exception이 나지 않음. Process도 종료 안되고 코루틴 내부적으로 무시됨.
    // finally: canceled.

     

    Exception propagation

    • launch and actor
      • exception propagation
      • When these builders are used to create a root coroutine, builders treat exceptions as uncaught exceptions, similar to Java's Thread.uncaughtExceptionHandler.
    • async and produce
      • exposing them to users
      • When these builders are used to create a root coroutine, builders are relying on the user to consume the final exception, for example via await or receive.
    • Here we look at what happens if an exception is thrown during cancellation or multiple children of the same coroutine throw an exception.
    @OptIn(DelicateCoroutinesApi::class)
    fun main() = runBlocking {
        val job = GlobalScope.launch { // root coroutine with launch
            println("Throwing exception from launch")
            throw IndexOutOfBoundsException() // Will be printed to the console by Thread.defaultUncaughtExceptionHandler
        }
        job.join()
        println("Joined failed job")
        val deferred = GlobalScope.async { // root coroutine with async
            println("Throwing exception from async")
            throw ArithmeticException() // Nothing is printed, relying on user to call await
        }
        try {
            println("Start await")
            deferred.await()
            println("Unreached")
        } catch (e: ArithmeticException) {
            println("Caught ArithmeticException")
        }
    }

     

    Output

    Throwing exception from launch
    Exception in thread "DefaultDispatcher-worker-2 @coroutine#2" java.lang.IndexOutOfBoundsException
    	at com.example.largescreen.ExampleUnitTest$exception_propagation$1$job$1.invokeSuspend(ExampleUnitTest.kt:82)
    	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
    	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:570)
    	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:749)
    	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:677)
    	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:664)
    	Suppressed: kotlinx.coroutines.DiagnosticCoroutineContextException: [CoroutineId(2), "coroutine#2":StandaloneCoroutine{Cancelling}@3dec09e5, Dispatchers.Default]
    Joined failed job
    Start await
    Throwing exception from async
    Caught ArithmeticException

     

    • Thread.setDefaultUncaughtExceptionHandler()
      • Global하게 쓰일 수 있는 try-catch
    • Thread.setUncaughtExceptionHandler()
      • Thread마다 쓰일 수 있는 try-catch
    // Thread.setDefaultUncaughtExceptionHandler() 없을 때, 
    @Test
    fun testException() {
        println("Started.")
        thread(true) {
            println("Run Thread1")
            throw IndexOutOfBoundsException("Thread1")
        }
    
        thread(true) {
            println("Run Thread2")
            throw IndexOutOfBoundsException("Thread2")
        }
        println("Finished.")
    }
    
    // Outputs
    Started.
    Run Thread1
    Finished.
    Run Thread2
    Exception in thread "Thread-3" java.lang.IndexOutOfBoundsException: Thread1
    	at com.example.largescreen.ExampleUnitTest$testException$1.invoke(ExampleUnitTest.kt:33)
    	at com.example.largescreen.ExampleUnitTest$testException$1.invoke(ExampleUnitTest.kt:31)
    	at kotlin.concurrent.ThreadsKt$thread$thread$1.run(Thread.kt:30)
    Exception in thread "Thread-4" java.lang.IndexOutOfBoundsException: Thread2
    	at com.example.largescreen.ExampleUnitTest$testException$2.invoke(ExampleUnitTest.kt:38)
    	at com.example.largescreen.ExampleUnitTest$testException$2.invoke(ExampleUnitTest.kt:36)
    	at kotlin.concurrent.ThreadsKt$thread$thread$1.run(Thread.kt:30)
        
    // Thread.setDefaultUncaughtExceptionHandler() 있을 때, 
    @Test
    fun testException() {
        Thread.setDefaultUncaughtExceptionHandler { t, e -> println("$t $e") }
        
        println("Started.")
        thread(true) {
            println("Run Thread1")
            throw IndexOutOfBoundsException("Thread1")
        }
    
        thread(true) {
            println("Run Thread2")
            throw IndexOutOfBoundsException("Thread2")
        }
        println("Finished.")
    }
    
    // Outputs
    Started.
    Run Thread1
    Thread[Thread-3,5,main] java.lang.IndexOutOfBoundsException: Thread1
    Run Thread2
    Thread[Thread-4,5,main] java.lang.IndexOutOfBoundsException: Thread2
    Finished.

     

    CoroutineExceptionHandler

    • Thread.setUncaughtExceptionHandler()와 비슷하게 사용 가능 
    • It is possible to customize the default behavior of printing uncaught exceptions to the console. 
    • CoroutineExceptionHandler context element on a root coroutine can be used as a generic catch block for this root coroutine and all its children where custom exception handling may take place.
    • It is similar to Thread.uncaughtExceptionHandler.
    • You cannot recover from the exception in the CoroutineExceptionHandler. The coroutine had already completed with the corresponding exception when the handler is called. Normally, the handler is used to log the exception, show some kind of error message, terminate, and/or restart the application.
    • Coroutines running in supervision scope do not propagate exceptions to their parent and are excluded from this rule. A further Supervision section of this document gives more details.
    • CoroutineExceptionHandler is invoked only on uncaught exceptions — exceptions that were not handled in any other way. In particular, all children coroutines (coroutines created in the context of another Job) delegate handling of their exceptions to their parent coroutine, which also delegates to the parent, and so on until the root, so the CoroutineExceptionHandler installed in their context is never used. (=propagation)
    • In addition to that, async builder always catches all exceptions and represents them in the resulting Deferred object, so its CoroutineExceptionHandler has no effect either. 
    val handler = CoroutineExceptionHandler { _, exception -> 
        println("CoroutineExceptionHandler got $exception") 
    }
    val job = GlobalScope.launch(handler) { // root coroutine, running in GlobalScope
        throw AssertionError()
    }
    val deferred = GlobalScope.async(handler) { // also root, but async instead of launch
        throw ArithmeticException() // Nothing will be printed, relying on user to call deferred.await()
    }
    joinAll(job, deferred)

    Outputs

    CoroutineExceptionHandler got java.lang.AssertionError

     

    try-catch문이 있으면 CoroutineExceptionHandler는 사용되지 않는다.

    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception")
    }
    val job = GlobalScope.launch(handler) { 
        try {
            throw AssertionError()
        } catch (ex: Throwable) {
            println("Try-Catch $ex")
        }
    }
    job.join()
    
    // Outputs
    Try-Catch java.lang.AssertionError

     

    child coroutine과 root coroutine에 CoroutineExceptionHandler가 있으면 root의 것이 사용된다.

    val childHandler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler for child got $exception")
    }
    val job = GlobalScope.launch {
        launch(childHandler) {
            throw AssertionError("For child")
        }
    }
    job.join()

     

    단, child coroutine가 단독으로 CoroutineExceptionHandler를 사용할 수 없다. 

    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception")
    }
    val job = GlobalScope.launch {
        launch(handler) {
            throw AssertionError()
        }
    }
    job.join()
    
    // Output
    Exception in thread "DefaultDispatcher-worker-2 @coroutine#3" java.lang.AssertionError
    	at com.example.largescreen.ExampleUnitTest$childFailed2$1$job$1$1.invokeSuspend(ExampleUnitTest.kt:402)
    	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
    	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:570)
    	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:749)
    	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:677)
    	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:664)
    	Suppressed: kotlinx.coroutines.DiagnosticCoroutineContextException: [CoroutineId(2), "coroutine#2":StandaloneCoroutine{Cancelling}@76e5dd07, Dispatchers.Default]

     

    CoroutineExceptionHandler가 Exception propagation을 막진 못한다.

    val rootHandler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler for root got $exception")
    }
    val job = GlobalScope.launch(rootHandler) {
        val firstChildJob = launch {
            delay(50L)
            throw AssertionError("First child job exception.")
        }
        val secondChildJob = launch {
            println("Start second child job.")
            try {
                firstChildJob.join()
                delay(Long.MAX_VALUE)
            } catch (e: CancellationException) {
                println("Canceled second child job $e")
            } finally {
                println("Finished second child job.")
            }
        }
        firstChildJob.join()
        secondChildJob.join()
    }
    job.join()
    
    // Output
    Start second child job.
    Canceled second child job kotlinx.coroutines.JobCancellationException: Parent job is Cancelling; job="coroutine#2":StandaloneCoroutine{Cancelling}@5f8ded00
    Finished second child job.
    CoroutineExceptionHandler for root got java.lang.AssertionError: First child job exception.

     

    Cancellation and exceptions

    • Cancellation is closely related to exceptions.
    • Coroutines internally use CancellationException for cancellation, these exceptions are ignored by all handlers, so they should be used only as the source of additional debug information, which can be obtained by catch block.
    • When a coroutine is cancelled using Job.cancel, it terminates, but it does not cancel its parent.
    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()

    Output

    Cancelling child
    Child is cancelled
    Parent is not cancelled

     

    CancellationException으로 부모 코루틴이 취소되지는 않는다. 하지만, CancellationException이 아닌 다른 Exception이면 얘기가 다르다.

    = CancellationException이 propagation까지 막진 않는다.

    Children coroutine exception propagation 상세 

    https://kotlinworld.com/153

     

    If a coroutine encounters an exception other than CancellationException, it cancels its parent with that exception. This behaviour cannot be overridden and is used to provide stable coroutines hierarchies for structured concurrency. CoroutineExceptionHandler implementation is not used for child coroutines.

    val handler = CoroutineExceptionHandler { _, exception -> 
        println("CoroutineExceptionHandler got $exception") 
    }
    val job = GlobalScope.launch(handler) {
        launch { // the first child
            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 { // the second child
            delay(10)
            println("Second child throws an exception")
            throw ArithmeticException()
        }
    }
    println("--Children are running--")
    job.join()
    println("--Children done--")

    Output

    --Children are running--
    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
    --Children done--

     

    Exceptions aggregation

    When multiple children of a coroutine fail with an exception, the general rule is "the first exception wins", so the first exception gets handled. All additional exceptions that happen after the first one are attached to the first exception as suppressed ones.

    import kotlinx.coroutines.*
    import java.io.*
    
    @OptIn(DelicateCoroutinesApi::class)
    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) // it gets cancelled when another sibling fails with IOException
                } finally {
                    throw ArithmeticException() // the second exception
                }
            }
            launch {
                delay(100)
                throw IOException() // the first exception
            }
            delay(Long.MAX_VALUE)
        }
        job.join()  
    }
     

    Note: This above code will work properly only on JDK7+ that supports suppressed exceptions.

    Output (Note that this mechanism currently only works on Java version 1.7+.)

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

     

    Cancellation exceptions are transparent and are unwrapped by default:

    val handler = CoroutineExceptionHandler { _, exception ->
        println("CoroutineExceptionHandler got $exception")
    }
    val job = GlobalScope.launch(handler) {
        val inner = launch { // all this stack of coroutines will get cancelled
            launch {
                launch {
                    throw IOException() // the original exception
                }
            }
        }
        try {
            inner.join()
        } catch (e: CancellationException) {
            println("Rethrowing CancellationException with original cause")
            throw e // cancellation exception is rethrown, yet the original IOException gets to the handler  
        }
    }
    job.join()

    Output

    Rethrowing CancellationException with original cause
    CoroutineExceptionHandler got java.io.IOException

    Child 중 하나는 CancellationException이 호출될 것이기 때문에 catch 되고 throw 되지만, handler는 CancellationException이 아닌 IOException을 받는다. 

     

    Supervision

    Exception이 발생하면 cancellation이 코루틴 계층 전체적으로 양방향(부모와 나머지 자식들)으로 전파(bidirectional relationship propagation)되는 것을 확인했다. 

    As we have studied before, cancellation is a bidirectional relationship propagating through the whole hierarchy of coroutines. Let us take a look at the case when unidirectional cancellation is required.

    A good example of such a requirement is a UI component with the job defined in its scope. If any of the UI's child tasks have failed, it is not always necessary to cancel (effectively kill) the whole UI component, but if the UI component is destroyed (and its job is cancelled), then it is necessary to cancel all child jobs as their results are no longer needed. (onDestroy() 시, 단방향으로 다 취소)

    Another example is a server process that spawns multiple child jobs and needs to supervise their execution, tracking their failures and only restarting the failed ones. (일부 코루틴 실패 감시 후 그 것만 다시 시작)

     

     

    SupervisorJob

    The SupervisorJob can be used for these purposes. It is similar to a regular Job with the only exception that cancellation is propagated only downwards(부모에게 전달하지 않음).

    https://medium.com/androiddevelopers/exceptions-in-coroutines-ce8da1ec060c

    This can easily be demonstrated using the following example:

    val supervisor = SupervisorJob()
    with(CoroutineScope(coroutineContext + supervisor)) {
        // launch the first child -- its exception is ignored for this example (don't do this in practice!)
        val firstChild = launch(CoroutineExceptionHandler { _, _ ->  }) {
            println("The first child is failing")
            throw AssertionError("The first child is cancelled")
        }
        // launch the second child
        val secondChild = launch {
            firstChild.join()
            // Cancellation of the first child is not propagated to the second child
            println("The first child is cancelled: ${firstChild.isCancelled}, but the second one is still active")
            try {
                delay(Long.MAX_VALUE)
            } finally {
                // But cancellation of the supervisor is propagated
                println("The second child is cancelled because the supervisor was cancelled")
            }
        }
        // wait until the first child fails & completes
        firstChild.join()
        println("Cancelling the supervisor")
        supervisor.cancel()
        secondChild.join()
    }

    Output

    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

    단, Exception을 propagation하진 않지만 Exception의 try-catch까진 해주진 않는다. 

     

    SupervisionScope

    CoroutineScope + SupervisorJob을 생성하는 것과 동일하다. (supervisionScope : Creates a CoroutineScope with SupervisorJob and calls the specified suspend block with this scope.)

    Instead of coroutineScope, we can use supervisorScope for scoped concurrency. It propagates the cancellation in one direction only and cancels all its children only if it failed itself. It also waits for all children before completion just like coroutineScope does.

    try {
        supervisorScope {
            val child = launch {
                try {
                    println("The child is sleeping")
                    delay(Long.MAX_VALUE)
                } finally {
                    println("The child is cancelled")
                }
            }
            // Give our child a chance to execute and print using yield 
            yield()
            println("Throwing an exception from the scope")
            throw AssertionError()
        }
    } catch(e: AssertionError) {
        println("Caught an assertion error")
    }

     

    Output

    The child is sleeping
    Throwing an exception from the scope
    The child is cancelled
    Caught an assertion error

     

    supervision scope vs supservision job

    CoroutineScope + SupervisionJob을 생성하는 것과 동일하다. (supervisionScope : Creates a CoroutineScope with SupervisorJob and calls the specified suspend block with this scope.)

     

    Exceptions in supervised coroutines

    Another crucial difference between regular and supervisor jobs is exception handling. Every child should handle its exceptions by itself via the exception handling mechanism. This difference comes from the fact that child's failure does not propagate to the parent. It means that coroutines launched directly inside the supervisorScope do use the CoroutineExceptionHandler that is installed in their scope in the same way as root coroutines do (see the CoroutineExceptionHandler section for details).

    val handler = CoroutineExceptionHandler { _, exception -> 
        println("CoroutineExceptionHandler got $exception") 
    }
    supervisorScope {
        val child = launch(handler) {
            println("The child throws an exception")
            throw AssertionError()
        }
        println("The scope is completing")
    }
    println("The scope is completed")

      Output

    The scope is completing
    The child throws an exception
    CoroutineExceptionHandler got java.lang.AssertionError
    The scope is completed

     

     

    children coroutine 구조 어디에 쓸까? 

    child coroutine은 같은 scope, context 안에서 동시에 처리될 수 있음. async 빌더를 쓰면 값 리턴이 필요하지만 launch를 사용하면 결과 값이 필요 없고 두 개의 child coroutine이 완료 돼야 UI 변경 되는 일? 둘 중 하나가 에러가 발생하면 UI 변경이 취소 되어야 하는 일? 

     

    https://medium.com/androiddevelopers/coroutines-first-things-first-e6187bf3bb21

    https://medium.com/androiddevelopers/exceptions-in-coroutines-ce8da1ec060c

    https://kotlinworld.com/153

    https://thdev.tech/kotlin/2019/04/30/Coroutines-Job-Exception/

    반응형
Designed by Tistory.