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

The Kotlin launch builder and exception handling

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.

Coroutine cancellation is a two way street

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 and failure 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 a SupervisorJob, this exception did not cancel its parent and sibling (success‘s coroutine in this case).
  • The exception thrown by failure was handled by the CoroutineExceptionHandler, 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.

A software developer from Vietnam and is currently living in Japan.

One Thought on “The Kotlin launch builder and exception handling”

Leave a Reply