Note: phiên bản Tiếng Việt của bài này ở link dưới.
https://duongnt.com/launch-exception-handling-vie
Kotlin fully supports asynchronous programming with coroutine. However, exception handling in the asynchronous world is trickier than in sequential programming. This is especially true for coroutines created by the launch
coroutine builder, because they are usually fire-and-forget. Today, we will explore how to handle exceptions occurring inside a coroutine created by the launch
function.
Some dummy functions
In this article, we will use two suspending functions to simulate long running I/O calls. The first waits for a while then prints a message to console; while the second one waits for a while then throws an exception. In both cases, the delay is configurable.
suspend fun success(delayInMs: Long) {
delay(delayInMs)
println("Success!")
}
suspend fun failure(delayInMs: Long) {
delay(delayInMs)
throw Exception("This is a dummy error!")
}
And we will call those two functions on separate coroutines.
runBlocking {
val successJob = launch(Dispatchers.IO) {
success(2000)
}
val failedJob = launch(Dispatchers.IO) {
failure(1000)
}
joinAll(successJob, failedJob)
}
As we can expect, the result is a runtime error.
Exception in thread "main" java.lang.Exception: This is a dummy error!
In the following sections, we will try to catch the exception thrown by the failure
function, while keeping the success
function running.
An naive approach with try-catch
People familiar with sequential programming might try to wrap the two launch
commands in a try-catch
block.
runBlocking {
try {
// code to launch two coroutines
}
catch(ex: Exception) {
println("An error occurred: ${ex.message}")
}
}
However, we still receive the same exception.
Exception in thread "main" java.lang.Exception: This is a dummy error!
Moreover, we cannot find the message Success
in the output. At first glance, this might seem strange. Even if failedJob
throws an error, shouldn’t successJob
still run to completion? After all, each of those jobs is running in a separate coroutine and has its own thread, right?
Job and SupervisorJob in Kotlin
The reason successJob
cannot run to completion is because failedJob
is a normal Job
. When it fails, it cancels the parent coroutine created by runBlocking
. And that parent coroutine in turn cancels the rest of its children, including successJob
.
To stop a failed child coroutine from canceling its parent, we need to use SupervisorJob
instead of Job
. If a coroutine is a SupervisorJob
then even when it fails, the parent and all of its siblings will keep running. There are two ways to create a SupervisorJob
.
Launch each child coroutine with SupervisorJob context
As we can see in the example code, we launch each coroutine with the Dispatchers.IO
context. To create a SupervisorJob
, we need to add the SupervisorJob context to Dispatchers.IO
.
runBlocking {
val successJob = launch(Dispatchers.IO + SupervisorJob()) {
success(2000)
}
val failedJob = launch(Dispatchers.IO + SupervisorJob()) {
failure(1000)
}
joinAll(successJob, failedJob)
}
Notice that the success
function doesn’t actually need the SupervisorJob
context. I added it just for consistency.
Wrap all child coroutines inside a supervisorScope
If we have multiple child coroutines then it’s tiring to add SupervisorJob
to their contexts one by one. However, we cannot just add SupervisorJob
to the parent coroutine created by runBlocking
. This is because the child coroutines are assigned a new Job
, which overrides the SupervisorJob
inherited from their parent.
// This won't work
runBlocking(SupervisorJob()) {
val successJob = launch(Dispatchers.IO) {
success(2000)
}
val failedJob = launch(Dispatchers.IO) {
failure(1000)
}
joinAll(successJob, failedJob)
}
Instead, we can wrap all those coroutines inside a supervisorScope. This function creates a scope that inherits the context from the parent coroutine, but overrides the Job
of that context with SupervisorJob
.
runBlocking {
supervisorScope {
val successJob = launch(Dispatchers.IO) {
success(2000)
}
val failedJob = launch(Dispatchers.IO) {
failure(1000)
}
joinAll(successJob, failedJob)
}
}
See the SupervisorJob in action
Below is the output of the two versions above.
Exception in thread "DefaultDispatcher-worker-3 @coroutine#3" java.lang.RuntimeException: Exception while trying to handle coroutine exception
at kotlinx.coroutines.CoroutineExceptionHandlerKt.handlerException(CoroutineExceptionHandler.kt:38)
at kotlinx.coroutines.CoroutineExceptionHandlerImplKt.handleCoroutineExceptionImpl(CoroutineExceptionHandlerImpl.kt:52)
at kotlinx.coroutines.CoroutineExceptionHandlerKt.handleCoroutineException(CoroutineExceptionHandler.kt:33)
<omitted>
Success!
As we can see, the success
function ran to completion. However, we still got an unhandled exception from the failure
function. That exception will be propagated up until it reaches and kills the main thread. Let’s see how we can handle that in the next section.
Use CoroutineExceptionHandler to handle error in coroutines
At the beginning of this article, we saw that wrapping the launch
functions in a try-catch
did not have the result we expected. Instead, we need to define and use a CoroutineExceptionHandler.
val handler = CoroutineExceptionHandler { _, ex ->
// Code to handle the exception goes here
println("From handler: An error occurred: ${ex.message}")
}
How to launch a coroutine with CoroutineExceptionHandler context
This handler also implements the CoroutineContext
interface and can be used as a context.
runBlocking {
val successJob = launch(Dispatchers.IO + SupervisorJob() + handler) {
success(2000)
}
val failedJob = launch(Dispatchers.IO + SupervisorJob() + handler) {
failure(1000)
}
joinAll(successJob, failedJob)
}
Or in case we want to use supervisorScope
.
runBlocking {
supervisorScope {
val successJob = launch(Dispatchers.IO + handler) {
success(2000)
}
val failedJob = launch(Dispatchers.IO + handler) {
failure(1000)
}
joinAll(successJob, failedJob)
}
}
We can also add handler
to the parent coroutine’s context.
runBlocking(handler) {
supervisorScope {
val successJob = launch(Dispatchers.IO) {
success(2000)
}
val failedJob = launch(Dispatchers.IO) {
failure(1000)
}
joinAll(successJob, failedJob)
}
}
See the CoroutineExceptionHandler in action
We get the following output when running the version above.
From handler: An error occurred: This is a dummy error!
Success!
Here is what happened in our code.
- The
success
andfailure
functions were launched at the same time, and ran in separate coroutines, on different threads. - After 1000ms,
failure
threw an exception, which canceled its coroutine. However, because it is aSupervisorJob
, this exception did not cancel its parent and sibling (success
‘s coroutine in this case). - The exception thrown by
failure
was handled by theCoroutineExceptionHandler
, which printed a message to console. - After 2000ms,
success
ran to completion, and printed another message to console.
What if we use CoroutineExceptionHandler with Job instead of SupervisorJob?
Some people might wonder why bother using SupervisorJob
if we can just use CoroutineExceptionHandler
to handle the exception thrown from inside the child coroutine. Let’s do a little experiment and use the handler with a normal Job
.
runBlocking {
val successJob = launch(Dispatchers.IO + handler) {
success(2000)
}
val failedJob = launch(Dispatchers.IO + handler) {
failure(1000)
}
joinAll(successJob, failedJob)
}
Below is the result of the snippet above.
Exception in thread "main" java.lang.Exception: This is a dummy error!
The handler has no effect in this case. According to the document, below are the reasons.
- The
CoroutineExceptionHandler
can only handler uncaught exceptions. - Uncaught exceptions can only happen at the root coroutine.
- All exceptions in child coroutines will be propagated to its parent, until they reach the root.
- The only exception is
SupervisorJob
coroutines. They are treated as root coroutines and can have uncaught exceptions.
Because of the points above, when we remove the supervisorScope
(or the SupervisorJob
context), the exception inside failure
function will be propagated outside of the child coroutine, and cannot be handled by the CoroutineExceptionHandler
.
Conclusion
In many ways, a coroutine created by the launch
builder is similar to a non generic Task
in C#. But with a Task
, I can wrap it inside a try-catch
block as long as I remember to await it. Because of that, it took me some time to get used to the Kotlin way. But reading about exception handling helps deepen my understanding of coroutine and concurrency in Kotlin.
One Thought on “The Kotlin launch builder and exception handling”