Coroutines Exceptions Handling: A Guide to SupervisorScope, SupervisorJob, and CoroutineExceptionHandler.
If you are a seasoned Android developer with Kotlin then you have worked and will continue to use Coroutines for asynchronous and multithreading operations since they are lightweight, easy to use, and, most importantly, constantly under maintenance to make sure they're performing to their optimum. But are we really handling its exceptions correctly, especially where we have coroutine hierarchies—in other words, root, parent, and child coroutines. Let's debunk this
Coroutine failure
Yes! Coroutines fail, and we may not have experienced an instance where our coroutines failed us but keep in mind that coroutines fail and this can lead to a crash in our applications which can be fatal to the user experience. You don't want your app uninstalled because you didn't catch an exception earlier.
Coroutine exception propagation
When a coroutine fails mild execution because of an exception, it is immediately canceled and this exception is propagated to the parent coroutine, which is also canceled together with all its children in the hierarchy, and finally to the root of the coroutine.
Enough words; illustrate in code
fun main(){
val rootJob = runBlocking {
val parentJob = launch {
println("Coroutine is active")
val childJob1 = launch {
println("Child job 1 : child job 1")
}
val childJob2 = launch {
println("Child job 2 : child job 2")
}
launch() {
println("rooting : parent job")
}
}
launch() {
println("run blocking main coroutine job ")
}
}
}
When an exception occurs in a coroutine the exception is propagated upwards in the hierarchy causing the cancellation of the parent
, the root
and finally its children
. Let's first understand the execution hierarchy of a coroutine and a coroutine lifecycle before we introduce an exception and monitor the propagation.
In the above code, we have the runBlocking
that helps us start coroutines in the main function, and inside it we have a job named parentJob
which is cancellable and inside it we have started child coroutines namely childJob1,
childJob2
and unnamed coroutine and because it has not been assigned a job it automatically falls under parentJob
as its handler then we have another unnamed coroutine started and launched within runBlocking
context and handled by the rootJob for cancellation purposes.
Below is the output in the order of execution
Coroutine is active
run blocking main coroutine job
Child job 1 : child job 1
Child job 2 : child job 2
rooting : parent job
runBlock
executes sequentially but before the parentJob
could execute/launch its children runBlocking
launches its unnamed coroutine. and to better understand this lets delay the runBlocking
coroutine job for two seconds and observe the output.
launch() {
delay(2000)
println("run blocking main coroutine job ")
}
This is the output.
Coroutine is active
Child job 1 : child job 1
Child job 2 : child job 2
rooting : parent job
run blocking main coroutine job
So what do we understand from this? In the first occurrence, the unnamed block of runBlocking
is launched first together with the parentJob
and suspended till ready and the parentJob is ready first but before it could launch its children the unnamed job is ready and the output is printed, the parentJob
children are now ready behind runBlocking
task but in the second occurrence the runBlocking
unnamed job takes time to execute and so its suspended till its ready and in the meantime the parentJob
is ready with its children and so they are printed.
Having understood the execution hierarchy, let's introduce an exception in both of these instances.
fun main() {
val rootJob = runBlocking {
val parentJob = launch {
println("Coroutine is active")
val childJob1 = launch {
throw Exception("From child 1")
}
val childJob2 = launch {
println("Child job 2 : child job 2")
}
launch() {
println("rooting : parent job")
}
}
launch() {
delay(2000)
println("run blocking main coroutine job ")
}
}
}
If we run the code above, we get the below output:
Coroutine is active
Exception in thread "main" java.lang.Exception: From child 1
The exception in childJob1
is propagated to the parent canceling the parent and hence canceling all its children and so childJob2
is never launched and the same is propagated to the root job which is the run blocking and so the unnamed job is cancelled in the inCompleting stage and so it never completes its task that's why "run blocking coroutine job" is never printed but if we didnt delay for 5 seconds it would get to finish its task before the run blocking is canceled with the below output.
Coroutine is active
run blocking main coroutine job
Exception in thread "main" java.lang.Exception: From child 1
The exception is propagated to the parent which in turn cancels all its children and this is a phenomenon that could lead to our application crushing and so how do we mitigate this?
Introducing Supervisor job and Supervisor Scope
SupervisorJob
A SupervisorJob
behaves differently from a normal job in a way that it stops the propagation of the exceptions to the other children and so the cancellation of a child does not affect other children and It won't cancel itself.
Let's use a SupervisorJob
in our example above.
import kotlinx.coroutines.*
fun main() {
val scope = CoroutineScope(SupervisorJob())
runBlocking {
launch {
println("Coroutine is active")
val childJob1 = scope.launch {
throw Exception("From child 1")
}
val childJob2 = scope.launch {
println("Child job 2")
}
launch() {
println("parent job scope")
}
}
launch() {
println("run blocking scope")
}
}
}
This is the output to the code above
Coroutine is active
Child job 2
run blocking scope
parent job scope
The childJob2
has not been cancelled by the exception from childJob1
because of the help of a SupervisorJob
making every child independent of the other stopping propagation of exceptions but there are somethings you should keep in mind when using SupervsorJob
for it to be effective.
SupervisorJob
only propergates exceptions to its direct children.Use
SupervisorJob
where we have children coroutines and want to isolate exceptions independently.Dont use
SupervisorJob
as an argument to a parent since this will only treat it as a normal job and so it won't stop the propagation.
Supervisor Scope
SupervisorScope
can also be used to avoid the behaviour of exception propagation to other children when one child gets canceled, and this is done by supervising the children under a Supervisor Scope
by wrapping coroutine builders inside a Supervisor Scope
.
Lets use a Supervisor Scope
in our example
import kotlinx.coroutines.*
fun main() {
runBlocking {
supervisorScope {
println("Coroutine is active")
val childJob1 = launch {
throw Exception("From child 1")
}
val childJob2 = launch {
println("Child job 2")
}
launch() {
println("parent job scope")
}
}
launch() {
println("run blocking scope")
}
}
}
This is the output
Coroutine is active
Exception in thread "main" java.lang.RuntimeException: Suppressed: java.lang.Exception: From child 1
Child job 2
parent job scope
run blocking scope
When to Use SupervisorJob and When to Use SupervisorScope
Use SupervisorJob
when you don't want an exception from a child to cancel the parent or the other children, because if a parent fails, then all its children are cancelled.
Supervisor Scope
is only used as a parent to run children coroutines, and so you can use it as a builder to wrap children coroutines to avoid cancellation of children coroutines if one child is cancelled due to an exception thrown.
Coroutine Handler
A Coroutine handler
is a very important component as it gives us more information about a coroutine exception like when the exception occurred, where the exception occurred, and its origin and we can define what should happen when an exception occurs, but keep in mind that it only works with launch and not async and it does not stop the propagation of exceptions and so it is encouraged t use it with Supervisor Job
.
Lets implement it in our example
import kotlinx.coroutines.*
fun main() {
val handler = CoroutineExceptionHandler { ctx, exception ->
println("Caught $exception")
}
val scope = CoroutineScope(SupervisorJob() + handler)
runBlocking {
launch {
println("Coroutine is active")
val childJob1 = scope.launch {
throw Exception("From child 1")
}
val childJob2 = scope.launch {
println("Child job 2")
}
launch() {
println("parent job scope")
}
}
launch() {
println("run blocking scope")
}
}
}
This is the output
Coroutine is active
Caught java.lang.Exception: From child 1
Child job 2
run blocking scope
parent job scope
The coroutine exception handler can be useful, especially when debugging. It helps in logging and monitoring exceptions, giving us an easy time tracking down and diagnosing exceptions.
I hope you have learned something about handling exception propagation in your coroutines, and now you are in a position to deal with them gracefully to avoid unnecessary crushing of your applications.
Happy coding.