With a well-designed support for structured concurrency, cancelling should be easy, and calling job.cancel()
will do the trick, right?
Cancellation is cooperative
Let’s start with the following piece of code:
private suspend fun doSomething() { ... }
private var fooJob: Job? = null
fun startCoroutine(coroutineScope: CoroutineScope) {
fooJob?.cancel()
fooJob = coroutineScope.launch {
try {
doSomething()
} finally {
fooJob = null
}
}
}
It tries to cancel any ongoing coroutine before launching a new one, looks innocent.
However, if startCoroutine()
is called when there is already a coroutine running, it will wrongly nullify the fooJob
of the new job.
This is because cancellation is cooperative. When fooJob.cancel()
is called, the existing coroutine is not immediately terminated, but throws a CancellationException
. As a result, the finally
block of the previous job will be called after launch()
is called and the fooJob
is re-assigned with the new value.
To avoid such bugs, we can wait until the coroutine is terminated:
suspend fun startCoroutine(coroutineScope: CoroutineScope) {
fooJob?.cancelAndJoin() // This is a suspend function.
fooJob = coroutineScope.launch {
...
}
}
Or check if the coroutine context is still alive before nullifying:
fun startCoroutine(coroutineScope: CoroutineScope) {
fooJob?.cancel()
fooJob = coroutineScope.launch {
try {
doSomething()
} finally {
if (isActive) { // isActive will be false, if the coroutine is cancelled.
fooJob = null
}
}
}
}
CancellationException
As mentioned earlier, when a coroutine is cancelled, it throws a CancellationException
to communicate with all coroutines inside the scope. As a result, the following try-catch
block will prevent the coroutine from being properly cancelled:
fun startCoroutine(coroutineScope: CoroutineScope) {
coroutineScope.launch {
try {
doSomething()
} catch (e: Exception) {
// The CancellationException is caught here, so the
// execution will continue.
...
}
}
}
To fix this, we should re-throw the CancellationException
:
fun startCoroutine(coroutineScope: CoroutineScope) {
coroutineScope.launch {
try {
doSomething()
} catch (c: CancellationException) {
throw c
} catch (e: Exception) {
...
}
}
}
Of course, repeating this everywhere is tedious. So, let’s create a runSuspendCatching
function to wrap it:
inline suspend fun <R> runSuspendCatching(block: () -> R): Result<R> =
try {
Result.success(block())
} catch(c: CancellationException) {
throw c
} catch (e: Throwable) {
Result.failure(e)
}
What about Flows?
Fortunately, we don’t need to worry too much about Flows, because the catch
operator already specifically handles this situation.
Conclusion
When cancelling coroutines and handling exceptions, these two cases can be easily missed, causing unexpected bugs. Unfortunately, there is no easy way to fix this, and we have to be careful dealing with them. Maybe new LINT rules could help with this. How do you think?