6

Exception handling in Kotlin Coroutines

 2 years ago
source link: https://kt.academy/article/cc-exception-handling
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

205_exception_handling.jpg

Exception handling in Kotlin Coroutines

This is a chapter from the book Kotlin Coroutines. You can find Early Access on LeanPub.

A very important part of how coroutines behave is their exception handling. Just like a program breaks when an uncaught exception slips by, a coroutine breaks in case of an uncaught exception. This behavior is nothing new, for instance, threads end in such cases as well. The difference is that coroutine builders additionally cancel their parents as well, and cancelled parent cancel all their children. Let's look at the example below: once a coroutine has an exception, so it cancels itself and propagates the exception to its parent (launch). The parent cancels itself, all its children, and propagates the exception to its parent (runBlocking). runBlocking is a root coroutine (has no parent), so it just ends the program (runBlocking rethrow the exception). Adding additional launch coroutines wouldn't change anything. Exception propagation is bi-directional: the exception is propagated from child to parent, and when those parents are cancelled, they cancel their children. This way, if exception propagation is not stopped, all coroutines in the hierarchy will be cancelled.

xxxxxxxxxx
fun main(): Unit = runBlocking {
    launch {
        launch {
            delay(1000)
            throw Error("Some error")
        }
        launch {
            delay(2000)
            println("Will not be printed")
        }
        launch {
            delay(500) // faster than the exception
            println("Will be printed")
        }
    }
    launch {
        delay(2000)
        println("Will not be printed")
    }
}
// Will be printed
// Exception in thread "main" java.lang.Error: Some error...
Target platform: JVMRunning on kotlin v.1.5.31

calcellation.png

Stop breaking my coroutines

Catching the exception before it breaks a coroutine is helpful, but after that, it is too late. Communication happens via a job, so wrapping a coroutine builder with try-catch is not helpful at all.

xxxxxxxxxx
fun main(): Unit = runBlocking {
    // Don't wrap in a try-catch here. It will be ignored.
    try {
        launch {
            delay(1000)
            throw Error("Some error")
        }
    } catch (e: Throwable) { // nope, does not help here
        println("Will not be printed")
    }
    launch {
        delay(2000)
        println("Will not be printed")
    }
}
// Exception in thread "main" java.lang.Error: Some error
// ...
Target platform: JVMRunning on kotlin v.1.5.31

SupervisorJob

The most important way to stop coroutines from braking is by using a SupervisorJob. This is a special kind of job that ignores all the exceptions in its children. It is generally used as a part of a scope we start multiple coroutines on (more about it in the chapter Constructing coroutine scope).

xxxxxxxxxx
fun main(): Unit = runBlocking {
    val scope = CoroutineScope(SupervisorJob())
    scope.launch {
        delay(1000)
        throw Error("Some error")
    }
    scope.launch {
        delay(2000)
        println("Will be printed")
    }
    delay(3000)
}
// Exception...
// Will be printed
Target platform: JVMRunning on kotlin v.1.5.31

A common mistake is to use it as an argument to parent coroutine, like in the code below. It won't help us handle exceptions, because in such a case SupervisorJob has only one direct child - the launch that received it as an argument. So in such a case, there is no advantage of using SupervisorJob over Job (the exception in both cases will not propagate to runBlocking, because we are not using its job).

xxxxxxxxxx
fun main(): Unit = runBlocking {
    // Don't do that, SupervisorJob with one children 
    // and no parent works similar to just Job
    launch(SupervisorJob()) {
        launch {
            delay(1000)
            throw Error("Some error")
        }
        launch {
            delay(2000)
            println("Will not be printed")
        }
    }
    delay(3000)
}
// Exception...
Target platform: JVMRunning on kotlin v.1.5.31

It would make more sense if we would use this job as a context for multiple coroutine builders. Then each of them can be cancelled, but they will not cancel each other.

xxxxxxxxxx
fun main(): Unit = runBlocking {
    val job = SupervisorJob()
    launch(job) {
        delay(1000)
        throw Error("Some error")
    }
    launch(job) {
        delay(2000)
        println("Will be printed")
    }
    job.join()
}
// (1 sec)
// Exception...
// (1 sec)
// Will be printed
Target platform: JVMRunning on kotlin v.1.5.31

supervisorScope

