Mallow's Blog

Kotlin Coroutines; Knowing The Cancellations Better

Cancellation in Coroutines:

As we know, it’s important to avoid doing more work than needed, since it can waste memory and energy. The same principle applies to Coroutines also and the Cancellation is important to do this.

CoroutineScope:

A CoroutineScope can keep the track of any coroutine that you created using launch or async. The ongoing work can be cancelled by calling scope.cancel() at any point.You should make a CoroutineScope on every point needed to begin and control the lifecycle of coroutines in a specific layer of your application. In  platforms like Android, there are KTX libraries that gives  CoroutineScope the certain lifecycle classes like viewModelScope and lifecycleScope.

While creating the CoroutineScope, it will take the CoroutineContext as its parameter. You can create the new scope and coroutine as below,

val scope = CoroutineScope(Job() + Dispatchers.Main)

val job = scope.launch {

// new routine

}

Job:

A Job is a handle to the coroutine. For every coroutine that you create, it returns a Job instance that particularly recognises the coroutine and deals with its lifecycle. As mentioned above, you can also pass a Job to a CoroutineScope to handle its lifecycle.

CoroutineContext:

The CoroutineContext contains the below elements that defines the coroutine’s behaviour.

Job – Controls the coroutine’s lifecycle

CoroutineDispatcher – Dispatches the work to an appropriate thread

CoroutineName – Name of the coroutine, useful for debugging

CoroutineExceptionHandler – Handles the uncaught exceptions

As we know already, the new instance of Job will be created and allows us to control its lifecycle. Then the rest of the elements will be inherited from CoroutineContext of its parent.

Job lifecycle:

A Job will have the following states; New, Active, Completing, Completed, Cancelling and Cancelled. 

Since we don’t have access to the states directly, we can use these methods; isActive, isCancelled, and isCompleted

When we calling the job.cancel() for the active coroutine, it moves the job to Cancelling state. Once all children have completed their work, the coroutine will go to Cancelled state.

Coroutine cancelling:

When launching multiple coroutines, it can be difficult to track them or cancel each individually. If we cancel the launched scope coroutine, it will directly cancel it’s child coroutines created;

val job1 = scope.launch { … } 

val job2 = scope.launch { … }

scope.cancel() // Child coroutines will be cancelled

Sometimes, you might need to cancel only one coroutine. So that you need to call job1.cancel() to ensures that only specific coroutine gets cancelled and all other siblings are not affected;

val job1 = scope.launch { … } 

val job2 = scope.launch { … }

job1.cancel() // First coroutine will be cancelled

What is CancellationException?

Whenever you cancelling the coroutine, it will be throwing CancellationException. If you need to provide more details on cancellation reasons you can provide an instance of CancellationException while calling cancel(). 

fun cancel(cause: CancellationException? = null)

If you don’t provide your own CancellationException instance, a default CancellationException will be made as below;

public override fun cancel(cause: CancellationException?) {

    cancelInternal(cause ?: defaultCancellationException())

}

If the child coroutine was cancelled due to CancellationException, then no other action is required for the parent. This means you can’t be able to launch new coroutines from the cancelled scope.

If you are using the androidx ktx libraries, you don’t need to create your own scopes and therefore you are not responsible for cancelling them. If you are working in the scope of a ViewModel, use viewModelScope and, if you want to launch coroutines tied to a lifecycle scope, you would use the lifecycleScope. Both viewModelScope and lifecycleScope are CoroutineScope objects that will be cancelled at the right time.

Concerns on cancelling coroutine:

If we just call the cancel() function, it doesn’t stop the coroutine work. If you are doing some heavy computation, like reading multiple files, there’s nothing that automatically stops your code from running.

In the below example, we need to print “Hello” twice a second using coroutines. We are going to run the coroutine for a second and then cancel it.

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) {

            if (System.currentTimeMillis() >= nextPrintTime) {

                println(“Hello ${i++}”)

                  nextPrintTime += 500L

                 }

        }

    }

    delay(1000L)

    println(“Cancel!”)

    job.cancel()

    println(“Done!”)

}

When calling launch, we are creating a new coroutine in the active state and letting the coroutine run for 1000ms. It will print the below output,

Hello 0

Hello 1 

Hello 2 

Cancel! 

Done! 

Hello 3 

Hello 4 

Once job.cancel is called, our coroutine will move to Cancelling state. But, we can see that Hello 3 and Hello 4 are also printed. Only after the work is done, the coroutine moves to Cancelled state, Here, the coroutine work doesn’t stop when cancel is called. So, we need to modify the code to check the coroutine is active periodically.

Making your coroutine work cancellable:

You need to make sure that all the coroutine work you  implement is cooperative with cancellation. So you need to check for cancellation periodically or before beginning any long-running work.

We have two options to make your coroutine code cooperative:

  • Checking job.isActive or ensureActive()
  • Using yield() to other work happens

Checking for job’s active state:

One option is to add another check along with (i<5), which means that the work should only be executed while the coroutine is in an active state.

// Since we are in the launch block, we have access to job.isActive

while (i < 5 && isActive)

Another helpful method provided by the Coroutine is 

ensureActive().

fun Job.ensureActive(): Unit {

    if (!isActive) { 

        throw getCancellationException()

    }

}

Because this method throws an exception if the job is not active, we can include this as first thing in our while loop.

while (i < 5) {

    ensureActive()

    // Add remaining logics here

}

By using ensureActive(), you can avoid implementing the ‘if statement’ required by ‘isActive’ yourself and it  decreases the amount of boilerplate code you need to write, but we lose the flexibility to perform any other action like logging. 

Use yield() for other works: 

If the, 1) CPU heavy, 2) may exhaust the thread pool and 3) you want to allow the thread to do other work without adding more threads to the pool, then use yield()

The yield will be checking for completion of the first operation and then exit the coroutine by throwing CancellationException if the job is already completed.

Handling cancellation side effects:

Let’s say that you want to execute a specific action when a coroutine is cancelled: closing any resources you might be using, logging the cancellation or some other cleanup code you want to execute. There are several ways we can do this:

Check for !isActive

If you are periodically checking for isActive status, then once you should be out of the while loop then you can clean up the resources. Our code could be updated as below,

while (i < 5 && isActive) {

    // print a message twice a second

    if (…) {

        println(“Hello ${i++}”) 

        nextPrintTime += 500L

    }

}

println(“Clean up!”)

Try catch finally

Since a CancellationException is thrown when a coroutine is cancelled, then we can wrap our suspending work in try/catch and in the finally block, we can implement the clean up work.

val job = launch {

    try {

      work()

    } catch (e: CancellationException){

      println(“Work cancelled!”)

    } finally {

      println(“Clean up!”) 

    }

}

delay(1000L)

println(“Cancel!”)

job.cancel()

println(“Done!”)

Conclusion:

Hope this was helpful to understand the advantages of structured concurrency and guarantee that we are not doing unnecessary work. This is indeed essential for you to ensure that you are also making your code cancellable.

Sasikumar, Android Team

Mallow Technologies Pvt Ltd

Leave a Comment

Your email address will not be published. Required fields are marked *