Cancellation
Basic cancellation
The Job
interface has a cancel
method, which allows its cancellation. Calling it triggers the following effects:
- Cancelled coroutine ends the job at the first suspension point (see: Cancellation Cooperative Functions/suspension point).
- If a job has some children, they are also cancelled (but its parent is not affected).
- Once a job is cancelled, it cannot be used as a parent for any new coroutines. It is first in the “Cancelling” and then in the “Cancelled” state. (see Job states)
Basic example cancellation:
suspend fun main(): Unit = coroutineScope {
val job = launch (CoroutineName("job")){
repeat(1_000) { i ->
delay(200)
out.info("Printing $i")
}
}
delay(1100)
job.cancel()
// JOIN is important, to make sure cancellation is complete.
job.join()
out.info("Cancelled successfully")
}
GT-Sandbox-Snapshot
Code
package com.glassthought.sandbox
import gt.sandbox.util.output.Out
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
val out = Out.standard()
suspend fun main(): Unit = coroutineScope {
val job = launch (CoroutineName("job")){
repeat(1_000) { i ->
delay(200)
out.info("Printing $i")
}
}
delay(1100)
job.cancel()
job.join()
out.info("Cancelled successfully")
}
Command to reproduce:
gt.sandbox.checkout.commit aad4f965c9769347302e \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./gradlew run --quiet"
Recorded output of command:
[elapsed: 268ms][tname:DefaultDispatcher-worker-1/tid:20][coroutine:job] Printing 0
[elapsed: 487ms][tname:DefaultDispatcher-worker-1/tid:20][coroutine:job] Printing 1
[elapsed: 692ms][tname:DefaultDispatcher-worker-1/tid:20][coroutine:job] Printing 2
[elapsed: 896ms][tname:DefaultDispatcher-worker-1/tid:20][coroutine:job] Printing 3
[elapsed: 1101ms][tname:DefaultDispatcher-worker-1/tid:20][coroutine:job] Printing 4
[elapsed: 1147ms][tname:DefaultDispatcher-worker-1/tid:20][coroutine:unnamed] Cancelled successfully
Default Cancellation Behavior
In ALL scopes that use Regular Job:
- Uncaught, non-cancellation exception → Cancels Siblings + Propagates recursively through parent Job hierarchy, with each parent shutting down its children, Effectively shutting down the entire hierarchy up to the top most Job (or up to where you caught exception at error boundary).
- Cancels siblings: Cancellation Cooperative Functions/suspension point in sibling functions will throw CancellationException
- Propagates to parent: Parent scope will rethrow the original exception that was thrown in co-routine.
Highlight
Job - provide another avenue of stopping work other than traditional exceptions. Separate cancellation mechanism that is not as apparent as typical exception flow, where failed co-routine cancel's all co-routines that are related to it through Job hierarchy.
- Each coroutine has its own Job instance.
- Each Job instance is tied to it's parent job (if it has a parent Job). This tie in enables cancellation behavior.
Example
If regular Job()
were used. If Coroutine#6 throws non-CancellationException exception the entire hierarchy is gonig to be cancelled and Coroutine#1 will rethrow the exception that Coroutine#6 threw.
Creating Error Boundary
- Look at Coroutine scope function and Error Boundary to create error/ boundaries which allows us to try/catch exceptions stopping them from going higher up.
Gotchas
- ⚠️ Firstly, make sure to understand intended Exception/Cancellation Behavior with Regular Job ⚠️.
- ⚠️ Swallowing cancellation exception prevents cancellation ⚠️
- ⚠️ Unhandled exception from async can go to parent scope without going through await() ⚠️
- ⚠️ cancel() call stops on next suspension point, NOT right away ⚠️
- ⚠️ join() does NOT rethrow ⚠️
Cooperative
Cancellation is cooperative: co-routines MUST call Cancellation Cooperative Functions/suspension point to receive CancellationException and when they do receive Cancellation Exception they must rethrow it.
Functions that check for coroutine cancellation at suspension points and throw CancellationException
when co-routine is cancelled.
Examples:
delay()
,await()
,Channel.receive()
,- ensureActive()
yield()
,
Behavior: Stop execution immediately when parent scope is cancelled, enabling structured concurrency and proper cleanup.
Relationships
For Searchability
For Searchability
- Cancellation Aware Functions
- Respect CancellationException
- Suspending functions that respect cancellation
- Cooperative cancellation
- Cancellation propagation
- Cancellation point
- suspension point that checks for cancellation
Gotchas
Cancellation is cooperative co-routines MUST call Cancellation Cooperative Functions/suspension point to receive CancellationException and when they do receive Cancellation Exception they must rethrow it.
Example we explicitly swallow Cancellation
runBlocking - cancelled co-routine immorally "swallows" Cancellation exception, and keeps going - 😈🐛😈
Dont do this!
This is clear example of what you should NOT be doing.
In this example we have 2 co-routines:
- First co-routine is going to throw exception after 2 seconds.
- Second co-routine is processing some unrelated messages every half second.
Once 1st co-routine throws exception:
- Second co-routine gets Cancellation Exception on invocation of
delay()
(delay is Cancellation Cooperative Functions/suspension point)- BUT in this case second co-routine does something 😈evil😈 and it logs Cancellation exception WITHOUT rethrow, and keeps going. Allowing second co-routine to execute its functionality back-to-back WITHOUT delay.
- After second co-routine immorally finishes all of its messages without delay, the original exception is rethrown to the parent. Where it's caught in the catch block at main() level.
Code
package com.glassthought.sandbox
import gt.sandbox.util.output.Out
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
suspend fun main(): kotlin.Unit {
val out = Out.standard()
out.info("START")
try {
val runBlocking = runBlocking {
launch(CoroutineName("WillFail")) {
val timeMillis = 2000L
out.info("This one will throw in $timeMillis ms")
delay(timeMillis)
out.warn("OK I am about to throw an exception!")
throw MyExceptionWillThrowFromCoroutine("I-Failed-In-CoRoutine")
}
launch(CoroutineName("JustPrints")) {
(0..10)
.map { "a-${it}" }
.forEach {
out.info(it)
try {
delay(500)
} catch (e: CancellationException) {
val excMsg = e.message ?: e.toString()
val emoji = "\uD83D\uDE08"
out.warn("$emoji I caught and swallowed [${e::class.simpleName}/$excMsg] thrown from delay() $emoji")
}
}
}
}
} catch (e: Exception) {
out.error("runBlocking threw an exception! of type=[${e::class.simpleName}] with msg=[${e.message}]")
}
out.info("DONE")
}
class MyExceptionWillThrowFromCoroutine(msg: String) : RuntimeException(msg)
Command to reproduce:
gt.sandbox.checkout.commit 7b37b9e9e1061e7d56c7 \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./gradlew run --quiet"
Recorded output of command:
Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
[INFO][elapsed: 17ms][🥇][🧵][tname:main/tid:1] START
[INFO][elapsed: 47ms][🥇][1️⃣][coroutname:@WillFail#2][tname:main/tid:1] This one will throw in 2000 ms
[INFO][elapsed: 54ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-0
[INFO][elapsed: 555ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-1
[INFO][elapsed: 1056ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-2
[INFO][elapsed: 1556ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-3
[WARN][elapsed: 2052ms][🥇][1️⃣][coroutname:@WillFail#2][tname:main/tid:1] OK I am about to throw an exception!
[WARN][elapsed: 2101ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2101ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-4
[WARN][elapsed: 2101ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-5
[WARN][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-6
[WARN][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-7
[WARN][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-8
[WARN][elapsed: 2102ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2103ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-9
[WARN][elapsed: 2103ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[INFO][elapsed: 2103ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] a-10
[WARN][elapsed: 2103ms][🥇][2️⃣][coroutname:@JustPrints#3][tname:main/tid:1] 😈 I caught and swallowed [JobCancellationException/Parent job is Cancelling] thrown from delay() 😈
[ERROR][elapsed: 2104ms][🥇][🧵][tname:main/tid:1] runBlocking threw an exception! of type=[MyExceptionWillThrowFromCoroutine] with msg=[I-Failed-In-CoRoutine]
[INFO][elapsed: 2105ms][🥇][🧵][tname:main/tid:1] DONE
Can inadvertently swallow CancellationException
While the above example quite clearly does something evil explicitly swallowing the CancellationException, we can very easily run into this issue by inadvertently swallowing cancellation exception. Let's take a look at example pseudo-code to illustrate.
Example inadvertently swallowing CancellationException
// In this example you parse some lines and it's ok for some lines to fail parsing.
// So you try{}catch()/log and keep going.
//
// But you inadvertently end up swallowing cancellation exception and hence not respecting cancellation.
parseLines(){
for (lines):{
try {
accumulator.add(parseVisitHistoryLine())
} catch(e: Exception){
// Let's say its ok for some lines to not be parsable so you log and keep going.
// The problem though is that CancellationException is descendent of Exception
// So what you are not respecting cancellation here.
log.dataError(e)
}
}
return accumulator
}
Must Rethrow CancellationException
// What you should do in this case is to have a separate catch for CancellationException and rethrow it.
parseLines(){
try{
accumulator.add(parseVisitHistoryLine())
} catch(cancelExc: CancellationException){
// Log cancellation with some context
log.cancel(cancelExc)
// Re-throw cancellation exception so that you cooperate with cancellation.
throw cancelExc
} catch(e: Exception){
// Log other exceptions and keep going.
log.dataError(e)
}
return accumulator
}
Children