skip to content

scope.cancel() is called but a child coroutine keeps running. Explain why coroutine cancellation is cooperative, and how to make CPU-bound or blocking code actually stop.

level: seniorimportance: must knowfreq 55%

answer

  1. Cancellation = cooperative, only at check points
  2. Tight loop / Thread.sleep ignore cancel()
  3. isActive, ensureActive(), yield() add check points
  4. runInterruptible + Dispatchers.IO for blocking work
  5. Never swallow CancellationException; NonCancellable for cleanup

basics

~10 s

Cancellation only takes effect at suspension or check points. A tight loop or a blocking call never checks, so it keeps running. You make it stop by suspending, checking isActive, or calling ensureActive()/yield().

solid answer

~40 s

scope.cancel() sets the children's Jobs to cancelling, but a coroutine only observes that at cancellation points — suspend functions in kotlinx.coroutines (delay, yield, calls that go through suspendCancellableCoroutine) throw CancellationException when the Job is cancelled. Pure CPU loops or blocking JVM calls (Thread.sleep, blocking IO) contain no such point, so they ignore cancellation and the scope never truly tears down — a leak despite cancel(). Fixes: insert ensureActive() or check isActive in the loop, call yield() periodically, or for blocking IO use withContext(Dispatchers.IO) plus an interruptible/closeable resource and runInterruptible. Also never swallow CancellationException in a catch (Exception) without rethrowing — that defeats cooperative cancellation. Verifying that long-running children actually honor cancellation is part of writing leak-free lifecycle-scoped code.

code

kotlin · 12 lines
kotlin
scope.launch {
    while (isActive) {            // cooperative: exits on cancel()
        process(nextChunk())
        yield()                  // also a cancellation checkpoint
    }
}

scope.launch {
    withContext(Dispatchers.IO) {
        runInterruptible { socket.read(buffer) }  // cancel -> interrupt
    }
}

go deeper

for a junior

Recognizes delay is cancellable and that something must be 'checked' for cancellation to work.

for a middle

Explains cooperative cancellation and adds isActive/ensureActive() to loops; knows not to swallow CancellationException.

for a senior

Handles blocking IO with runInterruptible/Dispatchers.IO, uses NonCancellable for cleanup, and audits children for real cancellability.

for a principal

Sets standards/tests ensuring lifecycle-scoped work is provably cancellable, preventing leaks across the codebase.

## Cancellation is cooperative, not preemptive When you call `scope.cancel()`, the runtime marks the children's `Job`s as cancelling. It does **not** forcibly stop a thread. A coroutine notices cancellation only at a **cancellation point**. If a coroutine never reaches one, it runs to completion regardless — so the lifecycle scope you tried to tear down still has live work: a leak. ## What counts as a cancellation point - Every suspending function from `kotlinx.coroutines` that is cancellable: `delay`, `yield`, `withContext` boundaries, `await`, channel/Flow suspension. These throw `CancellationException` when the Job is cancelled. - Explicit checks you add: `ensureActive()` and reading `isActive`. ## Code that ignores cancellation ```kotlin // BAD: tight CPU loop, no cancellation point scope.launch { var x = 0L while (x < Long.MAX_VALUE) { x += compute(x) } // ignores cancel() } ``` A blocking call is just as bad: ```kotlin scope.launch { Thread.sleep(60_000) } // blocks the thread, no check ``` ## Making it cooperative **1. Add cancellation checks in CPU loops** ```kotlin scope.launch { while (isActive) { // stops looping when cancelled step() } // or: ensureActive() inside the loop body (throws on cancel) } ``` `ensureActive()` throws `CancellationException` immediately if cancelled; `isActive` lets you exit cleanly. `yield()` both checks cancellation and gives other coroutines a turn. **2. Make blocking work interruptible** Move blocking IO to `Dispatchers.IO` and wrap it so cancellation interrupts the thread: ```kotlin withContext(Dispatchers.IO) { runInterruptible { blockingReadFromSocket() } // cancel -> thread interrupt } ``` Use closeable resources (`use { }`) so cancellation closes them. `runInterruptible` converts thread interruption into cooperative cancellation. ## Don't break cancellation `CancellationException` is how cancellation flows. Catching it and not rethrowing keeps the coroutine alive: ```kotlin try { work() } catch (e: Exception) { log(e) } // BUG: swallows CancellationException ``` Prefer `catch (e: CancellationException) { throw e }` first, or catch specific exceptions. In cleanup that must run during cancellation, use `withContext(NonCancellable) { ... }` so the cleanup itself isn't cancelled. ## Why this matters for scope lifecycle The whole point of owning a scope and calling `cancel()` is deterministic teardown. If children don't cooperate, `cancel()` is a no-op for them and you still leak. Reference-quality lifecycle code ensures every long-running child has a cancellation point and that blocking sections are interruptible. ## Key APIs/keywords - `isActive` — Boolean on the coroutine's scope/Job; false once cancelled. - `ensureActive()` — throws `CancellationException` if cancelled. - `yield()` — cooperative checkpoint + dispatch. - `withContext(Dispatchers.IO)` / `runInterruptible` — make blocking work cancellable. - `NonCancellable` — context for cleanup that must complete during cancellation. - `CancellationException` — the cancellation signal; rethrow, don't swallow.

  • Why is catching Exception around suspend work dangerous for cancellation?
    CancellationException is a subtype of Exception; catching it without rethrowing stops cancellation from propagating, so the coroutine keeps running after cancel().
  • How do you run cleanup code that must complete even though the coroutine is being cancelled?
    Wrap it in withContext(NonCancellable) { ... } so the cleanup's suspension points aren't themselves cancelled.
  • What does runInterruptible do?
    It runs a blocking block such that coroutine cancellation triggers a thread interrupt, turning blocking JVM calls into cooperatively cancellable work.

Cancellation is like asking a worker to stop when they next look up; if they never look up (no checkpoint), your request goes unheard until they finish.

saying these in an interview costs you the question

  • Thinks cancel() preempts/kills threads immediately
  • Adds cancel() but never a cancellation point in CPU loops
  • Swallows CancellationException in a generic catch
  • Uses Thread.sleep instead of delay and expects cancellation
  • Doesn't know about isActive/ensureActive()/yield()

context