Every coroutine launched with launch or async runs inside a CoroutineScope, and that scope forms a parent-child relationship with every coroutine it starts — a parent coroutine's own completion genuinely waits for all its children to finish first. Cancelling a scope (or its parent Job) cancels every child coroutine within it, cooperatively — a suspending function checks for cancellation at its suspension points and stops itself, rather than being forcibly killed. This structured relationship is precisely what prevents the class of bugs where a background task keeps running long after the code that started it has moved on or been abandoned.
coroutineScope: a parent that waits for every child
Both launched coroutines run concurrently, but coroutineScope itself doesn't return until BOTH have completed — the parent structurally cannot finish early and leave a child still running unattended.
suspend fun loadData() = coroutineScope { // a genuine parent scope
launch {
delay(1000)
println("First task done")
}
launch {
delay(500)
println("Second task done")
}
} // coroutineScope waits here until BOTH children have finished — guaranteedCancellation: cooperative, not forced
Cancellation isn't a forced kill — it works because delay (and other suspending functions) check for cancellation at their suspension points and throw a CancellationException there, which propagates up naturally, stopping the coroutine cooperatively rather than at an arbitrary instruction.
val job = CoroutineScope(Dispatchers.Default).launch {
repeat(1000) { i ->
println("Working: $i")
delay(100) // cancellation is checked HERE, at each suspension point
}
}
delay(300)
job.cancel() // cooperatively stops the loop the next time it hits delay()Cancelling a parent cancels every child
Cancelling the parent Job cancels both child coroutines automatically — there's no need to track and individually cancel each one, since structured concurrency propagates cancellation down the whole hierarchy on its own.
val parentJob = CoroutineScope(Dispatchers.Default).launch {
launch { delay(5000); println("Child 1 done") }
launch { delay(5000); println("Child 2 done") }
}
delay(100)
parentJob.cancel() // BOTH children are cancelled automatically — neither line ever prints