9.
Manage Cancellation
Written by Nishant Srivastava
When you initiate multiple asynchronous operations that are dependent on each other, the possibilities of one failing, then leading to others also failing, increases. This means that the result is not going to end as you expected. Coroutines address this problem and provide mechanisms to handle this and many other cases.
This chapter will dive deeper into the concepts and mechanics of cancellation in coroutines.
Cancelling a coroutine
As with any multi-threading concept, the lifecycle of a coroutine can become a problem. You need to stop any potentially long-running background tasks when it is in an inconsistent state in order to prevent memory leaks or crashes. To resolve this, coroutines provide a simple cancelling mechanism.
Job object
As you’ve seen in Chapter 3: “Getting Started with Coroutines,” when you launch a new routine using the launch coroutine builder, you get a Job object as the return value. This Job object represents the running coroutine, which you can cancel at any point by calling the cancel function.
This is interesting because with Kotlin coroutines, you have the ability to specify a parent job as a context for multiple coroutines, and calling cancel on the parent coroutine will result in all coroutines being canceled.
Note: When you cancel the parent coroutine, all of its children are recursively cancelled, too.
The launch coroutine builder is used as a fire-and-forget way of starting a coroutine. It is similar to starting a new thread. If the code inside the coroutine that was started from launch terminates with an exception, the system treats it like an uncaught exception in a thread — usually printed to stderr in backend JVM applications — and the Android applications crash. You use join to wait for the completion of the launched coroutine but it does not propagate its exception. However, a crashed child coroutine cancels its parent with the corresponding exception, too.
Cancel
In a long-running application, you might need fine-grained control on your background coroutines. For example, a task that launched a coroutine might have finished, and now its result is no longer needed; consequently, its operation can be canceled. This is where the cancel method comes in.
In order to cancel a coroutine, you simply need to call the cancel method on the Job object that was returned from the coroutine builder. Calling the cancel function on a Job, or on a Deferred instance, will stop the inner computation on a coroutine if the handling of the isActive flag is properly implemented.
Coroutine cancelation is cooperative. This means that the suspending function has to cooperate in order to support cancelling. In practice, the suspending function has to periodically test the isActive property, which is set to false when the coroutine is canceled. This applies to your suspending functions, too. All suspending functions provided by the Kotlin coroutine library support cancelation already.
Note: isActive is checked between child coroutine suspension points by the standard library, so you only have to check isActive in your own long-running computations.
In the code snippet below, the launch function returns a Job that can be used to cancel the running coroutine:
fun main() = runBlocking {
val job = launch {
repeat(1000) { i ->
println("$i. Crunching numbers [Beep.Boop.Beep]...")
delay(500L)
}
}
delay(1300L) // delay a bit
println("main: I am tired of waiting!")
job.cancel() // cancels the job
job.join() // waits for job’s completion
println("main: Now I can quit.")
}
Note: You can find the executable version of the above snippet of code in the starter project in the file called CancelCoroutine.kt.
Output:
0. Crunching numbers [Beep.Boop.Beep]...
1. Crunching numbers [Beep.Boop.Beep]...
2. Crunching numbers [Beep.Boop.Beep]...
main: I am tired of waiting!
main: Now I can quit.
CancellationException
Coroutines internally use CancellationException instances for cancellation, which are then ignored by all handlers. They are typically thrown by cancellable suspending functions if the Job of the coroutine is canceled while it is suspending. It indicates normal cancellation of a coroutine.
Note: CancellationException is not printed to the console/log by the default uncaught exception handler.
When you cancel a coroutine using the cancel function on its Job object without a cause, it terminates but it does not cancel its parent. Cancelling without cause is a mechanism for a parent to cancel its children without canceling itself.
The following piece of code shows an example of CancellationException handling when child jobs are canceled, which is pretty straightforward:
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, exception ->
println("Caught original $exception")
}
val parentJob = GlobalScope.launch(handler) {
val childJob = launch {
// Sub-child job
launch {
// Sub-child job
launch {
throw IOException()
}
}
}
try {
childJob.join()
} catch (e: CancellationException) {
println("Rethrowing CancellationException" +
" with original cause")
throw e
}
}
parentJob.join()
}
Note: You can find the executable version of the above snippet of code in the starter project in the file called CancellationExceptionExample.kt.
Output:
Rethrowing CancellationException with original cause
Caught original java.io.IOException
Join, CancelAndJoin and CancelChildren
The Kotlin standard library provides a couple of convenience functions for handling coroutine completion and cancellation.
-
When using coroutines, you will most likely be interested in the result of a completed job. To know about the completion of the coroutine, the join function is available, which suspends the coroutine execution until the canceled job is complete:
fun main() = runBlocking { val job = launch { println("Crunching numbers [Beep.Boop.Beep]...") delay(500L) } // waits for job’s completion job.join() println("main: Now I can quit.") }Note: You can find the executable version of the above snippet of code in the starter project in the file called JoinCoroutineExample.kt.
Output:
Crunching numbers [Beep.Boop.Beep]... main: Now I can quit. -
If you would like to wait for the completion of more than one coroutine, then you should use the joinAll function:
fun main() = runBlocking { val jobOne = launch { println("Job 1: Crunching numbers [Beep.Boop.Beep]...") delay(500L) } val jobTwo = launch { println("Job 2: Crunching numbers [Beep.Boop.Beep]...") delay(500L) } // waits for both the jobs to complete joinAll(jobOne, jobTwo) println("main: Now I can quit.") }Note: You can find the executable version of the above snippet of code in the starter project in the file called JoinAllCoroutineExample.kt.
Output:
Job 1: Crunching numbers [Beep.Boop.Beep]... Job 2: Crunching numbers [Beep.Boop.Beep]... main: Now I can quit. -
If you would like to cancel and then wait for the completion of a coroutine, then a cancelAndJoin function that combines the two is also provided:
fun main() = runBlocking { val job = launch { repeat(1000) { i -> println("$i. Crunching numbers [Beep.Boop.Beep]...") delay(500L) } } delay(1300L) // delay a bit println("main: I am tired of waiting!") // cancels the job and waits for job’s completion job.cancelAndJoin() println("main: Now I can quit.") }Note: You can find the executable version of the above snippet of code in the starter project in the file called CancelAndJoinCoroutineExample.kt
Output:
0. Crunching numbers [Beep.Boop.Beep]... 1. Crunching numbers [Beep.Boop.Beep]... 2. Crunching numbers [Beep.Boop.Beep]... main: I am tired of waiting! main: Now I can quit. -
If your coroutine has multiple child coroutines and you would like to cancel all of them, then you should use the cancelChildren method:
fun main() = runBlocking { val parentJob = launch { val childOne = launch { repeat(1000) { i -> println("Child Coroutine 1: " + "$i. Crunching numbers [Beep.Boop.Beep]...") delay(500L) } } // Handle the exception thrown from `launch` // coroutine builder childOne.invokeOnCompletion { exception -> println("Child One: ${exception?.message}") } val childTwo = launch { repeat(1000) { i -> println("Child Coroutine 2: " + "$i. Crunching numbers [Beep.Boop.Beep]...") delay(500L) } } // Handle the exception thrown from `launch` // coroutine builder childTwo.invokeOnCompletion { exception -> println("Child Two: ${exception?.message}") } } delay(1200L) println("Calling cancelChildren() on the parentJob") parentJob.cancelChildren() println("parentJob isActive: ${parentJob.isActive}") }Note: You can find the executable version of the above snippet of code in the starter project in the file called CancelChildren.kt.
Output:
Child Coroutine 1: 0. Crunching numbers [Beep.Boop.Beep]... Child Coroutine 2: 0. Crunching numbers [Beep.Boop.Beep]... Child Coroutine 1: 1. Crunching numbers [Beep.Boop.Beep]... Child Coroutine 2: 1. Crunching numbers [Beep.Boop.Beep]... Child Coroutine 1: 2. Crunching numbers [Beep.Boop.Beep]... Child Coroutine 2: 2. Crunching numbers [Beep.Boop.Beep]... Calling cancelChildren() on the parentJob parentJob isActive: true Child One: Job was canceled Child Two: Job was canceled
This is all nice, but how do you cancel a coroutine after a set time? The next section covers that specific scenario.
Timeout
Long-running coroutines are sometimes required to terminate after a set time has passed. While you can manually track the reference to the corresponding Job and launch a separate coroutine to cancel the tracked one after a delay, the coroutines library provides a convenience function called withTimeout.
Take a look at the following example:
fun main() = runBlocking {
withTimeout(1500L) {
repeat(1000) { i ->
println("$i. Crunching numbers [Beep.Boop.Beep]...")
delay(500L)
}
}
}
Note: You can find the executable version of the above snippet of code in the starter project in the file called WithTimeoutExample.kt.
Output:
0. Crunching numbers [Beep.Boop.Beep]...
1. Crunching numbers [Beep.Boop.Beep]...
2. Crunching numbers [Beep.Boop.Beep]...
Exception in thread "main" kotlinx.coroutines.TimeoutCancellationException: Timed out waiting for 1500 MILLISECONDS
...
The TimeoutCancellationException that withTimeout throws is a subclass of CancellationException. You haven’t seen its stack trace printed on the console before. That is because, inside a canceled coroutine, CancellationException is considered to be a normal reason for coroutine completion. However, in this example, you have used withTimeout right inside the main function.
Because cancellation is just an exception, you close all the resources in the usual way. You can wrap the code with a timeout in a try {…} catch (e: TimeoutCancellationException) {…} block if you need to do some additional action, specifically on any kind of timeout or use the withTimeoutOrNull function:
fun main() = runBlocking {
try {
withTimeout(1500L) {
repeat(1000) { i ->
println("$i. Crunching numbers [Beep.Boop.Beep]...")
delay(500L)
}
}
} catch (e: TimeoutCancellationException) {
println("Caught ${e.javaClass.simpleName}")
}
}
Note: You can find the executable version of the above snippet of code in the starter project in the file called TimeoutCancellationExceptionHandling.kt.
Output:
0. Crunching numbers [Beep.Boop.Beep]...
1. Crunching numbers [Beep.Boop.Beep]...
2. Crunching numbers [Beep.Boop.Beep]...
Caught TimeoutCancellationException
If you want to set a timeout for a coroutine Job, wrap the suspended code with the withTimeoutOrNull function, which will return null in case of timeout:
fun main() = runBlocking {
val result = withTimeoutOrNull(1300L) {
repeat(1000) { i ->
println("$i. Crunching numbers [Beep.Boop.Beep]...")
delay(500L)
}
"Done" // will get canceled before it produces this result
}
// Result will be `null`
println("Result is $result")
}
Note: You can find the executable version of the above snippet of code in the starter project in the file called WithTimeoutOrNullExample.kt.
Output:
0. Crunching numbers [Beep.Boop.Beep]...
1. Crunching numbers [Beep.Boop.Beep]...
2. Crunching numbers [Beep.Boop.Beep]...
Result is null
Key points
- When the parent coroutine is canceled, all of its children are recursively canceled, too.
- CancellationException is not printed to the console/log by the default uncaught exception handler.
- Using the withTimeout function, you can terminate a long-running coroutine after a set time has elapsed.
Where to go from here?
Being able to cancel an ongoing task is almost always required. The cycle of starting a coroutine and canceling it when an exception is thrown or when the business logic demands it is part of some of the common patterns in programming. Coroutines in Kotlin were built keeping that in mind since the very beginning.
Next up, you will explore how to efficiently process collections involving more than one processing step using coroutines.