Structured concurrency and cancellation
This is the lesson that makes coroutines safe, not just convenient — and it is the one that separates people who use coroutines from people who understand them. Structured concurrency is the principle that coroutines are organised in a hierarchy where a parent cannot finish until its children do, and cancelling a parent cancels its children. It also covers cancellation and the race condition on shared state that every concurrency learner must see with their own eyes.
Structured concurrency: children belong to parents
When you launch a coroutine inside another coroutine (or inside a scope), the new one becomes a child of the enclosing one. This creates a tree, and the tree has two guarantees that eliminate a whole class of bug:
suspend fun loadDashboard() = coroutineScope { // a scope for the children
val user = async { fetchUser() } // child 1
val orders = async { fetchOrders() } // child 2
println("${user.await()}, ${orders.await()}")
} // coroutineScope does NOT return until BOTH children finish
- A parent waits for its children.
coroutineScope { }(andrunBlocking) does not complete until every coroutine launched inside it has finished. You cannot accidentally leave background work running after the function that started it returned — the structure prevents it. - Cancelling a parent cancels its children. Cancel the scope and every child stops. No orphaned coroutines, no leaks.
This is structured concurrency because the concurrency follows the structure of your code: a block that starts coroutines owns them for its duration. Contrast the old world, where you fired off a thread and lost track of it — it could outlive everything, crash later, or leak forever. Structured concurrency makes "start some concurrent work, wait for all of it, and clean up if anything goes wrong" the default, not something you have to remember to do. It is the single most important idea in the module.
coroutineScope and error propagation
coroutineScope { } creates a scope for a group of related coroutines with one more crucial
property: if any child fails, the others are cancelled and the exception propagates. If
fetchUser throws, fetchOrders is cancelled and loadDashboard throws — you do not end up with
one task silently failed and another still running. This "all succeed or the whole thing fails
cleanly" behaviour is exactly what you want for a group of related operations, and you get it for
free from the structure.
Cancellation — stopping work cleanly
Coroutines are cancellable, which is how a scope can stop its children. But cancellation is cooperative — a coroutine has to check whether it has been cancelled, at suspension points:
val job = launch {
repeat(1000) { i ->
println("Working on $i")
delay(100) // a suspension point — cancellation is checked here
}
}
delay(350)
job.cancel() // request cancellation
job.join() // wait for it to actually stop
println("Cancelled after ~3 iterations")
The coroutine stops around iteration 3, because delay is a suspension point where cancellation
is checked and, if requested, throws a CancellationException that unwinds the coroutine cleanly.
Every suspend function from the library (delay, withContext, await) is a cancellation point.
The catch that catches people: a coroutine doing tight CPU work with no suspension points cannot be
cancelled, because it never checks. A while (true) { heavyCompute() } with no delay or
yield() ignores cancellation entirely. The fix is to check cooperatively — call yield()
periodically, or check isActive — so long-running computation can be stopped. This is a real gotcha
worth remembering: cancellation is not something done to a coroutine; it is something the coroutine
must cooperate with.
The race condition — see it break
Now the demonstration every concurrency learner must witness. When multiple coroutines on multiple
threads modify the same variable, updates get lost — because counter++ is not one atomic step but
three (read, add, write), and two coroutines can interleave. Here is the code, run for real:
var counter = 0
withContext(Dispatchers.Default) { // a multi-thread pool
List(100) { launch { repeat(1000) { counter++ } } }.joinAll()
}
println("Expected 100000, got: $counter")
A hundred coroutines each add 1,000, so the answer should be 100,000. The actual output from one run:
Expected 100000, got: 32789
It lost roughly two-thirds of the updates. Not a crash, not an error — just a silently wrong number, different every run. This is a race condition, and it is the defining bug of concurrent programming: shared mutable state modified without coordination. On a phone it might be a wrong balance, a lost tap, a corrupted list. You have to see this to respect it — reading that "it can happen" is not the same as watching a counter lose 67,000 increments.
Fixing the race
Two correct fixes, both verified to give exactly 100,000:
// 1. AtomicInteger — the increment is a single atomic hardware operation
val atomic = AtomicInteger(0)
List(100) { launch { repeat(1000) { atomic.incrementAndGet() } } }.joinAll()
// -> 100000, always
// 2. A Mutex — only one coroutine holds the lock at a time
val mutex = Mutex()
var guarded = 0
List(100) { launch { repeat(1000) { mutex.withLock { guarded++ } } } }.joinAll()
// -> 100000, always
AtomicIntegermakes the increment a single indivisible operation the hardware guarantees — no interleaving possible. Right for a counter.Mutex(withLock) lets only one coroutine into the critical section at a time — right when several statements must happen together as a unit. (This is the coroutine-friendly lock; it suspends rather than blocks while waiting.)
The deeper lesson, and the best fix of all: avoid shared mutable state. The reason the collection
operations and val and immutable data were pushed so hard earlier is that immutable data cannot
have a race condition — there is nothing to modify concurrently. When you must share mutable state
across coroutines, guard it (atomic or mutex); when you can avoid it, avoid it.
Check your work
What structured concurrency means. Coroutines form a parent-child tree; a parent waits for its children, and cancelling a parent cancels its children.
What coroutineScope { } guarantees. It does not return until all children finish; if any child
fails, the others are cancelled and the exception propagates.
Why structured concurrency matters. It makes "wait for all concurrent work and clean up on failure" the default, preventing leaked and orphaned coroutines.
Why cancellation is cooperative. A coroutine is cancelled only at suspension points where it
checks; library suspend functions (delay, await) are cancellation points.
The cancellation gotcha. Tight CPU work with no suspension points cannot be cancelled — add
yield() or check isActive.
What a race condition is. Shared mutable state modified concurrently without coordination — e.g.
counter++ interleaving and losing updates.
What the broken counter demonstrated. ~100 coroutines lost roughly two-thirds of 100,000 increments — a silently wrong number, not a crash.
Two fixes for shared-state races. AtomicInteger (atomic increment) and Mutex/withLock
(one coroutine in the critical section at a time).
The best fix of all. Avoid shared mutable state — immutable data cannot race.
Practice
- Launch two
asyncchildren insidecoroutineScopeand confirm the scope waits for both. - Make one child throw and confirm the other is cancelled and the exception propagates.
- Launch a coroutine that loops with
delay, cancel it partway, and confirm it stops around the expected iteration. - Write a tight loop with no suspension point, try to cancel it, and observe it ignores
cancellation. Fix it by adding
yield(). - Run the broken counter (100 coroutines × 1,000 increments on
Dispatchers.Default) several times. Record the wrong numbers and note they differ each run. - Fix it with
AtomicIntegerand confirm you always get 100,000. Then fix it with aMutexand confirm the same. - Redesign a piece of shared-mutable-state code so there is no shared mutable state (each coroutine returns a value, and you combine the results). Note that the race is now impossible.
Official documentation
- Kotlin — Coroutines and structured concurrency — The parent-child guarantees.
- Kotlin — Cancellation and timeouts — Cooperative cancellation and the CPU-loop gotcha.
- Kotlin — Shared mutable state and concurrency — The race condition, atomics, and
Mutex. - Kotlin — Composing suspending functions: structured concurrency with async —
coroutineScopeand error propagation.
Next: Flow — a stream of values over time.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship