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 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.sandboximport gt.sandbox.util.output.Outimport kotlinx.coroutines.CoroutineNameimport kotlinx.coroutines.coroutineScopeimport kotlinx.coroutines.delayimport kotlinx.coroutines.launchval 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"
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).
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.
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 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.sandboximport gt.sandbox.util.output.Outimport kotlinx.coroutines.CancellationExceptionimport kotlinx.coroutines.CoroutineNameimport kotlinx.coroutines.delayimport kotlinx.coroutines.launchimport kotlinx.coroutines.runBlockingsuspend 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.debugPicked 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}
✅ Extension methods for handling cooperative cancellation
Extension methods for handling cooperative cancellation
The following extension methods are concise way to avoid having to write separate catch for cancellation exception over and over again.
/** * Executes a suspend function, catching all exceptions except [CancellationException]. * * Does not catch [Error] or other [Throwable] types that don't extend [Exception]. * Cancellation exceptions are always rethrown to preserve structured concurrency. * * Example: * ``` * suspend { * syncData() * }.catching { e -> * logger.error("Sync failed", e) * } * ``` */suspend fun (suspend () -> Unit).catching( onError: suspend (Exception) -> Unit) { try { this() } catch (e: CancellationException) { throw e } catch (e: Exception) { onError(e) }}/** * Executes a suspend function that returns a value, catching all exceptions except [CancellationException]. * * Cancellation exceptions are always rethrown to preserve structured concurrency. * * @param onError Handler invoked when an exception occurs. Must return a fallback value of type [T]. * @return The result of the block, or the fallback value from [onError] if an exception occurs. * * Example: * ``` * val data = suspend { * fetchUserData() * }.catchingReturns { e -> * logger.error("Fetch failed", e) * emptyList() // fallback value * } * ``` */suspend fun <T> (suspend () -> T).catchingReturns( onError: suspend (Exception) -> T): T = try { this()} catch (e: CancellationException) { throw e} catch (e: Exception) { onError(e)}