Another way to stop exception propagation is to wrap coroutine builders with a supervisorScope. This is very convenient, as we still keep a connection to the parent, and yet an exception from the coroutine will be silenced.

xxxxxxxxxx
fun main(): Unit = runBlocking {
    supervisorScope {
        launch {
            delay(1000)
            throw Error("Some error")
        }
        launch {
            delay(2000)
            println("Will be printed")
        }
    }
    delay(1000)
    println("Done")
}
// Exception...
// Will be printed
// (1 sec)
// Done
Target platform: JVMRunning on kotlin v.1.5.31

supervisorScope is just a suspending function, and can be used to wrap suspending function bodies. This and other functionalities of supervisorScope will be described better in the next chapter. The common way to use it is to start multiple independent tasks.

xxxxxxxxxx
fun notifyAnalytics(actions: List<UserAction>) =
    supervisorScope {
        actions.forEach { action ->
            launch {
                notifyAnalytics(action)
            }
        }
    }

Another way to stop exception propagation is to use coroutineScope. This function, instead of influencing a parent, throws an exception that can be caught using try-catch (in opposition to coroutine builders). It will also be described in the next chapter.

Await

So we know how to stop exception propagation, but sometimes it is not enough. The coroutine builder async, in case of an exception, breaks its parent just like launch and other coroutine builders that are in relation to their parents. Although what if this process is silenced (for instance using SupervisorJob or supervisorScope) and await is called? Let's look at the following example:

xxxxxxxxxx
class MyException : Throwable()
suspend fun main() = supervisorScope {
    val str1 = async<String> {
        delay(1000)
        throw MyException()
    }
    val str2 = async {
        delay(2000)
        "Text2"
    }
    try {
        println(str1.await())
    } catch (e: MyException) {
        println(e)
    }
    println(str2.await())
}
// MyException
// Text2
Target platform: JVMRunning on kotlin v.1.5.31

We have no value to return since the coroutine ended with an exception, so instead the MyException exception is thrown out of await. That is why MyException is printed. The other async finishes uninterrupted, thanks to the fact we use the supervisorScope.

CancellationException is not propagating to parent

If an exception is a subclass of CancellationException, it will not be propagated to the parent. It will only cause current coroutine cancellation. CancellationException is an open class, so it can be extended by our own classes or objects.

xxxxxxxxxx
import kotlinx.coroutines.*
object MyNonPropagatingException: CancellationException()
suspend fun main(): Unit = coroutineScope {
    launch {
        launch {
            delay(2000)
            println("Will not be printed")
        }
        throw MyNonPropagatingException
    }
    launch {
        delay(2000)
        println("Will be printed")
    }
}
// (2 sec)
// Will be printed
Target platform: JVMRunning on kotlin v.1.5.31
  1. We start two coroutines with builders at line 4 and 11.
  2. At line 9 we throw an exception MyNonPropagatingException that is a subtype of CancellationException.
  3. This exception is caught by launch started at line 4. This builder cancels, so it also cancels its children, so the builder defined at line 5.
  4. Second launch is not affected, so it prints "Will be printed" after 2 seconds.

Coroutine exception handler

When dealing with exceptions, sometimes it is useful to define default behavior for all of them. This is where the context CoroutineExceptionHandler comes in handy. It does not stop the exception from propagation, but can be used to set what should happen in case of an exception (by default it is printing the exception stack trace).

xxxxxxxxxx
fun main(): Unit = runBlocking {
    val handler = CoroutineExceptionHandler { 
            ctx, exception ->
        println("Caught $exception")
    }
    val scope = CoroutineScope(SupervisorJob() + handler)
    scope.launch {
        delay(1000)
        throw Error("Some error")
    }
    scope.launch {
        delay(2000)
        println("Will be printed")
    }
    delay(3000)
}
// Caught java.lang.Error: Some error
// Will be printed
Target platform: JVMRunning on kotlin v.1.5.31

This context is useful on many platforms to add a default way to deal with exceptions. For Android, it is often informing the user about the problem by showing a dialog or an error message. On the backend, it might be responding with an exception (it is most often handled by the framework).

Summary

Exception handling is an important part of kotlinx.coroutines library. Over time, we will inevitably be getting back to those topics. For now, I hope that you understand how exceptions propagate from child to parent in basic builders, and how they can be stopped. Now it is time for a topic that is strongly connected, and also long-awaited. Time to talk about scoping functions.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK