Structured concurrency is what makes coroutines safe: no leaked work, predictable cancellation and error handling.
Structured concurrency means every coroutine runs inside a CoroutineScope, and a parent doesn't complete until all its children do. If a child fails, the parent is cancelled along with its other children, and the exception propagates up. If the parent is cancelled, all children are cancelled. Nothing leaks.
coroutineScope { } creates such a scope inside a suspend function. supervisorScope { } (or a SupervisorJob) changes the rule so one child's failure doesn't cancel its siblings, useful for independent tasks such as per-connection handlers.
Dispatchers decide which threads run coroutines:
Dispatchers.Default: CPU-bound work, threads equal to CPU cores.Dispatchers.IO: blocking I/O, a larger pool (64 threads by default).Dispatchers.Main: the UI thread on Android/desktop.- Switch with
withContext(Dispatchers.IO) { blockingCall() }, which suspends until the block finishes and returns its result.
Cancellation is cooperative. Suspending functions from kotlinx.coroutines (delay, withContext, channel operations) check for cancellation and throw CancellationException. CPU loops must check isActive or call ensureActive() / yield(). Use withTimeout(ms) or withTimeoutOrNull for deadlines. Never swallow CancellationException: rethrow it if you catch Exception broadly.
Avoid GlobalScope: it breaks structure. Long-lived components should own a scope (CoroutineScope(SupervisorJob() + Dispatchers.Default)) and cancel it when they shut down.
Example
import kotlinx.coroutines.*
suspend fun loadAll(): List<String> = coroutineScope {
val a = async { delay(100); "a" }
val b = async { delay(50); error("b failed") } // fails -> cancels a and the scope
listOf(a.await(), b.await())
}
fun main() = runBlocking {
runCatching { loadAll() }.onFailure { println("caught: ${it.message}") } // caught: b failed
val text = withContext(Dispatchers.IO) { // blocking work off the caller's thread
Thread.currentThread().name.startsWith("DefaultDispatcher")
}
println(text) // true
val result = withTimeoutOrNull(120) {
var i = 0
while (true) { ensureActive(); i++; delay(50) } // cooperative loop
}
println(result) // null (timed out)
supervisorScope {
val bad = launch(CoroutineExceptionHandler { _, e -> println("handled: ${e.message}") }) {
error("boom")
}
val good = launch { delay(50); println("sibling still ran") }
joinAll(bad, good) // handled: boom / sibling still ran
}
}Remember
- Children run inside their parent's scope; the parent waits for all of them
- A failing child cancels its siblings unless you use supervisorScope
- withContext(Dispatchers.IO) for blocking calls, Default for CPU work
- Cancellation is cooperative; never swallow CancellationException
Watch out for
- Launching work in GlobalScope, so it outlives the request or screen that started it.