Kotlin path · Coroutines from the inside · Topic 12 of 13

Structured concurrency, cancellation, dispatchers

Not started

A coroutine started with launch or async doesn't float free: it is a child of the CoroutineScope it was started in. **Structured concurrency** is the rule that ties the two together. The parent can't complete until its children have, cancelling the parent cancels the children, and an exception in a child is reported to the parent instead of disappearing. The Android path shows this with viewModelScope; this topic is about the rules themselves.

Why it matters: Coroutines are cheap to start and easy to leak. Scopes, Jobs and the cancellation rules are what make sure every coroutine ends, and that a failure isn't silently lost.

  1. 1Every coroutine has a parent

    launch and async create a child Job of the scope they are called in; the parent finishes only when all children have.

  2. 2Failure cancels the family

    By default a failing child cancels its parent and siblings; supervisorScope and SupervisorJob stop that spreading.

  3. 3Cancellation is cooperative

    Cancelling sets a flag and makes suspending calls throw CancellationException; plain code must check isActive or call ensureActive().

  4. 4Dispatchers pick threads

    Default for CPU work, IO for blocking calls, Main for UI; withContext switches and comes back.

1

How it works

The explanation, with examples you can run.

Scopes and Jobs

Every coroutine has a Job in its context. Starting a coroutine inside a scope makes its Job a child of the scope's Job, so you get a tree. coroutineScope { } creates a scope inside a suspend function and suspends until every child is done:

import kotlinx.coroutines.* suspend fun price(id: Int): Int { delay(100L * id); return id * 10 } suspend fun total(ids: List<Int>): Int = coroutineScope {    ids.map { id -> async { price(id) } }     // all run concurrently        .awaitAll()        .sum()} fun main() = runBlocking {    println(total(listOf(1, 2, 3)))            // 60, after about 300 ms, not 600    val failing = runCatching {        coroutineScope {            launch { delay(1_000); println("never printed") }            launch { delay(50); error("boom") }        }    }    println(failing.exceptionOrNull()?.message)  // boom: the slow sibling was cancelled}

By default a child that fails with an exception cancels its parent, which cancels all the other children; then the exception surfaces from coroutineScope (or reaches the scope's CoroutineExceptionHandler, for a top-level launch). Where siblings should be independent, use supervisorScope { } or a scope built with SupervisorJob(): a failing child then fails alone. async keeps its exception until await() is called, but in a normal scope the failure still cancels the parent immediately.

Cancellation is cooperative

job.cancel() doesn't stop a thread. It marks the Job cancelled, and the next suspending call that checks (delay, yield, withContext, most kotlinx.coroutines functions) throws CancellationException. Code that never suspends has to check for itself:

import kotlinx.coroutines.* fun main() = runBlocking {    val job = launch(Dispatchers.Default) {        var n = 0L        try {            while (true) { ensureActive(); n++ }          // without ensureActive this never stops        } finally {            withContext(NonCancellable) { println("cleanup after $n iterations") }        }    }    delay(50)    job.cancelAndJoin()    println("cancelled: ${job.isCancelled}")}
  • CancellationException is how cancellation travels, so don't swallow it with a broad catch (e: Exception); rethrow it.
  • finally blocks run on cancellation. Suspending inside them needs withContext(NonCancellable), because the coroutine is already cancelled.
  • withTimeout(ms) { } cancels its block when time runs out and throws TimeoutCancellationException; withTimeoutOrNull returns null instead.

Dispatchers

The dispatcher in a coroutine's context decides which thread runs it between suspensions:

  • Dispatchers.Default: a pool sized to the CPU cores, for computation.
  • Dispatchers.IO: an elastic pool (64 threads by default, or more on large machines) for blocking calls. It shares threads with Default.
  • Dispatchers.Main: the UI thread on Android, Swing or JavaFX; it needs the platform artifact.
  • dispatcher.limitedParallelism(n): a view of a pool that runs at most n coroutines at once, useful to protect a resource.

withContext(Dispatchers.IO) { ... } runs a block on another dispatcher and returns its result to the original one. Avoid GlobalScope and runBlocking in production code paths: the first escapes structured concurrency, the second blocks a thread to wait.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.Inside coroutineScope { }, two async calls run in parallel and the first throws. What happens to the second?
  2. 2.Why is catch (e: Exception) { } around a suspending call inside a loop a problem for cancellation?
  3. 3.Where should a suspend function that reads a file with java.io switch threads?

0 of 3 right so far. Get all three to finish this topic.