ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Follow publication

Cancellation in Kotlin Coroutines - Internal working

Hello Folks,

Today, we’re diving into a crucial topic in coroutines: coroutine cancellation. This is vital because, in Android, every coroutine is tied to a view or lifecycle, and when that view is destroyed, the coroutine should also end. Similarly, other coroutines need to wrap up when the app closes.

This cancellation behavior is incredibly important, as many other libraries require complex mechanisms to manage it. However, in Kotlin Coroutines, it’s both straightforward and safe, making it easier to ensure proper coroutine cleanup.

Let’s explore this key property and see how coroutines handle cancellation so efficiently.

This mechanism is also used on the backend, especially when we deal with long connections, like WebSockets or long polling. What is more, cancellation often works in situations we do not even realize, to free resources and to make our application more efficient.

Cancellation is crucial, and many classes and libraries use suspending functions mainly to support it. There’s a valid reason for this focus: a robust cancellation mechanism is invaluable. Simply killing a thread is a poor solution, as it doesn’t allow for proper cleanup of resources or closing of connections. Likewise, forcing developers to manually check some status repeatedly isn’t ideal. The need for an effective cancellation method has been longstanding, but Kotlin Coroutines provide a solution that’s surprisingly straightforward, user-friendly, and safe.

ViewModelScope Cancellation:

When you use viewModelScope or lifecycleScope, cancellation becomes straightforward—you don't need to manually manage it. When the ViewModel is destroyed, viewModelScope is automatically cancelled, along with all the coroutines it contains.

How does it work internally? Let’s dive into some code to understand the mechanics:

Explanation:

  • The viewModelScope operates with a structure that involves setting a tag to the ViewModel. This is done via setTagIfAbsent, which takes two parameters: a key and a CloseableCoroutineScope. The CloseableCoroutineScope implements a Cancelable interface, providing a way to cancel coroutines.
  • Inside setTagIfAbsent, there's a HashMap called mBagOfTags. This map stores various tags and corresponding scopes. When a ViewModel is about to be cleared, the onClear method is called.
  • During onClear, it invokes closeWithRuntimeException, which checks if the scope is an instance of Closeable. If so, it closes (or cancels) that scope, effectively cancelling all coroutines running within it. This behavior ensures that when the ViewModel is destroyed, all associated coroutines are automatically cancelled.

That’s the internal mechanism of viewModelScope, providing a way to clean up and cancel coroutines when the ViewModel is no longer in use. This automation means you don't have to worry about manually handling coroutine cancellation—it happens as part of the lifecycle.

What if we create a CoroutineScope manually that isn’t bound to viewModelScope or lifecycleScope, where cancellation is automated?

In that case, you can cancel the job manually by calling job.cancel(). Let's see an example of how to do that.

suspend fun main(): Unit = coroutineScope {
val job = launch {
repeat(1_000) { i ->
delay(200)
println("Printing $i")
}
}

delay(1100)
job.cancel()
job.join()
println("Cancelled successfully")
}
// (0.2 sec)
// Printing 0
// (0.2 sec)
// Printing 1
// (0.2 sec)
// Printing 2
// (0.2 sec)
// Printing 3
// (0.2 sec)
// Printing 4
// (0.1 sec)
// Cancelled successfully

Explanation:

  • When you cancel a coroutine, its state changes to “cancelling.”
  • In the code above, you can see that after 200 ms, it prints 5 times, indicating about 1 second of execution. But then, after an additional 100 ms, the coroutine gets cancelled, and it stops with “Cancelled successfully.”
  • When cancellation occurs, the coroutine’s state transitions to “cancelling,” and it throws a CancellationException, signaling that the coroutine has been interrupted and is in the process of stopping.
  • job.join(): This suspends the current coroutine until the specified job completes. This means it waits until the other coroutine has fully stopped and all its resources are released.

Let’s look at the order and how this cancellation will work:

  • All the children of this job are also canceled.
  • The job cannot be used as a parent for any new coroutines.
  • At the first suspension point, a CancellationException is thrown. If this coroutine is currently suspended, it will be resumed immediately with CancellationException. CancellationException is ignored by the coroutine builder, so it is not necessary to catch it, but it is used to complete this coroutine body as soon as possible.
  • Once the coroutine body is completed, and all its children are completed too, it changes its state to “Cancelled”.

You can catch a CancellationException with a try-catch block. But why bother catching it? After all, it doesn't really harm anything, right?

Well, sometimes you need to clean up after a coroutine is cancelled—like closing resources or rolling back changes. In those cases, catching the exception is your way of handling the cleanup process. It's all about keeping things tidy when the coroutine gets the axe. 🔥🧹

Let’s check out the code:

suspend fun main(): Unit = coroutineScope {
val job = launch {
try {
repeat(1_000) { i ->
delay(200)
println("Printing $i")
}
} catch (e: CancellationException) {
println("Cancelled with $e")
} finally {
println("Finally")
}
}
delay(700)
job.cancel()
job.join()
println("Cancelled successfully")
delay(1000)
}
// (0.2 sec)
// Printing 0
// (0.2 sec)
// Printing 1
// (0.2 sec)
// Printing 2
// (0.1 sec)
// Cancelled with JobCancellationException...
// Finally
// Cancelled successfully

Explanation:

The code above is pretty straightforward, so let’s keep going.

What if, after cancelling a job, you want to make sure it’s really cancelled? No worries — Kotlin Coroutines has got you covered with these three states to check on:

  1. job.isActive
  2. job.isCompleted
  3. job.isCancelled

