Photo by Mike Lewis HeadSmart Media on Unsplash
This is the third in a series of blog posts about applying Structural concurrency.
Rules of Structured concurrency in Kotlin
Applying Structured concurrency in Kotlin: Part II — Creation
- (This post) — Applying Kotlin Structured Concurrency: Part III — Exceptions in coroutines
Applying Kotlin Structured concurrency: Part IV — Coroutines Cancellation
In Java and Kotlin you can use try
/catch
for catch exceptions.
fun main() { try { throw (Exception()) } catch (e: Exception) { println(e.javaClass.canonicalName) } }
If you don’t handle an exception in a method where an exception was thrown then you need to handle it in the method that called this method and so on.
fun main() { try { funException() } catch (e: Exception){ println(e.javaClass.canonicalName) } } fun funException(){ throw (Exception()) }
If there is no such handler in the callstack then user will get an app crash on Android.
Handling exceptions in coroutines work differently. In coroutines we have one more additional way for bubbling exception up — not only re-throw via callstack but also propagation via Job
hierarchy. You can read more about Job
hierarchy and Job
parent-child relations in previous article:
Applying Structured concurrency in Kotlin: Part II — Creation
Structured Concurrency for exception propagation uses the following machinery:
0. Exception is thrown in a child coroutine
- Parent cancels other child coroutines. That’s siblings cancellation
- Parent coroutine is cancelled too. That’s parent cancellation
- Exception is propagated up
Job
hierarchy
Different coroutine builders and suspend functions have differences in their behaviour. Let’s discuss them.
Top-level launch or launch in supervisorScope
Exception thrown here and not caught by try
/catch
inside launch
OR propagated from child leads to:
- cancelling other siblings
- cancelling itself
- propagating up the
Job
hierarchy
These exceptions can’t be caught outside launch
using try
/catch
because they will not be re-thrown via callstack but will be propagated via Job hierarchy.
import kotlinx.coroutines.* fun main() { val scope = CoroutineScope(Job()) try { scope.launch { throw (Exception()) } } catch (e: Exception) { println(e.javaClass.canonicalName) } Thread.sleep(1000) }
Exception can be handled on top of Job
hierarchy by installed CoroutineExceptionHandler
or by inherited CoroutineExceptionHandler.
import kotlinx.coroutines.* fun main() { val ceh = CoroutineExceptionHandler { context, exception -> println("CEH: $exception") } val scope = CoroutineScope(Job() + ceh) scope.launch { throw (Exception()) } Thread.sleep(1000) }
If CoroutineExceptionHandler
is missing — the exception is passed to the thread’s uncaught exception handler. On Android, it will lead to application crash.
Top-level async or async in supervisorScope
Exception thrown here and not caught by try
/catch
inside async
OR propagated from a child will be encapsulated in Deferred
object returned by the builder.
It is thrown as a normal exception only when invoking the await()
method. Therefore — await()
surrounded by try
/catch
should be used to avoid crash.
import kotlinx.coroutines.* fun main() { val scope = CoroutineScope(Job()) var deferred: Deferred<Unit>? = null scope.launch { supervisorScope { deferred = async { throw (Exception()) } } supervisorScope { try { deferred?.await() } catch (e: Exception) { println(e.javaClass.canonicalName) } } } Thread.sleep(1000) }
Without await()
you can’t catch an exception in async
surrounding by try
/catch
— so exception will be silently dropped.
import kotlinx.coroutines.* fun main() { val scope = CoroutineScope(Job()) scope.async { throw (Exception()) } Thread.sleep(1000) }
CoroutineExceptionHandler
has no effect here because there is no propagation via Job
hierarchy.
Nested launch
Uncaught exception in launch
OR exception propagated from child leads to:
- cancelling other children
- cancelling itself
- propagating up the
Job
hierarchy
These exceptions are not re-thrown but propagate up the Job
hierarchy. If nested launch()
is wrapped with try
/catch
then exception can’t be caught.
Nested async
Uncaught exception propagated from child OR occurred in async
leads to:
- cancelling other children
- cancelling itself
- propagating up the
Job
hierarchy
Exception propagates up the Job hierarchy even without calling await()
on it.
If await()
is wrapped with try
/catch
then exception can be caught but it ANYWAY propagates via Job
hierarchy.
coroutineScope
Uncaught exception in scoping function coroutineScope
OR exception propagated from child leads to:
- cancelling other children
- cancelling itself
- it doesn’t propagate exception up the
Job
hierarchy
Exception will be re-thrown, it allows to handle exception of failed coroutineScope
surrounded by try
/catch
.
import kotlinx.coroutines.* fun main() { val scope = CoroutineScope(Job()) scope.launch { try { coroutineScope { throw (Exception()) } } catch (e: Exception) { println(e.javaClass.canonicalName) } } Thread.sleep(1000) }
Job Offers
supervisorScope (exception thrown in scope)
Uncaught exception in scoping function supervisorScope
(exception thrown in the block) leads to:
- cancelling other children
- cancelling itself
- it doesn’t propagate exceptions up the
Job
hierarchy.
Exception will be re-thrown which allows us to handle it by surrounding supervisorScope
with try
/catch.
import kotlinx.coroutines.* fun main() { val scope = CoroutineScope(Job()) scope.launch { try { supervisorScope { //child launch { delay(50) println("sibling") } throw (Exception()) } } catch (e: Exception) { println(e.javaClass.canonicalName) } } Thread.sleep(1000) }
supervisorScope (exception thrown in child coroutine)
If one of the child coroutines fails:
- the sibling coroutines are not cancelled
supervisorScope
is not cancelled- no exception propagation up the
Job
hierarchy - no re-throw exceptions
So surrounding supervisorScope
with try
/catch
doesn’t catch exception and there is no propagation via Job
hierarchy.
Coroutines that are started directly from the supervisorScope
are top-level coroutines — behaviour of top level launch
and async
works like follows:
launch
child coroutine requires installation of a CoroutineExceptionHandler
(or using inherited CoroutineExceptionHandler
from CoroutineExceptionHandler
of parent Job
). If there is no CoroutineExceptionHandler
— exception is passed to the thread’s uncaught exception handler (crash on Android).
async
/await
requires surrounding await
with try
/catch
. Without it — exception is passed to the thread’s uncaught exception handler (crash on Android).
Without await()
exception will be silently dropped.
supervisorScope
should be used when you have children those errors should propagate only up to a certain point and not all the way to the root.
withContext
Uncaught exception in scoping function withContext
OR exception propagated from child leads to:
- cancelling other children
- cancelling itself
- it doesn’t propagate exceptions up the
Job
hierarchy.
Exception will be re-thrown, it allows to handle exception of failed withContext
surrounded by try
/catch
.
CoroutineExceptionHandler
If you want to use cancellation functionality of Structured Concurrency then you shouldn’t use try
/catch
inside coroutines.
If an exception in a child coroutine should not stop other coroutines belonging to the same parent, or if you want to retry the operation, or if specific error-handling behaviour is required, try
/catch
blocks should be used inside coroutine.
Use the CoroutineExceptionHandler
for logic that should happen after the coroutine has already been completed.
CoroutineExceptionHandler
is a last-resort mechanism for global “catch all” behaviour. You cannot recover from the exception in theCoroutineExceptionHandler
(scope is canceled and coroutine can’t be restarted). The coroutine had already completed with the corresponding exception when the handler is called. Normally, the handler is used to log the exception, show some kind of error message, terminate, and/or restart the application.
See you in the last post:
This article was previously published on proandroiddev.com