9.
Manage Cancellation
Written by Luka Kordić
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 often means that the end result won’t be exactly what you expected. Coroutines address this problem and provide cancellation 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 coroutine 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 cancel().
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 cancelled.
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’s similar to starting a new thread. If the code inside the coroutine terminates with an exception, the system treats it like an uncaught exception in a thread — usually printed to stderr in backend JVM applications — and Android applications crash. You can 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.
Being Cooperative
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 cancel() comes in.
In order to cancel a coroutine, you simply need to call cancel() on the Job instance that was returned from the coroutine builder. Calling cancel() on a Job, or on a Deferred instance, will stop the inner computation in a coroutine if the code is cooperative with cancellation.
Coroutine code should be cooperative with cancellation. This means that the code you write in a suspending function should check if the coroutine is still active before doing any intensive work. In practice, the suspending function should periodically check the isActive property. It will be set to false when the coroutine gets cancelled. All suspending functions provided by the Kotlin coroutine library support cancellation 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.
If your code is not cooperative with cancellation, you can get some unexpected results. Open the CooperativeCancellation.kt file in the starter project for this chapter and take a look at the following example:
fun main() = runBlocking {
val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
var nextPrintTime = startTime
var i = 0
while (i < 10) {
if (System.currentTimeMillis() >= nextPrintTime) {
println("Doing heavy work: $i")
i++
nextPrintTime += 500L
}
}
}
delay(1000)
println("Cancelling coroutine")
job.cancel()
println("Main: now I can quit!")
}
Output of the example is this:
Doing heavy work: 0
Doing heavy work: 1
Doing heavy work: 2
Cancelling coroutine
Main: now I can quit!
Doing heavy work: 3
Doing heavy work: 4
As you can see from the output, even though the coroutine has been cancelled after one second, it still continued doing its work. To fix this, you must check for the state of the coroutine. You’re going to do that by checking the isActive property in while. Most of the example stays the same, just replace while(i < 5) with while (i < 5 && isActive). The output now becomes:
Doing heavy work: 0
Doing heavy work: 1
Doing heavy work: 2
Cancelling coroutine
Main: now I can quit!
You can see that the coroutine stopped executing after you cancelled the job because the isActive flag is set to false as soon as you cancel the coroutine.
You know that functions from the standard library already support cancellation. Thus, you can rewrite this example by utilizing those functions. Open the StdLibCancellation.kt file in the starter project for the chapter and put in the following code:
fun main() = runBlocking {
val job = launch(Dispatchers.Default) {
var i = 0
while (i < 1000) {
println("Doing heavy work ${i++}")
delay(500)
}
}
delay(1200)
println("Cancelling")
job.cancel()
println("Main: Now I can quit!")
}
Output:
Doing heavy work 0
Doing heavy work 1
Doing heavy work 2
Cancelling
Main: Now I can quit!
You can see that the coroutine stopped the execution when you called cancel(). This is because delay() checks for the active state internally. If the Job of the current coroutine is cancelled or completed while this function is waiting, it immediately resumes with CancellationException.
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 cancelled 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 cancelled. Open the CancellationExceptionExample.kt file in the starter project for the chapter. Insert the code below, then run it.
@OptIn(DelicateCoroutinesApi::class)
fun main() = runBlocking {
// 1
val handler = CoroutineExceptionHandler { _, exception ->
// 6
println("Caught original $exception")
}
// 2
val parentJob = GlobalScope.launch(handler) {
val childJob = launch {
// 4
throw IOException()
}
try {
childJob.join()
} catch (e: CancellationException) {
// 5
println("Rethrowing CancellationException with original cause: ${e.cause}")
throw e
}
}
// 3
parentJob.join()
}
Output:
Rethrowing CancellationException with original cause: java.io.IOException
Caught original java.io.IOException
Let’s review the execution flow of this example to gain a bit deeper understanding of what’s happening here:
- You create a
handlerwhich will handle and print the exception. - Then, you create a hierarchy of coroutines, keeping references to their jobs in
parentJobandchildJob. -
parentJob.join()is invoked and the coroutine suspends, waiting for the job to finish. - The innermost coroutine throws
IOException(). This cancels the coroutine execution and automatically propagates the exception upwards. - You catch an instance of
CancellationExceptionand print its cause. - Finally,
handlergets to handle the original exception.
It’s very important here that you pay attention to the cause. When the innermost coroutine threw an exception, it wrapped that exception into the CancellationException with IOException as its cause. Because of the cause, the entire hierarchy of coroutines is going to get cancelled.
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 job is complete. You’ve already seen
join()in action, but let’s review it one more time with a simple example. Open the JoinCoroutineExample.kt file. Replace the empty function with the code below.
fun main() = runBlocking {
val job = launch {
println("Crunching numbers [Beep.Boop.Beep]...")
delay(1000L)
}
// waits for job's completion
job.join()
println("main: Now I can quit.")
}
Output:
Crunching numbers [Beep.Boop.Beep]…
main: Now I can quit.
You can see that the program prints the statement and then waits a second for the job to finish, before completing.
Try to comment out job.join() and see what happens.
- If you would like to wait for the completion of more than one coroutine, the standard library provides the joinAll function. Let’s see an example. Open the JoinAllCoroutineExample.kt and insert the following:
fun main() = runBlocking {
val jobOne = launch {
println("Job 1: Crunching numbers [Beep.Boop.Beep]…")
delay(2000L)
}
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.")
}
Output:
Job 1: Crunching numbers [Beep.Boop.Beep]…
Job 2: Crunching numbers [Beep.Boop.Beep]…
main: Now I can quit.
Notice that the program waits for 2 seconds because both jobs need to finish before the entire program completes.
- If you would like to cancel and then wait for the completion of a coroutine, the standard library provides a handy cancelAndJoin function that combines the two. To try it, open the CancelAndJoinCoroutineExample.kt file and use the code below.
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.")
}
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.
A coroutine which invokes cancelAndJoin() is simply suspended until the cancelled job is completed.
- If your coroutine has multiple child coroutines and you would like to cancel all of them, then you would use the cancelChildren method. To test this, open the CancelChildren.kt file and put in the following:
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}")
}
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
You now know a couple of ways to cancel coroutines and to handle cancellation. But, how do you cancel a coroutine after a set time? The next section covers that specific scenario.
Timing Out
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. To see it in action, open the WithTimeoutExample.kt file in the starter project, and use the code below.
fun main() = runBlocking {
withTimeout(1500L) {
repeat(1000) { i ->
println("$i. Crunching numbers [Beep.Boop.Beep]...")
delay(500L)
}
}
}
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 function 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 standard try/catch block if you need to do some additional action. Open the TimeoutCancellationExceptionHandling.kt and add a try/catch block to the code from the previous example, like this:
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}")
}
}
Output:
0. Crunching numbers [Beep.Boop.Beep]...
1. Crunching numbers [Beep.Boop.Beep]...
2. Crunching numbers [Beep.Boop.Beep]...
Caught TimeoutCancellationException
Alternatively, the coroutines library provides a handy withTimeoutOrNull function. You can use it to store the result of the function’s computation, or null if it timed out. To test its behavior, open the WithTimeoutOrNullExample.kt file and put in the following piece of code:
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")
}
Output:
0. Crunching numbers [Beep.Boop.Beep]...
1. Crunching numbers [Beep.Boop.Beep]...
2. Crunching numbers [Beep.Boop.Beep]...
Result is null
Key Points
- You can use
cancel()onJobinstances to cancel a coroutine. - Always make sure that your code is cooperative with cancellation.
- All functions from the standard library support cancellation out of the box.
- When the parent coroutine is canceled, all of its children are recursively canceled, too.
- Coroutines manage cancellation internally by using CancellationException.
- 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.