How does cancellation propagate through a Job hierarchy when you cancel a parent?
answer
- cancel() flows DOWN the whole subtree
- Cooperative: stops at next suspension point
- CancellationException is swallowed (not a failure)
- Never swallow CancellationException — re-throw
- finally + NonCancellable for cleanup
basics
~10 sCancelling a parent cancels all of its children, and their children, all the way down the tree. Each coroutine stops at its next suspension point by getting a cancellation exception.
solid answer
~40 sCalling cancel() on a parent Job transitions it to Cancelling and recursively cancels every descendant by propagating a CancellationException down the parent-child links. Each affected coroutine becomes cooperatively cancelled: at its next suspension point (delay, yield, or any suspend call that checks isActive/ensureActive), it throws CancellationException and unwinds, running finally blocks and NonCancellable cleanup. A parent will not reach Cancelled until all descendants are cancelled and done — cancellation is also structured. CancellationException is special: it's swallowed by the machinery and does not mark the parent as failed. Cancellation flows strictly downward from cancel(); upward propagation happens only on child failure (a different mechanism), and a child cancelling itself does NOT cancel its parent.
code
kotlin · 12 linesimport kotlinx.coroutines.*
fun main() = runBlocking {
val parent = launch {
launch { try { delay(5000) } finally { println("child A cleanup") } }
launch { try { delay(5000) } finally { println("child B cleanup") } }
}
delay(100)
parent.cancelAndJoin() // cancels parent -> both children
println("all cancelled")
}
// child A cleanup / child B cleanup (order may vary) then: all cancelledgo deeper
Knows cancelling a parent stops its children too.
Explains cooperative cancellation at suspension points and the role of finally for cleanup.
Distinguishes downward cancel() from upward failure propagation, handles CancellationException correctly, and uses NonCancellable.
Reasons about cancellation as a structured, awaited subtree teardown and the invariants that prevent leaked or zombie coroutines.
## Cancellation goes down the tree When you call `job.cancel()` (or a coroutine throws, or its scope is cancelled), the `Job`: 1. Moves to the **Cancelling** state. 2. **Recursively cancels all children**, which cancel their children, and so on — the whole subtree. 3. Waits for every descendant to finish unwinding, then becomes **Cancelled**. The mechanism is a `CancellationException` pushed into each coroutine. ## Cancellation is cooperative A coroutine is not killed mid-instruction. It stops at the **next suspension point**: - All `kotlinx.coroutines` `suspend` functions (`delay`, `yield`, `await`, `join`, channel ops...) check for cancellation and throw `CancellationException`. - Pure CPU loops with no suspension keep running. You make them cooperative with `yield()`, `ensureActive()`, or by checking `isActive`. ```kotlin val job = scope.launch { try { while (isActive) { // cooperative check heavyStep() ensureActive() // throws if cancelled } } finally { // cleanup still runs on cancellation withContext(NonCancellable) { closeResource() } } } job.cancel() job.join() ``` ## CancellationException is special - It is the signal used for normal cancellation, so the framework **swallows** it — a cancelled child does **not** mark its parent as failed. - Therefore `try/catch (e: Exception)` that swallows `CancellationException` is a bug: it breaks cancellation. Re-throw it (or catch only your own exceptions). ## Direction matters - **`cancel()` propagates DOWN only.** Cancelling a parent cancels children; cancelling a single child does **not** cancel the parent or siblings. - **Failure propagates UP.** If a child throws a *non*-CancellationException, that failure goes up to the parent, which then cancels the parent's other children — a separate mechanism from `cancel()` (and stoppable with a `SupervisorJob`, covered in the sibling topic). ## Structured cancellation Just like completion, cancellation is structured: the parent's `Cancelling -> Cancelled` transition waits for all descendants to finish their unwinding (including `finally`/`NonCancellable` cleanup). So `parent.cancelAndJoin()` guarantees the whole subtree is fully torn down.
- If a child catches and swallows CancellationException, what breaks?The child appears to keep running and the cancellation/join semantics break — the parent may hang waiting, and the coroutine is no longer cancellable. Always re-throw CancellationException.
- Does cancelling one child cancel its siblings?No. cancel() only propagates downward. Sibling cancellation happens when a child fails (non-CancellationException), which cancels the parent and thus the siblings.
- How do you run cleanup that itself suspends, during cancellation?Wrap it in withContext(NonCancellable) { ... } inside a finally block; otherwise suspending calls in cleanup immediately throw because the job is already cancelling.
Cancelling a parent is like a manager telling their whole team to stop; each person finishes the line they're on, tidies their desk (finally), then leaves.
saying these in an interview costs you the question
- Thinking cancellation force-kills a coroutine immediately
- Swallowing CancellationException in a generic catch block
- Believing a tight CPU loop is cancellable without yield/ensureActive
- Saying cancel() propagates upward to the parent
- Forgetting that cleanup needs NonCancellable when it suspends