Coroutines from the Ground Up
The Concurrency Problem Java Kept Trying to Solve
If you’ve spent any time in Java’s concurrency landscape, you’ve lived through the evolution: raw Thread objects, then ExecutorService pools, then CompletableFuture chains, and now Project Loom’s virtual threads. Each iteration addressed a real pain point — and each left something on the table.
// Java: The evolution of "do two things at once"
// Era 1: Raw threads — unstructured, resource-heavy
Thread t = new Thread(() -> fetchFromNetwork());
t.start();
t.join(); // Block the calling thread. Hope nothing goes wrong.
// Era 2: ExecutorService — pooled, but still callback-shaped
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<String> future = pool.submit(() -> fetchFromNetwork());
String result = future.get(); // Still blocking.
// Era 3: CompletableFuture — composable, but the syntax fights you
CompletableFuture.supplyAsync(() -> fetchUser(id))
.thenCompose(user -> fetchOrders(user.getId()))
.thenApply(orders -> summarize(orders))
.exceptionally(ex -> fallback(ex));
// Era 4: Virtual threads (Project Loom) — lightweight, but still threads
Thread.startVirtualThread(() -> {
var user = fetchUser(id); // Looks sequential
var orders = fetchOrders(user); // Runs on virtual thread
return summarize(orders);
});
Project Loom gets closest to the ideal. Virtual threads are cheap to create, the JVM handles the mounting/unmounting from carrier threads, and your code reads sequentially. So why do Kotlin coroutines exist at all?
The answer isn’t performance — it’s structure. Virtual threads solve the resource problem (you can have millions of them), but they don’t solve the lifecycle problem. When a virtual thread launches ten child tasks, there’s no built-in mechanism to say “if I’m cancelled, cancel all my children.” There’s no automatic propagation of failure from a child back to a parent. You’re still writing the orchestration logic yourself.
Kotlin coroutines attack both problems simultaneously: they’re lightweight and they enforce a parent-child hierarchy that makes cancellation and failure propagation automatic.
Coroutines Are Not Threads — They’re a Compiler Trick
Here’s the mental model shift you need: a coroutine is not a lightweight thread. It’s a compiler transformation that turns sequential-looking code into a state machine. The runtime then executes that state machine, potentially across different threads, suspending and resuming without blocking any of them.
When you mark a function with suspend, you’re telling the compiler: “This function may pause its execution and resume later.” The compiler responds by rewriting the function’s signature. What you write:
suspend fun fetchUser(id: String): User {
val response = httpClient.get("https://api.example.com/users/$id")
return response.body()
}
What the compiler produces (conceptually):
// Compiler-generated signature — note the Continuation parameter
Object fetchUser(String id, Continuation<? super User> continuation) {
// State machine implementation...
}
That Continuation<T> parameter is the key. It’s an interface with two methods:
public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>)
}
The continuation represents “what to do next” after this function completes. Instead of returning a value directly, the function can return a special sentinel COROUTINE_SUSPENDED to indicate “I’m not done yet — I’ll call continuation.resumeWith() when I have the result.”
This is Continuation Passing Style (CPS) — a technique from functional programming, applied at the compiler level. You write sequential code, and the compiler converts it into callback-passing code. No callback hell in your source, full callback machinery under the hood.
CoroutineContext: The Persistent Map
Every coroutine carries a CoroutineContext — think of it as an immutable map from Key types to Element values. When a coroutine launches a child, the child inherits the parent’s context (with possible overrides).
The four elements you’ll interact with most:
| Element | Key | Purpose |
|---|---|---|
Job | Job | Lifecycle handle — cancel, join, check status |
ContinuationInterceptor | ContinuationInterceptor | Determines which thread runs the coroutine (Dispatcher) |
CoroutineName | CoroutineName | Debug label for logging |
CoroutineExceptionHandler | CoroutineExceptionHandler | Last-resort handler for uncaught exceptions |
Contexts compose with the + operator. The right-hand side overrides matching keys:
val ctx = Dispatchers.IO + CoroutineName("data-loader") + SupervisorJob()
// IO dispatcher + debug name + supervisor semantics
This isn’t syntactic convenience — it’s how Kotlin makes coroutine behavior configurable without parameter explosion. Compare with Java’s ExecutorService, where changing the threading model requires constructing a different executor, and propagating a name or error handler requires threading it through your entire call chain manually.
Dispatchers: Mapping Coroutines to Thread Pools
A Dispatcher is a ContinuationInterceptor that controls which thread a coroutine runs on. The standard dispatchers map directly to thread pool configurations:
-
Dispatchers.Default— Backed by a thread pool sized toRuntime.getRuntime().availableProcessors(). Use for CPU-intensive work: JSON parsing, sorting, computation. Equivalent to Java’sForkJoinPool.commonPool(). -
Dispatchers.IO— Backed by an elastic thread pool (up to 64 threads by default, configurable viakotlinx.coroutines.io.parallelism). Use for blocking I/O: file reads, JDBC calls, network without async support. This is your escape hatch from “don’t block the coroutine.” -
Dispatchers.Main— The UI thread on Android/Swing. Not available in pure JVM server code unless you add aMaindispatcher implementation. -
Dispatchers.Unconfined— Starts in the caller’s thread, resumes in whatever thread the suspension completed on. Useful in tests, dangerous in production.
What Dispatchers.Default and Dispatchers.IO share that you might not expect: they share the same underlying thread pool. IO dispatches are allowed to exceed the core pool size, but they draw from the same CoroutineScheduler. This means switching between Default and IO can be a no-op if a thread is already available — no context switch overhead.
The State Machine Under the Hood
Consider this suspend function:
suspend fun loadUserData(userId: String): UserProfile {
val user = fetchUser(userId) // suspension point 1
val orders = fetchOrders(user.id) // suspension point 2
return UserProfile(user, orders)
}
The compiler transforms this into a state machine with three states (one per suspension point plus the initial entry). Each state corresponds to a segment of code between suspension points. The generated class extends ContinuationImpl and contains:
- A
labelfield tracking which state to execute next - Fields for every local variable that’s live across a suspension point
- A
invokeSuspend(result: Result<Any?>)method with awhen(label)dispatch
Decompiled to Java-like pseudocode:
Object loadUserData(String userId, Continuation<? super UserProfile> cont) {
// Create or reuse the state machine continuation
LoadUserDataContinuation sm;
if (cont instanceof LoadUserDataContinuation) {
sm = (LoadUserDataContinuation) cont;
} else {
sm = new LoadUserDataContinuation(cont);
}
switch (sm.label) {
case 0:
sm.label = 1;
Object result = fetchUser(userId, sm); // pass self as continuation
if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
// fall through if fetchUser completed synchronously
case 1:
User user = (User) sm.result;
sm.user = user; // save local variable
sm.label = 2;
Object result2 = fetchOrders(user.getId(), sm);
if (result2 == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
case 2:
List<Order> orders = (List<Order>) sm.result;
return new UserProfile(sm.user, orders);
}
}
Notice: no threads created, no callbacks in your source code, no CompletableFuture chains. The sequential flow you wrote becomes a resumable state machine. When fetchUser suspends, the entire call returns COROUTINE_SUSPENDED up the stack, freeing the thread. When the network response arrives, the dispatcher schedules the continuation’s resumeWith() on an available thread, and execution jumps to case 1.
This is the mechanical foundation that everything else builds on. The next two sections drill into the details: first, how the continuation object manages local state across suspension points; then, how Kotlin enforces structured concurrency through the Job hierarchy so that failure in one coroutine cascades predictably through the system.