Inside CoroutineContext: Job, dispatcher, and carrying an MDC
A CoroutineContext is an immutable set keyed by type, `+` is set union with right-hand override, and every confusing thing about scopes falls out of those two facts.
Explore the full explanation 8 ideas
A `CoroutineContext` is an immutable, indexed set of `Element`s, each identified by a `Key`. `Dispatchers.IO`, a `Job`, a `CoroutineName`, a `CoroutineExceptionHandler` are all elements, and `context[Key]` looks one up by type. The `+` operator is set union where the right-hand side wins on a key collision: `Dispatchers.IO + CoroutineName("import")` is a two-element set, and `ctx + Dispatchers.Default` replaces whatever dispatcher `ctx` had. Once you hold that picture, the API stops looking like magic and starts looking like a `Map<KClass, Element>` with an operator.
Context is **inherited by children**. `launch { }` takes the current scope's context, applies any overrides you passed, and — critically — installs a **fresh `Job`** that is a child of the current one. That single substitution is structured concurrency: the parent knows its children because they hold its Job as their parent, cancellation walks down the tree, and `coroutineScope` can wait for all of them without you registering anything. Everything else in the context is inherited unchanged.
The `Job` is the piece worth knowing in detail. It has a lifecycle — *new → active → completing → completed*, with *cancelling → cancelled* branching off — exposed as `isActive`, `isCompleted` and `isCancelled` (note that a cancelled Job reports `isCompleted == true` once it has finished unwinding, which surprises people writing status checks). `job.children` walks the tree. `job.invokeOnCompletion { cause -> … }` is a non-suspending callback on every terminal path, which makes it a better place for bookkeeping than a `finally` that has to fight cancellation. And `SupervisorJob` differs in exactly one behaviour: a child's failure does not propagate upward.
The dispatcher is a `ContinuationInterceptor`, which is a context element like any other — it intercepts each resumption and decides which thread runs it. `Dispatchers.Default` is a pool sized to the CPU count, for computation. `Dispatchers.IO` is a much larger elastic pool that shares threads with `Default`, for blocking calls. `Dispatchers.Unconfined` resumes on whatever thread completed the previous suspension and is for tests and edge cases, not production. `limitedParallelism(n)` carves a view of a dispatcher with a hard concurrency cap, which is how you give one subsystem a slice of `IO` without giving it all of `IO`.
`withContext(ctx) { … }` is the tool for a *temporary* override. It suspends, runs the block with the merged context, and returns the block's value — so `withContext(Dispatchers.IO) { jdbcQuery() }` moves one blocking call off the event loop and comes straight back. It is not a coroutine builder in the `launch`/`async` sense: it does not fork, and it returns a value rather than a `Job`. Reaching for `launch` where you meant `withContext` is how sequential code accidentally becomes fire-and-forget.
`CoroutineScope` is nothing but a holder for a context — literally an interface with one `coroutineContext` property. `CoroutineScope(SupervisorJob() + Dispatchers.Default + handler)` builds one; `viewModelScope` and `lifecycleScope` are pre-built ones tied to a lifecycle. That is why the guidance "do not use `GlobalScope`" is not stylistic: `GlobalScope` has no `Job` you own, so nothing cancels its children and every coroutine launched there outlives the thing that started it.
The part that trips up server code is thread-local state. A coroutine can resume on a different thread from the one it suspended on, so an SLF4J `MDC`, a Spring `SecurityContext` or any `ThreadLocal` set before a suspension is simply not there afterwards. `ThreadContextElement` is the mechanism that fixes it: it is a context element with `updateThreadContext`/`restoreThreadContext` hooks that the runtime calls around every resumption, so the thread-local is re-installed wherever the coroutine lands. `kotlinx-coroutines-slf4j` ships `MDCContext` built on exactly that, and `withContext(MDCContext()) { … }` is how a correlation id survives an `await`.
Two habits make context work for you rather than against you. **Name your coroutines**: `CoroutineName("order-import")` costs nothing, shows up in stack traces and in debug-mode thread names, and turns an anonymous hang into a labelled one. **Inject your dispatchers** rather than hard-coding them: a class that takes a `CoroutineDispatcher` parameter can be tested with `StandardTestDispatcher` and its virtual clock, while one that calls `Dispatchers.IO` directly cannot — the test will really sleep, really race, and really flake.
Let’s figure it out.
Ask me about this course or where to start.
import kotlinx.coroutines.*import kotlin.coroutines.ContinuationInterceptorfun main() = runBlocking {val base = Dispatchers.Default + CoroutineName("import")println("name = " + base[CoroutineName])println("dispatcher = " + base[ContinuationInterceptor])// Right-hand side wins on a key collision.val overridden = base + CoroutineName("export")println("overridden = " + overridden[CoroutineName])withContext(base) {val parentJob = coroutineContext[Job]launch(CoroutineName("child")) {val childJob = coroutineContext[Job]println("child name = " + coroutineContext[CoroutineName])// Inherited unchanged...println("same disp. = " + (coroutineContext[ContinuationInterceptor] === base[ContinuationInterceptor]))// ...except the Job, which is a fresh CHILD of the parent's.println("same job = " + (childJob === parentJob))println("is a child = " + (parentJob?.children?.contains(childJob) ?: false))}.join()}}
The context is a set: + is union with right-hand override, and a child inherits everything except the Job, which is replaced by a fresh child Job.
Arena IDEimport kotlinx.coroutines.*suspend fun blockingLookup(id: Int): String {Thread.sleep(10) // a genuinely blocking callreturn "row-" + id}fun main() = runBlocking {// Offload the blocking call, get the value back, carry on sequentially.val row = withContext(Dispatchers.IO) { blockingLookup(1) }println("withContext returned " + row)// launch does NOT return the value — it returns a Job, and the code after// it runs immediately.var captured: String? = nullval job = launch(Dispatchers.IO) { captured = blockingLookup(2) }println("right after launch, captured = " + captured)job.join()println("after join, captured = " + captured)}
withContext is a temporary override that returns a value; launch forks a child that returns a Job. Confusing the two is how sequential code becomes fire-and-forget.
Arena IDEimport kotlinx.coroutines.*import java.util.concurrent.atomic.AtomicInteger@OptIn(ExperimentalCoroutinesApi::class)fun main() = runBlocking {suspend fun measurePeak(dispatcher: CoroutineDispatcher): Int {val inFlight = AtomicInteger(0)val peak = AtomicInteger(0)coroutineScope {repeat(24) {launch(dispatcher) {val now = inFlight.incrementAndGet()peak.updateAndGet { if (now > it) now else it }Thread.sleep(20) // hold the thread, so the cap showsinFlight.decrementAndGet()}}}return peak.get()}val capped = Dispatchers.IO.limitedParallelism(4)println("limitedParallelism(4) peak = " + measurePeak(capped))println("(a hard ceiling, so one subsystem cannot starve the rest of IO)")}
limitedParallelism carves a capped view of a dispatcher — one subsystem gets a slice of IO instead of all of it, and the cap is observable.
Arena IDEimport kotlinx.coroutines.*val requestId = ThreadLocal<String?>()fun main() = runBlocking {requestId.set("req-42")// Plain: the value is gone the moment we resume on another thread.withContext(Dispatchers.Default) {println("without element: " + requestId.get())}// As a context element: re-installed around every resumption, on whatever// thread the coroutine lands on.withContext(Dispatchers.Default + requestId.asContextElement("req-42")) {println("with element: " + requestId.get())delay(10) // suspend, maybe move threadprintln("after suspend: " + requestId.get())}// In a Spring app this is MDCContext from kotlinx-coroutines-slf4j, which// is the same ThreadContextElement machinery applied to SLF4J's MDC.}
A ThreadLocal does not survive a suspension, because the coroutine can resume on another thread. asContextElement()/ThreadContextElement is the fix — and MDCContext is that mechanism packaged for logging.
Arena IDE🧠 Check your understanding
0/1 · 0/1 answered1. Inside a suspend function you set an SLF4J MDC value, then call `awaitSingle()` on a WebClient response, then log. The correlation id is missing from the second log line. Why?