Computer scienceProgramming languagesKotlinConcurrency and parallelismConcurrency

Coroutine exception handling

8 minutes read

In Kotlin coroutines, exceptions propagate differently than in synchronous code. An unhandled exception in a coroutine can affect not just the coroutine itself but also its parent and sibling coroutines. This behavior is part of Kotlin's structured concurrency model, which ensures that an error in one part of a concurrent program is handled appropriately and does not go unnoticed. If one coroutine fails, the entire scope and all coroutines within it are canceled, unless specific exception handling strategies are in place.

Coroutine builders and exception propagation

Coroutine builders, such as launch, async, and produce, handle exceptions in different ways:

  • launch: Starts a coroutine that does not return a result. Exceptions are treated as uncaught and can be handled with a CoroutineExceptionHandler.

  • async: Starts a coroutine that returns a result encapsulated in a Deferred. Exceptions are caught and can be retrieved by calling .await().

  • produce: Similar to async, it starts a coroutine that provides a stream of elements through a ReceiveChannel. Exceptions are caught and can be retrieved when consuming the channel.

But how does exception propagation look?

val scope = CoroutineScope(Job())

scope.launch {
    coroutineScope {
        launch { // Child 1
            println("Child 1 finished")
            throw ArithmeticException()
        }
        launch { // Child 2
            println("Child 2 finished")
        }
    }
    println("Parent finished")
}

In this example, Child 1 throws an ArithmeticException, which will cause the coroutineScope and the parent coroutine to be canceled. Child 2 and the parent coroutine will not complete normally because of the exception in Child 1.

CoroutineExceptionHandler and Supervision

The CoroutineExceptionHandler is used to handle uncaught exceptions within coroutines. It acts as a last line of defense to prevent the coroutine from terminating abruptly. It's important to note that the handler is passed to the coroutine builder as part of the coroutine context:

val handler = CoroutineExceptionHandler { _, exception ->
    println("Coroutine Exception Handled: $exception")
}

val scope = CoroutineScope(Job())

scope.launch(handler) { // Coroutine body with exception handler in context
    throw Exception("Coroutine exception")
}

Supervisor jobs and supervisor scopes are used when we want individual coroutines to fail without affecting their peers. These structures allow a child coroutine to fail and handle its exception independently:

scope.launch {
    supervisorScope {
        launch { // This coroutine can fail independently
            throw Exception("Failed coroutine")
        }
        launch { // This coroutine remains unaffected
        }
    }
}

CancellationException and try-catch Blocks

While CancellationException is not passed to the CoroutineExceptionHandler, it can still be caught in a try-catch block within the coroutine if needed. This is because CancellationException is designed to signal cancellation rather than a programming error.

Handling exceptions with try-catch blocks is also supported in coroutines:

scope.launch {
    try {
        // Potentially failing code
    } catch (e: SomeException) {
        // Exception handling
    }
}

With launch, exceptions will be thrown as soon as they happen. When async is used as a root coroutine, exceptions are thrown when you call .await().

Exceptions thrown within a coroutineScope builder or in coroutines created by other coroutines won’t be caught by the try-catch block that surrounds the coroutineScope call.

Exception aggregation

When dealing with concurrent operations in coroutines, it's possible for multiple child coroutines to fail at the same time or in quick succession. Kotlin coroutines provide a mechanism known as exception aggregation to handle such scenarios, ensuring that no exception information is lost.

In the context of structured concurrency, when a coroutine scope has multiple children and more than one of them fails, the scope needs to handle all the exceptions that arise, not just the first one. To accomplish this, Kotlin coroutines use the following process:

  • Primary Exception: The very first exception that is thrown by any of the child coroutines is designated as the "primary" exception. This exception is what initially triggers the failure of the coroutine scope.

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val job = launch {
            launch {
                // This will be the primary exception
                throw Exception("Primary exception")
            }
        }
        job.join() // Wait for completion
    }
  • Suppressed Exceptions: Any subsequent exceptions thrown by other child coroutines are not lost; instead, they are attached to the primary exception as "suppressed" exceptions. This is akin to the suppressed exception feature found in Java's exception handling, which allows one exception to hold references to multiple other exceptions that were suppressed in order to deliver the primary exception.

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val job = launch {
            launch {
                // This will be the primary exception
                throw Exception("Primary exception")
            }
            launch {
                // This exception will be suppressed
                throw Exception("Suppressed exception")
            }
        }
        job.join() // Wait for completion
        // Handle exceptions (e.g., print them)
    }
  • Cancellation and Aggregation: If the failure of a child coroutine leads to the cancellation of the parent scope, a CancellationException is created. This CancellationException will carry the primary exception as its cause, and all other exceptions as suppressed exceptions attached to it.

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val job = launch {
            launch {
                // This will be the primary exception
                throw Exception("Primary exception")
            }
            launch {
                // This exception will be suppressed
                throw Exception("Suppressed exception")
            }
        }
        try {
            job.join() // Wait for completion
        } catch (e: CancellationException) {
            // CancellationException will carry the primary exception as its cause
            // Suppressed exceptions will be attached to it
        }
    }
  • Handling Aggregated Exceptions: When the coroutine scope completes or fails, the aggregated exception (the primary exception along with any suppressed exceptions) is handled. This could be through a CoroutineExceptionHandler if one is present in the coroutine's context, or it could propagate up the coroutine hierarchy if not explicitly handled.

    import kotlinx.coroutines.*
    
    fun main() = runBlocking {
        val handler = CoroutineExceptionHandler { _, exception ->
            // Handle the primary exception
            println("Caught $exception")
            // Handle all suppressed exceptions
            exception.suppressed.forEach {
                println("Caught suppressed $it")
            }
        }
        val job = launch(handler) {
            launch {
                // This will be the primary exception
                throw Exception("Primary exception")
            }
            launch {
                // This exception will be suppressed
                throw Exception("Suppressed exception")
            }
        }
        job.join() // Wait for completion
    }

By aggregating exceptions in this manner, Kotlin ensures that all relevant information about coroutine failures is preserved and can be handled or reported in a comprehensive way. This is crucial for debugging and for creating resilient, fault-tolerant concurrent applications.

Conclusion

Mastering exception handling in Kotlin coroutines involves understanding structured concurrency, exception propagation through coroutine builders, and the use of CoroutineExceptionHandler and supervisor structures. Exception aggregation is a key feature that ensures all exceptions from concurrently running coroutines are captured and processed. By effectively utilizing tools like CoroutineExceptionHandler, supervisorScope, and familiar try-catch blocks, and by leveraging exception aggregation, developers can ensure robust error handling in their asynchronous Kotlin code. This comprehensive approach allows for the preservation of all relevant exception information, which is crucial for debugging and creating resilient, fault-tolerant concurrent applications.

2 learners liked this piece of theory. 4 didn't like it. What about you?
Report a typo