skip to content

You launch a coroutine that runs a tight CPU-bound while-loop, then call cancel() on its Job. The loop keeps running. Why, and what is the simplest fix?

level: juniorimportance: must knowfreq 70%

answer

  1. Cancellation is cooperative, not forced
  2. cancel() only sets state
  3. CPU loop has no suspension point
  4. while (isActive) is the simplest fix
  5. ensureActive/yield also work

basics

~10 s

Cancellation is cooperative: the coroutine only stops if it checks for cancellation. A pure CPU loop never checks, so it ignores cancel(). Add a check inside the loop, such as while (isActive).

solid answer

~40 s

Kotlin coroutine cancellation is cooperative. cancel() just sets the Job's state to cancelling; nothing forcibly stops the running code. Suspend functions in kotlinx.coroutines (delay, yield, etc.) cooperate by throwing CancellationException when resumed after cancel. A tight CPU loop calls no such suspend point, so it never observes cancellation and runs to completion. The simplest fix is to make the loop check the cancellation state: use `while (isActive)` (isActive is a CoroutineScope/CoroutineContext extension that is false once cancelled), or call `ensureActive()` / `yield()` inside the loop body. isActive is non-throwing and just stops the loop; ensureActive() throws CancellationException; yield() also throws and additionally lets other coroutines on the dispatcher run.

code

kotlin · 9 lines
kotlin
val job = launch(Dispatchers.Default) {
    var i = 0L
    while (isActive) {   // checks cancellation each iteration
        i++
    }
    println("stopped at $i")
}
delay(50)
job.cancelAndJoin()  // now the loop actually stops

go deeper

for a junior

States cancellation is cooperative and adds while (isActive) to fix the loop.

for a middle

Explains cancel() only sets state and lists isActive vs ensureActive vs yield with their throw/non-throw behavior.

for a senior

Connects to how suspend functions act as check points and why CancellationException must not be swallowed.

for a principal

Discusses structured-concurrency implications, dispatcher fairness via yield, and designing long compute loops with periodic checks and budgets.

## Why the loop ignores cancel() **Cooperative cancellation** means a coroutine is never killed from the outside. Calling `job.cancel()` only moves the `Job` into the *Cancelling* state and stores a `CancellationException`. The coroutine actually stops only when its code reaches a **cancellation check point** and observes that state. The built-in suspend functions in `kotlinx.coroutines` (`delay`, `yield`, `withContext`, channel/Flow operators, etc.) are the usual check points — when the coroutine is resumed after a cancel, they throw `CancellationException`. A tight loop like this contains no such point: ```kotlin val job = launch(Dispatchers.Default) { var i = 0L while (i < Long.MAX_VALUE) { i++ } // no suspension, no check } job.cancel() // sets state, but the loop never notices ``` So the loop runs to the end regardless of `cancel()`. ## How to make it cancellable Insert a cancellation check inside the loop. Three options: - **`isActive`** — a Boolean extension on `CoroutineScope`/`CoroutineContext`. It is `true` while active and `false` once cancelled. Non-throwing; you just exit the loop. ```kotlin while (isActive) { /* work */ } ``` - **`ensureActive()`** — checks the same state but **throws** `CancellationException` if cancelled, so the coroutine unwinds like any other suspend function would. - **`yield()`** — a `suspend` function that checks cancellation (throws if cancelled) **and** yields the thread so other coroutines can run. ## Key terms - **`Job`**: handle to a coroutine's lifecycle; `cancel()` requests cancellation. - **`CancellationException`**: the signal used to unwind a cancelled coroutine; it is treated specially and not logged as an error. - **CPU-bound**: work that keeps the thread busy computing (no I/O, no suspension), which is exactly the case that needs manual checks.

  • Why is delay() cancellable but your while-loop is not?
    delay() is a suspend function that registers a cancellation handler; when the Job is cancelled it resumes with CancellationException. A plain loop suspends nowhere, so there is nothing to throw.
  • Does isActive throw?
    No. isActive just returns false once cancelled; you decide how to exit. ensureActive() and yield() throw.

cancel() is like raising your hand to ask the runner to stop — they only stop if they occasionally look at you (the check inside the loop).

saying these in an interview costs you the question

  • Claiming cancel() forcibly kills the coroutine / interrupts the thread
  • Saying you must use Thread.stop() or interrupt()
  • Not knowing cancellation is cooperative
  • Thinking a CPU loop is automatically cancellable
  • Catching and swallowing CancellationException as a fix

context