Phase 6 · Coroutines & AsyncModule 31~38 min read

Coroutine Context, Dispatchers & Scopes

Control where and how coroutines run with dispatchers and scopes.

What you'll learn

A coroutine always runs in a context — a bundle of information that includes which thread it uses and which scope owns it. Master context, dispatchers, and scopes and you control exactly where your async work runs and how its lifecycle is managed.

By the end you'll be able to:

  • Understand CoroutineContext and CoroutineScope
  • Pick the right dispatcher for CPU, I/O, and UI work
  • Switch threads cleanly with withContext
  • Compose concurrent work with coroutineScope and supervisorScope

Context & scope

A CoroutineContext is a set of elements — chiefly a Job (the lifecycle handle) and a dispatcher (the thread policy). A CoroutineScope is simply something that holds a context and defines a boundary in which coroutines live. Every coroutine you launch is a child of a scope, and that parent-child link is what makes coroutines manageable rather than chaotic.

Key idea

The golden rule: a coroutine outlives neither its scope nor its parent. Cancel the scope and every coroutine inside it is cancelled too. This is called structured concurrency, and it's what keeps async Kotlin free of leaked, runaway background tasks.

Dispatchers

A dispatcher decides which thread (or pool) a coroutine runs on. Passing one to a builder overrides the inherited context:

Dispatchers.kt
import kotlinx.coroutines.*

fun main() = runBlocking {
    launch(Dispatchers.Default) {   // CPU-bound work
        println("Default: ${Thread.currentThread().name}")
    }
    launch(Dispatchers.IO) {        // network / disk I/O
        println("IO:      ${Thread.currentThread().name}")
    }
    launch {                        // no dispatcher: inherits the caller's
        println("Inherit: ${Thread.currentThread().name}")
    }
}
DispatcherBacked byUse it for
Dispatchers.Defaultshared CPU poolCPU-intensive work: parsing, sorting, math
Dispatchers.IOlarge elastic poolblocking I/O: network, files, databases
Dispatchers.Mainthe UI threadupdating UI (Android/Compose); needs a UI library
Dispatchers.Unconfinedthe current threadrare / advanced — usually avoid

Switching with withContext

withContext runs a block on a different dispatcher and suspends until it returns — no callback, no manual thread juggling. It's the idiomatic way to move a blocking call off the main thread and bring the result back:

WithContext.kt
import kotlinx.coroutines.*

// Do the blocking read on IO, then hand the result back to the caller.
suspend fun loadData(): String = withContext(Dispatchers.IO) {
    delay(200L)                     // pretend: a slow disk read
    "data from disk"
}

fun main() = runBlocking {
    println("start on ${Thread.currentThread().name}")
    val data = loadData()           // switches to IO and back automatically
    println("got: $data")
}

Tip

withContext returns a value; launch/async start new coroutines. Reach for withContext(Dispatchers.IO) around a blocking call, not a fresh launch — it's simpler and keeps the result in-line.

coroutineScope & supervisorScope

coroutineScope creates a scope that waits for all its children and, if any child fails, cancels the siblings and rethrows. It's the building block for "do these things concurrently, then combine them":

Scope.kt
import kotlinx.coroutines.*

suspend fun loadUser(): String { delay(100L); return "Ada" }
suspend fun loadFeed(): String { delay(100L); return "3 posts" }

// coroutineScope waits for ALL children; if one fails, it cancels the rest.
suspend fun loadDashboard(): String = coroutineScope {
    val user = async { loadUser() }
    val feed = async { loadFeed() }
    "${user.await()} — ${feed.await()}"
}

fun main() = runBlocking {
    println(loadDashboard())
}

supervisorScope is the same but children fail independently — one crashing sibling doesn't take the others down. Use it when tasks are unrelated:

Supervisor.kt
import kotlinx.coroutines.*

fun main() = runBlocking {
    supervisorScope {
        val a = launch {
            throw RuntimeException("A failed")   // does NOT cancel b
        }
        val b = launch {
            delay(100L)
            println("B finished fine")
        }
    }
}

Recap & quick check

Key takeaways

  • A CoroutineContext bundles a Job (lifecycle) and a dispatcher (thread policy); a CoroutineScope owns a context.
  • Structured concurrency: a coroutine never outlives its scope — cancel the scope and children cancel too.
  • Dispatchers.Default is for CPU work, Dispatchers.IO for blocking I/O, Dispatchers.Main for UI.
  • withContext switches dispatcher for a block and returns its result — the clean way off the main thread.
  • coroutineScope cancels siblings when one fails; supervisorScope lets children fail independently.

Quick check

1. Which dispatcher is best for a network request?

2. What does withContext(Dispatchers.IO) { ... } do?

3. In coroutineScope, what happens if one child coroutine throws?

4. What is structured concurrency?

5. When would you choose supervisorScope over coroutineScope?

You now steer where coroutines run. Next we'll go deeper into their lifecycles — structured concurrency, cancellation, and error handling — the skills that keep async code correct under pressure.