“There was a Cancellation in coroutines,” said Florina Muntenescu

In development, as in life, we know to avoid doing too much work because it wastes memory and experience. The same principle applies to coroutines. You need to make sure you control the coroutine lifecycle and cancel it when it’s not needed – this is what coroutine structured concurrency represents.

⚠️ For the rest of this article, read and understand chapter 1 of this series

Cancels the coroutine in progress

When starting multiple coroutines, it can be cumbersome to trace or cancel them one by one, but we can rely on canceling the parent coroutine or coroutine scope, as this will cancel all coroutines it creates.

Suppose we have defined a scope scope for the following code

valjob1 = scope.launch {... }valjob2 = scope.launch {... } the scope. The cancel ()Copy the code

Cancelling the scope cancels its children Cancelling the scope cancels its children

Sometimes you may need to cancel only one coroutine, and calling job1.cancel() ensures that only that particular coroutine is cancelled and all its siblings are unaffected.

valJob1 = scope.launch {... }valJob2 = scope.launch {... }// The first coroutine is cancelled, the second is unaffected
job1.cancel()
Copy the code

A cancelled child coroutine does not affect other siblings.

Coroutines handle cancellations by throwing a special exception: CancellationException. If you want to provide more details about the cancellation reason, you can pass in an instance of CancellationException when calling the cancel() method, since this is the full method signature of cancel() :

fun cancel(cause: CancellationException? = null)
Copy the code

If the default call is used, a default CancellationException instance is created (full code here)

public override fun cancel(cause: CancellationException?).{ cancelInternal(cause ? : defaultCancellationException()) }Copy the code

Because cancellation of coroutines throws a CancellationException, we can use this mechanism to do something about cancellation of coroutines. For detailed instructions on how to do this, see the “Handling cancellation Side effects” section below this article. At the bottom, the cancellation of the child coroutine is notified to the parent by throwing an exception, and the parent determines whether the exception needs to be handled based on the reason for the cancellation. If the child coroutine is cancelled due to a CancellationException, the parent does not need to perform any further operations.

⚠️ We cannot create a new coroutine in the scope of a coroutine that has been canceled

When using the AndroidX KTX library, in most cases we do not need to create our own scopes, so we are not responsible for canceling them. For example, we can use viewModelScope in ViewModel, or lifecycleScope when we want to start a coroutine that is related to the lifecycle of the page. ViewModelScope and lifecycleScope are coroutine scoped objects that can be cancelled automatically at the right time. For example, when the ViewModel is cleared, the coroutine started in the viewModelScope is also cancelled.

Why didn’t my coroutine stop

If we just call cancel(), it doesn’t mean that the coroutine’s work will stop immediately. If the coroutine is doing some heavy computation, such as reading data from multiple files, there is nothing to stop it automatically. Let’s take a simpler example and see what happens. Suppose we need to print “Hello” twice a second using the coroutine, we let the coroutine run for a second and then cancel it;

import kotlinx.coroutines.*
 
fun main(args: Array<String>) = runBlocking<Unit> {
   val startTime = System.currentTimeMillis()
    val job = launch (Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (i < 5) {
            // print a message twice a second
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("Hello ${i++}")
                nextPrintTime += 500L
            }
        }
    }
    delay(1000L)
    println("Cancel!")
    job.cancel()
    println("Done!")}Copy the code

Let’s see what happens step by step. When launch is called, we are creating a new coroutine that is active. We let the coroutine run for 1000 milliseconds and now we see:

Hello 0
Hello 1
Hello 2
Copy the code

Once job.cancel() is called, the coroutine goes into Cancelling state, but then we still see Hello3 and Hello4 printed to the console. Only after the coroutine has completed its work will it be moved into Cancelled state. The coroutine does not stop immediately when job.cancel() is called, so we need to modify the code and periodically check to see if the coroutine is active.

⚠️ Cancellation of coroutines is collaborative and requires cooperation.

Make coroutines cancelable

We need to ensure that all coroutines are cooperative with cancellations, so we need to check for cancellations periodically or before starting any long running work. For example, when we read multiple files from disk, we should check to see if the coroutine has been canceled before we start reading each file, so that we can avoid performing CPU-intensive operations to reduce consumption when the CPU is no longer needed.

val job = launch {
    for(file in files) {
        // TODO check for cancellation
        readFile(file)
    }
}
Copy the code

All pending functions in kotlinx.coroutines can be cancelled, such as withContext(), delay(), etc. Therefore, if we use any of them, there is no need to check for cancellation and immediately stop or throw a CancellationException. But if we don’t use these, we have two options in order to make our coroutines cancelling cooperatively:

  • usejob.isActiveorensureActive()To check the
  • Use the yield ()

Check the Job activity status

Using the code above as an example, the first option is to add a state check to the coroutine at while(I < 5)

// Since we are inside the coroutine, we can access job.isactive,
while (i < 5 && isActive)
Copy the code

This means that work should only be performed when the coroutine is active, and once we leave the while, we can add a check if we want to do something else, like logging if the Job was canceled! IsActive. The coprogramming library provides another useful method, ensureActive(), which implements:

fun Job.ensureActive(a): Unit {
    if(! isActive) {throw getCancellationException()
    }
}
Copy the code

Since this method immediately throws an exception when the Job is inactive, we can use it as the first operation in the while loop:

while (i < 5) {ensureActive ()... }Copy the code

By using the ensureActive() method, we can eliminate the need to write boilerplate code by eliminating the need to implement if statements for isActive ourselves, but also lose the flexibility to perform other operations, such as logging.

Use the yield ()

Should be used if the operation we want to perform is 1) consuming a lot of CPU resources, 2) potentially depleting the thread pool, 3) and we want to allow threads to perform other work without adding more threads to the pool. The first operation of yield() is to check if the Job is complete. If it is, the coroutine can be exited by throwing a CancellationException. Yield () can be used as the first function in periodic checks, as ensureActive() did above.

Note: Using the yield() function should be noted that, in most cases, yield() suspends the current coroutine to allow other coroutines running on the same thread to execute, providing a mechanism for multiple long-running tasks to fairly tie up threads. The special cases are as follows: 1) If the current coroutine is Dispatchers.Unconfined, the current coroutine will be suspended only when other Dispatchers are also Dispatchers.Unconfined and event-Looper has been formed; 2) Yield () does not suspend the current coroutine if no coroutine scheduler is specified in the context of the current coroutine.

Job.join vs Deferred.await cancellation

There are two ways to wait for the result of a coroutine: Job instances returned from launch can call join methods, and Deferred (a subclass of Job) returned from Async can call await methods. Job.join() suspends a coroutine until its work is done. Used with job.cancel () yields results based on our call order:

  • If I call alpha and beta firstJob.cancel()And then callJob.join(), the coroutine will hang untilJob To complete.
  • If I callJob.join()And then call andJob.cancel(), will have no effect because the coroutine is already done.

We can use Deferred when we are more interested in the results of coroutines. When the coroutine completes, the result is returned via deferred.await (). Deferred is a subclass of Job, so it is also cancelable. Calling await() on the cancelled Deferred throws JobCancellationException.

valDeferred = async {... } deferred.cancel()val result = deferred.await() // throws JobCancellationException!
Copy the code

Since the purpose of await is to suspend the coroutine until the result is obtained, the result cannot be evaluated since the coroutine has been canceled. Therefore, cancel the call again after await could lead to JobCancellationException: Job was cancelled. On the other hand, calling deferred.cancel() after deferred.await() has no effect because the coroutine is already done.

Handle cancellation side effects

Suppose we want to perform a specific action when the coroutine is canceled such as closing any resources that may be in use, logging the cancellation, or performing other cleanup code, there are several ways to do this:

Check using isActive

If we periodically check the status of isActive, then once we exit the while loop, we can do some resource cleaning. The code above can be updated to read:

while (i < 5 && isActive) {
    // print a message twice a second
    if(...). Println (" Hello ${i++} ") nextPrintTime +=500L
    }
}
// The coroutine is complete and we can clean it upPrintln (" the Clean up!" )Copy the code

You can see it in action here. So now, when the coroutine is no longer active, the while loop breaks, and we can clean up some resources.

try catch finally

Since a CancellationException is thrown when a coroutine is cancelled, it is possible to wrap coroutines in try/catch and finally blocks to perform some cleanup:

val job = launch {
   try {
      work()
   } catchE: CancellationException {println (" Work cancelled!" )}finally{println (" the Clean up!" ) } } delay(1000L) println (" Cancel!" ) the job. The cancel () println (" Done!" )Copy the code

However, if the cleanup we need to perform is pending, the above code no longer works, because once the coroutine is Cancelling, it cannot be suspended again.

⚠️ Cancelled coroutines cannot be suspended again!

In order to be able to call the suspend function when the coroutine is cancelled, we need to switch the cleanup work in a non-cancelling coroutine context, which will allow the code to suspend and leave the coroutine in Cancelling state until the work is done:

val job = launch {
   try {
      work()
   } catchE: CancellationException {println (" Work cancelled!" )}finally {
      withContext(NonCancellable){
         delay(1000L) // or other suspend functionsPrintln (" Cleanup done!" ) } } } delay(1000L) println (" Cancel!" ) the job. The cancel () println (" Done!" )Copy the code

You can practice the above code here.

SuspendCancellableCoroutine and invokeOnCancellation

If suspendCoroutine method is used to change the callback to coroutines, so it is best to use suspendCancellableCoroutine. You can use the continuation. InvokeOnCancellation to complete the cancel the work:

suspend fun work(a) {
   return suspendCancellableCoroutine { continuation ->
       continuation.invokeOnCancellation { 
          // Can do some cleaning
       }
   // Rest of the implementation
}
Copy the code

To safely enjoy the benefits of structured concurrency without doing unnecessary work, we need to ensure that our coroutines are cancelable. Using CoroutineScopes (viewModelScope or lifecycleScope) defined in JetPack ensures that the internal coroutine is cancelled when the scope ends. If we were to create our own CoroutineScopes, we would bind it to a Job and cancel it if needed. The cancellation of coroutines is cooperative, so ensure that the cancellation is lazy when using coroutines to avoid unnecessary operations.