Job Creation States:

  • Jobs are usually created in the active state, meaning they start immediately.
  • Some coroutine builders have an optional start parameter. If set to [CoroutineStart.LAZY], the coroutine is created in the new state and can be activated by invoking start or join.

Active State:

  • A job is considered active while the coroutine is executing until it completes, or if it fails or is canceled.
  • Canceling and Cancelled States:
  • If an active job fails with an exception, it transitions to the canceling state.
  • A job can be manually canceled at any time with the cancel function, which also transitions it to the cancelling state.
  • The job becomes canceled when it finishes executing its work and all its children complete.

Completing and Completed States:

  • When an active coroutine’s body completes or CompletableJob.complete is called, the job transitions to the completing state.
  • The job remains in the completing state while it waits for all its children to complete.
  • Once all children are completed, the job transitions to the completed state.
  • Note that for external observers, a job in the completing state still appears active, but internally, it’s waiting for its children to finish.

When you cancel a coroutine scope, it’s a done deal — you can’t use it to launch new coroutines. A cancelled scope can’t be used as a parent for anything new. If you want to keep going, you have to create a new scope. A scope with a cancelled job is like an expired ticket: it won’t get you anywhere.

Let’s take a look at some code to see this in action:

suspend fun main() {

val scope = CoroutineScope(Dispatchers.IO)
scope.launch {

repeat(1_000) { i ->
delay(200)
println("Printing $i")
}
}
delay(1100)
scope.cancel()
scope.launch {
println("New Scope")
}
println("Cancelled successfully")
delay(1000)
}

Output:-
Printing 0
Printing 1
Printing 2
Printing 3
Printing 4
Cancelled successfully

Explanation:

  • As you can see on the above code output, we canceled the scope and launched it again but didn’t execute.

Cancellation in a coroutine scope internally:

  • To understand why this happens, let’s dig into the internal codebase.
  • Here’s where we’ll see what’s going on under the hood:
  • Let’s break down how this mechanism impacts the cancellation of child coroutines:

The Role of a Job:

  • A Job represents a unit of work in a coroutine and acts as a parent for all child coroutines within the same scope. It tracks their state (active, cancelled, or completed). If the CoroutineContext doesn't contain a Job, a default one is created to ensure centralized control over coroutine management.

Cancellation Behavior:

  • When you create a CoroutineScope with a context that includes a Job, any child coroutine launched within this scope becomes part of that Job. This allows cancellation to cascade, meaning cancelling the parent Job also cancels all child coroutines.
  • By calling CoroutineScope.cancel() on a scope with a Job, you trigger the cancellation process for all child coroutines. This initiates an orderly shutdown, causing coroutines to complete their work or exit at the next suspension point (like delay).

Application in Your Code:

  • In your code, you created a CoroutineScope and launched multiple coroutines within it. When you called scope.cancel(), it triggered the cancellation process for the parent Job and all its child coroutines. The orderly cancellation process helps ensure that all operations within the scope are stopped consistently and resources are released appropriately.w

As you guys already know that cancellation happens only at suspension point. but what if our thread is blocked, there is an I/O operation is happening inside coroutine which is large process. What will happen at the time when cancel the job? We can simulate the process via using Thread.sleep, but never try this situation in your live project.

Let’s look at the scenario:-

suspend fun main(): Unit = coroutineScope {
val job = Job()
launch(job) {
repeat(1_000) { i ->
Thread.sleep(200) // We might have some
// complex operations or reading files here
println("Printing $i")
}
}
delay(1000)
job.cancelAndJoin()
println("Cancelled successfully")
delay(1000)
}
// Printing 0
// Printing 1
// Printing 2
// ... (up to 1000)

Explanation:

  • The example below presents a situation in which a coroutine cannot be cancelled because there is no suspension point inside it (we use Thread.sleep instead of delay). The execution needs over 3 minutes, even though it should be cancelled after 1 second.
  • You guys can decompile the code and understand also.

Solution:

  • To overcome from this problem we can use yield().
  • Now someone will ask what the hack is yield(). But wait, before we dive deeper into this let’s look at the code.
suspend fun main(): Unit = coroutineScope {
val job = Job()
launch(job) {
repeat(1_000) { i ->
Thread.sleep(200) // We might have some
yield()
// complex operations or reading files here
println("Printing $i")
}
}
delay(1000)
job.cancelAndJoin()
println("Cancelled successfully")
delay(1000)
}

Output:-
Printing 0
Printing 1
Printing 2
Printing 3
Printing 4
Cancelled successfully
  • It is a good practice to use yield in suspend functions, between blocks of non-suspended CPU-intensive or time-intensive operations.
  • Let’s look at what the hack is yield().

In normal word yield is mean to give way to someone or something that one can no longer resist.

In our terms:

  • Thread Yielding: By calling yield(), the current coroutine gives control back to the dispatcher, allowing other coroutines to run. This is particularly useful for creating fairness among coroutines sharing the same dispatcher.
  • Cancellable: If the Job associated with the coroutine is cancelled while yield() is called or while waiting for dispatch, the coroutine immediately resumes with a CancellationException. This provides a prompt cancellation guarantee, ensuring cancellation is respected even if yield() was ready to return.
  • Cancellation Check: Even if yield() doesn't suspend, it still checks if the coroutine has been cancelled through ensureActive(). This adds an additional level of safety to prevent unwanted coroutine continuation after cancellation.

ensureActive():

It will check that job is still active or not using isActive which we already so earlier.

Try it out, and put a question in the comments if you guys have any. I’ll see you guys soon with some new amazing topics.

Published in ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Responses (2)

Write a response