Part 3 · Cancellation & Exception Handling

Cooperative Cancellation

Coroutines are not forcefully killed — cancellation is cooperative. A coroutine must check for cancellation, either at a suspension point (most suspend functions check automatically) or manually in long CPU-bound loops. A loop that never suspends and never checks isActive will keep running even after cancellation.

Method Behaviour When to use
isActive Boolean check, no exception Conditional work, manual loop checks
ensureActive() Throws CancellationException if cancelled Fast failure when cancelled
yield() Suspends, checks cancellation, lets others run Long CPU-intensive loops

Cooperative cancellation in a loop

1
2
3
4
5
6
7
8
9
// Bad -- never checks, so cancellation is ignored
launch {
    while (true) { /* expensive work, never stops */ }
}

// Good -- checks isActive, stops promptly on cancel
launch {
    while (isActive) { /* expensive work */ }
}

ensureActive() and yield() (from the manuscript)

1
2
3
4
5
6
7
8
9
10
11
12
13
// ensureActive() -- throws if cancelled; fast-failure pattern
repeat(1000) {
    ensureActive()
    doWork()
}

// yield() -- throws CancellationException if cancelled, AND
//            suspends to let other coroutines run. Ideal for
//            CPU-intensive loops that must stay cooperative.
repeat(10000) {
    yield()
    heavyWork()
}

Always re-throw CancellationException

CancellationException is used internally to implement cancellation. If you catch it in a broad catch (e: Exception) and swallow it, you break the cancellation machinery. Always re-throw it after any cleanup.

Correct exception handling around cancellation

1
2
3
4
5
6
7
8
9
10
launch {
    try {
        doWork()
    } catch (e: CancellationException) {
        cleanup()
        throw e            // MUST re-throw -- never swallow
    } catch (e: Exception) {
        handleError(e)     // handle real errors here
    }
}

CoroutineExceptionHandler — Critical Rules (from the manuscript)

CoroutineExceptionHandler is a last-resort mechanism for uncaught exceptions, and it works only in very specific situations.

Works with Does NOT work with
Root coroutines launched with launch async builder (exception lives in the Deferred)
Coroutines in supervisorScope Child coroutines (they delegate to the parent)
A handler installed in the CoroutineScope Non-root coroutines

Where the handler works and where it doesn’t

1
2
3
4
5
6
7
8
9
val handler = CoroutineExceptionHandler { _, e -> Log.e("TAG", "Caught: $e") }

// WORKS -- handler on a root launch
GlobalScope.launch(handler) { throw Exception() }   // caught

// DOES NOT WORK -- async stores the exception in the Deferred
val deferred = GlobalScope.async(handler) { throw Exception() }  // handler never called
// Correct way for async:
try { deferred.await() } catch (e: Exception) { /* handle here */ }

Mutex — Mutual Exclusion (from the manuscript)

A Mutex is a lock that ensures only one coroutine accesses a shared resource at a time, avoiding race conditions. Unlike a thread lock, acquiring it suspends rather than blocks. The idiomatic form is withLock, which acquires and releases automatically — even if the body throws or is cancelled.

Mutex with withLock

1
2
3
4
5
6
7
8
val mutex = Mutex()

launch {
    mutex.withLock {
        // only one coroutine at a time runs here
        sharedCounter++
    }   // lock released automatically, even on exception/cancellation
}

Atomic start — guaranteeing the finally block runs

A subtle but important manuscript note: when you must guarantee a critical cleanup (such as unlocking a Mutex) even if the coroutine is cancelled before it starts, launch it with start = CoroutineStart.ATOMIC. This guarantees the body begins executing even if cancellation arrives first, so your finally block runs and the Mutex is released.

CoroutineStart.ATOMIC ensures finally runs

1
2
3
4
5
6
7
8
9
mutex.lock()
val job = lifecycleScope.launch(start = CoroutineStart.ATOMIC) {
    try {
        // executes even if the activity is destroyed first
        doSomething()
    } finally {
        mutex.unlock()   // guaranteed to run, releasing the lock
    }
}

Previous: Coroutine Builders · ↑ Chapter Index · Next: Flow →


This site uses Just the Docs, a documentation theme for Jekyll.