
AI-generated image with Android robot, used for illustrative/editorial purposes.
Related Droidcon London — YouTube Video
1. Introduction
Coroutines make asynchronous code feel synchronous, which allows for a much more intuitive way of writing code by eliminating callbacks and allowing exceptions to be handled as if you were writing regular code.
In the Android world, we know that each application runs in its own instance of the Android Runtime (ART).
ART is Android’s managed runtime that executes DEX bytecode (produced from JVM bytecode by D8/R8). Given that, we understand that Kotlin code translates to Java bytecode. However, Java does not have the concept of coroutines. So how do coroutines (which don’t exist in the Java world) translate to Java bytecode?
Beneath the elegant syntax, suspending functions hide a powerful state machine transformation that the Kotlin compiler generates.
This transformation can feel magical, so our goal here is to:
- Introduce Finite State Machines (FSMs) as a conceptual tool.
- Show how coroutines leverage labels and continuations to suspend and resume execution.
- Illustrate these concepts with examples, including async/await for concurrency.
- Clarify how cancellation and exceptions flow through the generated state machine.
By the end of this article, you’ll understand how coroutines “pause” and “resume” seamlessly at the JVM level, preserving local variables and continuing right where they left off, often on a different dispatcher.
About this article:
This deep dive builds on the original “Coroutine Suspension Mechanics” article, which covers the same material, but in a more concise way.
Use this deep dive for comprehensive explanations with detailed examples and Android patterns, or use the original as a quick reference when you need a refresher.
1.a. Coroutines Beyond Kotlin: A Mobile Perspective
Before we dive deep into Kotlin’s implementation, it’s worth noting that compiler-based transformation of asynchronous code is not unique to Kotlin.
Modern mobile frameworks use similar approaches:
- Swift: Async functions with compiler-generated state machines (Swift 5.5+)
- Flutter/Dart: Async functions compiled to state machines
- React Native: JavaScript standard
async/await
The shared foundation:
“All of these approaches rely on compiler-generated state machines. ”
When you write async/await in Swift or JavaScript, or suspend functions in Kotlin, the compiler performs similar transformations (converting your sequential code into state machines that can pause and resume).
What makes Kotlin coroutines distinctive?
While the core mechanism is shared, Kotlin’s implementation has specific characteristics that make it particularly well-suited for Android:
Structured concurrency enforcement: Kotlin enforces parent-child relationships through its Job hierarchy, ensuring that child coroutines cannot outlive their parents. This prevents common resource leaks.
Explicit suspend marking: The suspend modifier is part of the function’s type signature, making it impossible to call a suspending function from non-suspending code without explicit handling. This prevents accidentally blocking threads.
Seamless exception propagation: Try-catch blocks work naturally across suspension points, with exceptions propagating up the coroutine hierarchy according to structured concurrency rules.
Context propagation: CoroutineContext automatically flows through the call chain, carrying dispatcher, job, and exception handler information without manual threading.
Android lifecycle integration: Android’s viewModelScope and lifecycleScope tie coroutine lifecycle to component lifecycle, providing automatic cleanup.
For Android developers, understanding these mechanisms is key to writing robust asynchronous code. This article will explore how the Kotlin compiler implements these features through state machine transformations.
The Java Situation
Java has no native support for suspendable functions, even as of Java 21. While the JVM can run coroutines (Kotlin proves this), Java “the language” doesn’t provide the syntax or compiler support.
Java’s Project Loom? — Java’s new “Virtual Threads” are not coroutines
Java’s Virtual Threads (GA in Java 21) solve asynchronous programming differently. Instead of compiler-generated state machines, Loom uses runtime thread management, multiplexing many lightweight threads onto a small pool of carrier threads. Methods still block logically; the blocking is just made cheap through efficient scheduling.
This is a fundamentally different approach: Kotlin uses compile-time transformation with explicit suspension points, while Loom uses runtime scheduling with implicit suspension. Both solve similar problems through different mechanisms.
Why This Matters for Android Developers
Most Android developers learned Java first, where threading was explicit: Thread, AsyncTask (old schools, you remember it?), ExecutorService. When a Java method returns, it’s done, stack frame destroyed, locals gone.
Coroutines break this model:
// Java - explicit, obvious
executor.execute(() -> {
String data = networkCall(); // Blocks thread
runOnUiThread(() -> updateUI(data));
});
// Kotlin - looks sequential, but isn't
suspend fun fetchData() {
val data = networkCall() // Suspends, doesn't block
updateUI(data) // Resumes on correct thread
}
The Kotlin version looks synchronous but performs complex thread management invisibly. For Java-trained developers, this feels like magic.
Let’s demystify how the compiler transforms your sequential-looking code into efficient state machines.
2. A Primer on Finite State Machines (FSM)
Understanding FSMs is absolutely crucial because they are the conceptual model that the Kotlin compiler uses to implement coroutines.
2.a. Finite State Machine (FSM)
A Finite State Machine (FSM) is a mathematical model that describes how a system moves through different states in response to inputs or events.
Think of an FSM as a system that can only be in one state at a time, chosen from a known, fixed set of possible states. The machine transitions from one state to another based on specific conditions or events, following predetermined rules.
This deterministic behavior (knowing exactly which state comes next) is what makes FSMs both predictable and analyzable.
An FSM consists of:
- A finite set of states: Distinct configurations like
STATE_0,STATE_1,STATE_2. The term “finite” is crucial. It means we can count and enumerate all states at compile-time. - Transitions between states: Rules defining how the machine moves from one state to another, triggered by inputs or events.
- A starting state: Where execution begins, typically
STATE_0. - Input/events: External triggers causing state transitions.
- Actions: Operations performed during transitions or within states.
A Simple FSM Example: The Traffic Light
To make FSMs concrete, let’s visualize something familiar: “a traffic light”
This is a perfect FSM example because it has clear states, obvious transitions, and deterministic behavior. Everyone understands how a traffic light works, which makes it an excellent demonstration subject.
Here’s how we’d model a traffic light as a Finite State Machine:
States: {RED, YELLOW, GREEN}
Starting state: RED
Transitions:
RED → GREEN (after 30 seconds)
GREEN → YELLOW (after 25 seconds)
YELLOW → RED (after 5 seconds)
Let’s trace through the FSM’s execution to see how it behaves over time:
- Time 0: The Traffic light starts in the
REDstate. Cars are stopped. A 30-second timer begins. - Time 30: Timer expires (input event). Transition
RED → GREEN. Cars can now go. A 25-second timer begins. - Time 55: Timer expires. Transition
GREEN → YELLOW. Cars should slow down. A 5-second timer begins. - Time 60: Timer expires. Transition
YELLOW → RED. Cars must stop. Back to the beginning of the cycle.
Notice the key properties of this FSM:
- At any moment, the light is in exactly ONE state — It can’t be both red and green simultaneously.
- Transitions are deterministic —
REDalways goes toGREEN, never toYELLOW. - Inputs drive transitions — The timer expiring causes state changes.
- The cycle is predictable — We can predict the entire sequence of states ahead of time.
This is exactly how coroutine state machines work, just with different states (code regions) and different inputs (completion of async operations).
2.b. FSMs in Coroutines: The Perfect Match
Now let’s map the FSM concepts to coroutines. The elegance of this mapping is what makes the FSM model so powerful for understanding coroutine internals. Every element of an FSM has a natural, intuitive counterpart in the coroutine world.
Here’s the complete correspondence between FSM theory and coroutine implementation:
- A finite set of states, such as
STATE_0,STATE_1,STATE_2, and so on. The word “finite” is critical. We must be able to count and enumerate all possible states at design time. A coroutine with three suspension points will have exactly four states (initial plus one after each suspension), no more, no less. - Transitions between states, which define how the machine moves from one state to another based on the inputs it reads or events that occur. In a coroutine, suspension points act as transitions. When you call a suspend function, you transition from the current state to a “waiting” configuration, and when the operation completes, you transition again to the next state.
- A starting state. Every FSM needs a well-defined beginning point. This is where execution starts, typically called the initial state or
STATE_0. For coroutines, this is the beginning of your suspend function, before any suspensions have occurred. - Input/events. These are the external triggers that cause state transitions. In a traffic light FSM, the input might be a timer expiring. In a coroutine FSM, the input is the completion of an asynchronous operation. A network response arriving, a delay expiring, or a database query completing.
- Actions. While transitioning between states or while residing in a state, the FSM can perform actions. In a coroutine, the actions are your actual business logic, that is, the code between suspension points that processes data, performs calculations, and makes decisions.
- (Optional) Accepting/final states. In some FSMs, certain states indicate successful completion. For coroutines, this is the state where your function returns its final value or throws an exception.
Key point:
In the coroutine world, each suspension point is a potential transition, and each code region between suspensions is a distinct state.
Let’s see this mapping in action with a concrete example that illustrates each element:

Note the arrows indicating suspension points in the code
We know that delay() is a suspend function, and let’s assume that fetchData() and processData() are also suspend functions… That would give us those curly arrows that indicate suspension points…
Let’s trace through the FSM execution:
- State 0 (Initial): Execute
println("State 0"). When we hitdelay(100), we transition out of State 0. - Suspension 1: The function suspends. Control returns to the caller. The label is set to 1, indicating “resume in State 1.”
- [100ms passes — this is the input/event]
- State 1: Resume execution. Execute
println("State 1"). When we hitfetchData(), we transition out of State 1. - Suspension 2: Function suspends again. Label is set to 2.
- [Network response arrives — input/event]
- State 2: Resume. Execute
println("State 2"). HitprocessData(), transition out. - Suspension 3: Suspend. Label = 3.
- [Processing completes — input/event]
- State 3: Resume. Execute
println("State 3"). Function completes. This is the final state.
This function has exactly 4 states (0, 1, 2, 3) and 3 transitions (the three suspension points). The FSM model captures the entire execution flow perfectly.
Key insight: If a suspend function has N suspension points and M non-suspending return/exit points, the compiler typically generates N + M states.
In the common case with a single final return, that’s N + 1 (initial state plus one state after each suspension).
This framing also explains why adding an early return can introduce an extra state even if you didn’t add a new suspension point.
2.c. Why Finite? The Compilation-Time Guarantee
The “finite” part of Finite State Machine is not just a technicality — it’s a fundamental property that makes the entire coroutine compilation strategy possible.
When the Kotlin compiler processes your suspend function, it performs a complete static analysis to count exactly how many suspension points exist. This finite, known-at-compile-time number is what enables the compiler to generate an efficient state machine.
Let’s understand why this matters with an example. Consider this suspend function:

When the compiler analyzes this code, it can definitively say: “This function has 3 suspension points, therefore it needs a state machine with 4 states.” There’s no uncertainty, no runtime discovery of states, no dynamic state creation. The entire state space is known before the program even runs.
This compile-time knowledge allows the compiler to generate a simple, efficient switch statement:
when (label) {
0 -> { /* State 0 code */ }
1 -> { /* State 1 code */ }
2 -> { /* State 2 code */ }
3 -> { /* State 3 code */ }
}
Since the compiler knows there are exactly 4 cases at compile-time, it can generate optimal code. Modern JVMs can optimize switch statements with known, finite cases into extremely fast jump tables — essentially array lookups that execute in constant time (though the exact optimization strategy is JVM-dependent).
Compare this to what would be needed if the number of states wasn’t finite:
- We’d need dynamic state creation at runtime (expensive memory allocations)
- We couldn’t use a simple switch statement (would need complex lookup structures)
- The JVM couldn’t optimize the state transitions (no constant-time guarantees)
- We’d need garbage collection for states (memory management overhead)
The finite property is what makes coroutines lightweight and efficient. Each coroutine’s state machine is a fixed-size object with a predictable memory footprint. This is why you can have millions of coroutines in a single application — each one is just a small state machine object, not an expensive runtime construct.
Mental model: Think of each suspend function as having a tiny program counter (label) and a frame that stores your local variables.
Suspending transitions you to the next state; resuming continues from where you left off.
A Common Confusion: What About Loops?
You might be thinking:
“Wait, if I have a loop that runs 1000 times, don’t I have 1000 different states?” Or worse: “What if I have an infinite loop — how can that be finite?”
This reveals a crucial distinction between the FSM structure (how many states exist) and FSM execution (how many times we visit those states).
Example 1: Loop without suspension inside

This function has exactly 3 states (0, 1, 2), NOT 1,000,000 states:
- State 0: Before
fetchItems()suspends (no code before it) - State 1: After
fetchItems()resumes, includes the entire loop executing - State 2: After
saveResults()completes
The entire loop executes within State 1. No matter if it’s 10 iterations or 1 million, it’s all part of one state because there are no suspension points inside the loop.
Example 2: Loop WITH suspension inside

This has 4 states (0, 1, 2, 3):
- State 0: Before
fetchItems() - State 1: After
fetchItems(), process one item, hityield() - State 2: After
yield()resumes, check loop condition, back to State 1 (or exit to State 3) - State 3: After
saveResults()
Here’s the key insight: States 1 and 2 are visited 1,000,000 times, but they’re the same 2 states in the FSM structure. The state machine doesn’t grow, it just cycles through the same states repeatedly.
Think of it like the traffic light example we saw, with 3 states (RED, YELLOW, GREEN). The light cycles through these states infinitely, but there are still only 3 states.
The number of states is finite, even though the number of cycles can be infinite.
Example 3: Infinite loop

This has 3 states that cycle forever:
- State 0 → State 1 (when
fetchTask()completes) - State 1 → State 2 (when
yield()completes) - State 2 → back to State 1 (loop continues, checking
while(true))
The state machine cycles indefinitely: 0 → 1 → 2 → 1 → 2 → 1 → 2… forever.
The FSM structure is finite (only 3 states exist in the switch statement), but the execution is infinite (it transitions through states forever).
3. Suspending Functions and the suspend Keyword
Now that we understand Finite State Machines within coroutines, we can explore how Kotlin’s suspend keyword activates the FSM transformation.
The suspend keyword is the trigger that tells the compiler:
“This function may contain zero or more suspension points, and those suspension points need to be integrated as states into the related coroutine’s FSM.”
But what does that actually mean? Let’s dive deep into the mechanics of suspension.
3.a. What Does suspend Actually Mean? Decoding the Compiler Directive
When you add the suspend modifier to a function, you’re not just adding a keyword; you’re fundamentally changing how that function behaves at the bytecode level.
The suspend keyword is a compiler directive that triggers a comprehensive transformation of your function’s structure and calling convention.
Let’s start with a simple suspend function to see what we’re working with:
suspend fun fetchData(): String {
// HTTP call or database operation
return "some result"
}
On the surface, this looks like a regular function that returns a String. But that suspend keyword changes everything. It’s telling the compiler: “This function might pause execution and resume later. Transform it accordingly.”
What Exactly Is a “Suspension Point”?
Before we go further, let’s define this critical term precisely:
A suspension point is a location in code where execution can pause and return control to the caller. In Kotlin coroutines, suspension points are created by:
1. Calling suspending functions from the standard library:
delay(),yield(),withContext(),async { }.await()
2. Calling suspend functions that contain suspension points:
suspend fun myFunction() {
delay(100) // ← This creates a suspension point in myFunction
}
3. Using suspendCoroutine/suspendCancellableCoroutine:
suspend fun custom() = suspendCoroutine { cont ->
// Explicitly creates a suspension point
}
The suspend keyword signals to the compiler that this function’s content may contain one or more suspension points, which have several important implications for the coroutine’s FSM:
- Pauseable execution: Suspension points within the function can pause execution, returning control to the caller
- Resumable execution: After a suspension point pauses execution, the coroutine can resume from exactly where it left off
- State preservation: Local variables must survive across suspension and resumption
- Continuation-passing: The function is transformed to accept a hidden
Continuationparameter, which, when called from a coroutine, provides access to the coroutine context (dispatcher, Job state, cancellation signals, etc.) - Exception propagation: The continuation mechanism ensures exceptions propagate correctly through the call chain
To accomplish all this, the compiler performs a sophisticated transformation that converts your simple-looking function into something quite different.
3.b. The Transformation: What the Compiler Generates
When the Kotlin compiler encounters a suspend function, it transforms it in a fundamental way.
The transformation is invisible to you as a developer (you write simple, sequential code), but understanding what happens under the hood is crucial for mastering coroutines.
Let’s see what the compiler actually generates for our simple suspend function:
// EXAMPLE 1 - Function without params // Your Kotlin code suspend fun fetchData(): String // What the compiler generates (conceptual Kotlin) fun fetchData(continuation: Continuation<String>): Any? // In JVM bytecode Object fetchData(Continuation continuation) // EXAMPLE 2 - Function with params // Your Kotlin code suspend fun fetchData(category: Int): String // What the compiler generates (conceptual Kotlin) fun fetchData(category: Int, continuation: Continuation<String>): Any? // In JVM bytecode Object fetchData(int category, Continuation continuation)
Notice the critical transformation. The compiler changes the function signature in two fundamental ways:
1. Adds a Continuation parameter: This hidden parameter is the gateway to the coroutine machinery. At runtime, it’s an instance of a compiler-generated class that contains:
- The current state (label field)
- Preserved local variables (as fields)
- The coroutine context (accessible via
continuation.context)
The Continuation interface itself provides:
interface Continuation<in T> {
val context: CoroutineContext // Dispatcher, Job, etc.
fun resumeWith(result: Result<T>) // How to resume execution
}
Every suspend function receives this continuation parameter, even though you never see it in your source code.
2. Changes the return type to Any?:
In JVM bytecode, this becomes Object. Instead of returning just a String, the function can now return:
- The actual
Stringresult (if it completes immediately without suspending), OR - The special sentinel value
COROUTINE_SUSPENDED(signaling “I’m pausing, resume me later via the continuation”)
This dual-return capability is the key to non-blocking behavior. The function can complete synchronously when possible (efficient fast-path) or suspend and resume asynchronously when needed.
The continuation parameter is like a callback, but much more powerful
It’s a complete package containing the state machine’s memory and execution context.
3.c. The Core Suspension Protocol: Two Ways to Return
Understanding the suspension protocol is essential for grasping how coroutines achieve their non-blocking magic.
Every suspend function follows a strict contract that allows it to return in two fundamentally different ways, depending on whether actual suspension is needed.
// Option 1: The function completes immediately (no actual suspension) return actualResult // Returns the String directly // Option 2: The function needs to suspend return COROUTINE_SUSPENDED // Special sentinel value // Later, when ready, the runtime calls: continuation.resumeWith(Result.success(actualResult)) // or continuation.resumeWith(Result.failure(exception))
Technical Note: COROUTINE_SUSPENDED is an internal sentinel from the kotlin.coroutines.intrinsics package. The coroutine machinery uses it to signal suspension (your application code never directly returns or compares it). Only library authors and the compiler work with this sentinel directly.
Let’s break down these two paths with concrete examples to see when each one happens:
Path 1: Immediate Return (No Suspension)
A suspend function can sometimes complete without suspending (e.g., cache hit). It still follows the suspend calling convention, but the compiler/JIT may optimize away extra allocations when possible:
suspend fun getCachedData(key: String): String {
cache[key]?.let { return it } // completes immediately
// else: perform async work and suspend...
}
When getCachedData finds the data in cache, it returns immediately, just like a regular function. The state machine code exists but takes the fast path, returning immediately without suspension, and therefore avoiding any thread switching overhead. The suspend keyword enables the function to suspend when needed, but doesn’t force suspension when it’s not necessary. This is a crucial optimization that keeps coroutines efficient.
Path 2: Actual Suspension
When the function needs to perform an asynchronous operation, it returns the special sentinel value COROUTINE_SUSPENDED:
⚠️ IMPORTANT — CONCEPTUAL CODE:
The following conceptualizes what the compiler generates internally. You cannot write this code directly in your applications! To create custom suspension points, use suspendCoroutine { }(explained in “Bridging the Gap” section below).
// ⚠️ CONCEPTUAL CODE - demos compiler-generated internals
// YOU CANNOT WRITE THIS! Use suspendCoroutine { } instead
suspend fun fetchFromNetwork(url: String): String {
// Start async network request
networkClient.get(url) { response ->
// 'continuation' is injected by compiler, not available to you
continuation.resumeWith(Result.success(response))
}
return COROUTINE_SUSPENDED // Not accessible in user code
}
When COROUTINE_SUSPENDED is returned:
- The function exits immediately (doesn’t block the thread)
- The calling coroutine’s state machine pauses at this point
- The thread becomes free to do other work
- Later, when the network response arrives,
continuation.resumeWith()is called - The state machine resumes from exactly where it paused
Key insight:
Not every call to a suspend function actually suspends! The function examines the situation and decides: “Can I return a result right now, or do I need to wait for something?”
This smart behavior is what makes coroutines both convenient (you can call suspend functions anywhere) and efficient (no overhead when suspension isn’t needed).
3.d. What Happens to Local Variables? State Preservation Across Time
This is perhaps the most magical-seeming aspect of coroutines:
“Local variables somehow survive suspension and resume.”
How is this possible when the function has “returned” and its stack frame should be gone? The answer lies in the continuation object that the compiler generates.
Let’s look at a concrete example that illustrates the problem and solution:
suspend fun processOrder() {
val orderId = generateOrderId() // Local variable created
println("Processing order: $orderId")
delay(1000) // Suspension! Function "returns" here
// When we resume, orderId must still exist!
submitOrder(orderId) // We need orderId here, 1 second later
println("Order $orderId submitted")
}
Think about what’s happening here from the JVM’s perspective:
- We create a local variable
orderIdon the stack - We print it (so far so good)
- We call
delay(1000)which suspends the function - The function returns
COROUTINE_SUSPENDEDto its caller - The stack frame is popped — normally,
orderIdwould be lost! - One second passes (the thread is doing other work)
- The continuation is resumed
- We need to access
orderIdagain—but it should be gone!
How does orderId survive this journey? The answer is that local variables are promoted to fields of a synthetic continuation object. The compiler performs a clever transformation:
⚠️ CONCEPTUAL CODE
Compiler-generated internals You do not write this class! The compiler generates something like this for you automatically.
// ⚠️ YOU DO NOT WRITE THIS - The compiler generates it for you!
// Conceptual representation of compiler-generated code
class ProcessOrderStateMachine : ContinuationImpl {
var label: Int = 0 // State tracker (program counter)
var orderId: String? = null // Storage for the local variable!
override fun invokeSuspend(result: Any?) {
// State machine logic that uses the stored orderId
}
}
Let’s trace through exactly what happens to orderId during suspension and resumption:
Before suspension:
1. Code executes: val orderId = generateOrderId()
2. The value is stored in stateMachine.orderId field
3. Code executes: println("Processing order: $orderId")
4. Code reaches: delay(1000)
During suspension:
5. label is set to 1 (indicating “resume in state 1”)
6. orderId remains safely stored in stateMachine.orderId
7. Function returns COROUTINE_SUSPENDED
8. Stack frame is popped, but the stateMachine object persists in heap memory
9. Time passes… thread does other work…
During resumption:
10. One second later, continuation.resumeWith() is called
11. The state machine’s invokeSuspend() method executes
12. It jumps to label = 1 (using the switch statement)
13. It reads orderId from the stateMachine.orderId field
14. Code continues: submitOrder(orderId) with the preserved value
The local variable has been transformed from a stack-allocated temporary into a heap-allocated field of the continuation object. This transformation is what enables variables to survive across suspension boundaries — they’re no longer tied to the call stack but stored in a persistent object.
Important note:
Only locals that are needed after a suspension point are lifted into fields of the synthetic continuation.
The compiler performs liveness analysis to keep that object as small as possible; short-lived temporaries that don’t cross a suspension boundary remain on the stack.
3.e. The Continuation Interface: Your State Machine Controller
Now that we understand what happens to local variables, let’s examine the continuation object itself more closely (which we already mentioned at 3.b).
The Continuation interface is the foundation of the entire coroutine system. It’s the contract that defines how state machines communicate and resume.
Here’s the beautifully simple interface that powers everything:
interface Continuation<in T> {
val context: CoroutineContext // Dispatcher, Job, etc.
fun resumeWith(result: Result<T>) // Resume with success or failure
}
Despite its simplicity, this interface is remarkably powerful. Let’s understand what each piece does and why it’s essential:
The context property:
This is a collection of contextual information that the coroutine needs to execute correctly:
- Dispatcher: Which thread pool should execute this code? (
Dispatchers.Main,Dispatchers.IO, etc.) - Job: Coroutine’s lifecycle status (Is this coroutine still active or has it been cancelled?)
- CoroutineName: What should this coroutine be called in debugging tools?
- ExceptionHandler: Where should unhandled exceptions go?
The context answers the “where and how” questions:
“Where should we resume? How should we handle errors? Is it still valid to continue?”
The resumeWith method:
This is the callback that brings the coroutine back to life. It’s how the asynchronous world (network callbacks, timer completions, database results) communicates back into the coroutine world.
The method takes a Result<T> which can represent either success or failure:
// On success: continuation.resumeWith(Result.success(value)) // On failure: continuation.resumeWith(Result.failure(exception))
This allows the continuation to carry either:
- A successful value:
Result.success("data") - An exception:
Result.failure(NetworkException())
This unified interface means the state machine can handle both normal returns and exceptions through the same mechanism. When you access the result, exceptions are automatically rethrown at the right point in your code.
The complete picture ContinuationImpl:
While the Continuation interface is minimal, the actual implementation (ContinuationImpl) adds the machinery needed for the state machine:
abstract class ContinuationImpl : Continuation<Any?> {
var label: Int = 0 // Current state (program counter)
abstract fun invokeSuspend(result: Any?): Any? // Execute the state machine
// Fields for local variables are added by the compiler
}
Important: ContinuationImpl is not a single class you interact with directly. For each suspend function, the compiler generates a unique subclass of ContinuationImpl.
When you write a suspend function, the compiler generates a subclass of ContinuationImpl that includes:
- The
labelfield to track which state we’re in - Fields for each local variable that needs to persist
- The
invokeSuspendmethod containing the state machine logic (the big switch statement)
This is the complete package that makes suspension and resumption possible.
How Continuations Chain Together
When one suspend function calls another, they form a chain. Understanding this chain is crucial for grasping how the entire system fits together.
suspend fun outerFunction() {
val result = innerFunction() // How do these link?
println(result)
}
suspend fun innerFunction(): String {
delay(100)
return "done"
}
The continuation chain:
OuterFunction's Continuation ↓ (referenced as 'completion' field) InnerFunction's Continuation ↓ (referenced as 'completion' field) delay()'s Continuation
When delay() completes, it calls innerFunction's continuation.resumeWith(), which eventually calls outerFunction's continuation.resumeWith(). This is how results propagate back up the call chain.
Key insight on Continuation Chain:
“Each suspend function receives a continuation that “knows” how to resume its caller. This creates a linked chain of continuations representing the entire call stack.
When the deepest function completes, it triggers a cascade of resumptions back up through the chain.”
Visualizing the flow:
outerFunction()callsinnerFunction(), passing its own continuation as the completioninnerFunction()callsdelay(), passing its own continuation as the completiondelay()suspends, storing the continuation- Time passes…
delay()completes and calls its stored continuation’sresumeWith()- This resumes
innerFunction(), which then completes innerFunction()calls its stored continuation’sresumeWith()- This resumes
outerFunction(), which continues execution
This chaining mechanism is how the entire call stack is preserved and restored across suspension points.
3.f. Dispatcher Magic: Thread Switching Made Easy
One of the most elegant and powerful features of coroutines is how seamlessly they handle thread switching. You can suspend on one thread and resume on a completely different thread, yet your code looks perfectly sequential with no explicit thread management. This capability is what makes coroutines ideal for UI programming, where you need to fetch data on a background thread but update the UI on the main thread.
Let’s see this in action with a typical Android scenario:
suspend fun loadUserProfile(): Profile = withContext(Dispatchers.IO) {
val data = networkApi.fetchUser() // Suspends on IO thread
// When resumed, we're still on IO thread
parseUserData(data) // Continues on IO thread
}
// But if you call this from Main dispatcher:
suspend fun displayProfile() {
// Currently running on Main dispatcher
val profile = loadUserProfile() // Switches to IO, then back to Main
// Running on Main dispatcher again - safe to update UI!
updateUI(profile) // UI updates must happen on Main
}
The magic here is that updateUI(profile) is guaranteed to run on the Main thread, even though loadUserProfile() executed on the IO dispatcher. How does this work?
The answer lies in how the ContinuationInterceptor (which is what dispatchers implement) wraps the continuation:
// Simplified conceptual code showing how dispatchers work
class DispatchedContinuation(
val dispatcher: CoroutineDispatcher,
val delegate: Continuation<T>
) : Continuation<T> {
override fun resumeWith(result: Result<T>) {
// Instead of resuming directly, dispatch to the right thread
dispatcher.dispatch {
delegate.resumeWith(result)
}
}
}
Note: Dispatchers implement ContinuationInterceptor. They wrap the continuation so that resumeWith(…) is dispatched to the right thread or thread-pool transparently.
withContext(X) guarantee: the block runs on dispatcher X and, when complete, resumes back on the original caller context (also honoring prompt cancellation).
When an async operation completes and calls continuation.resumeWith(), it’s actually calling a wrapped continuation.
The wrapper intercepts the resume call and dispatches it to the appropriate thread before actually resuming the state machine. This happens transparently as your state machine code doesn’t need to know anything about thread management.
Here’s the thread journey for our example:
Main Thread: displayProfile() starts Main Thread: calls loadUserProfile() Main Thread: withContext(Dispatchers.IO) suspends ↓ [Dispatcher.IO intercepts and enqueues work] ↓ IO Thread: networkApi.fetchUser() executes IO Thread: Network request suspends ↓ [Time passes, network response arrives] ↓ IO Thread: fetchUser() resumes IO Thread: parseUserData() executes IO Thread: loadUserProfile() completes ↓ [Dispatcher.Main intercepts return] ↓ Main Thread: displayProfile() resumes Main Thread: updateUI() executes safely
Key insight:
The state machine doesn’t care which thread it’s running on. The label and local variables work the same regardless of thread.
The dispatcher simply ensures that each state executes on the appropriate thread by intercepting resumption calls and scheduling them on the right thread pool.
This is why you can confidently update UI elements after a withContext(Dispatchers.IO) block—the dispatcher guarantees you’re back on the Main thread, even though your code looks completely sequential with no explicit thread switching.
Thread Switching Android Example
class UserProfileViewModel : ViewModel() {
fun loadProfile(userId: String) {
viewModelScope.launch { // Runs on Main
val profile = withContext(Dispatchers.IO) {
repository.fetchProfile(userId) // Network on IO
}
// Back on Main - safe to update UI
_profileState.value = ProfileState.Success(profile)
}
}
}
3.g. Example: Following the Flow Through Suspension and Resumption
To solidify our understanding, let’s trace through a complete example step by step, watching every detail of how suspension and resumption work. We’ll examine what happens to the stack, the heap, the label, the local variables, and the thread at each stage.
Here’s our example function that fetches data and then processes it:
suspend fun fetchAndProcess(): Result {
println("Step 1") // State 0
val data = fetchData() // Suspension point 1
println("Step 2") // State 1
val processed = process(data) // Suspension point 2
println("Step 3") // State 2
return Result(processed)
}
Now let’s trace through the complete execution, frame by frame:
Frame 1: Initial Call
Thread: Main
Stack:
- fetchAndProcess() called with continuation
- label = 0
Heap:
- StateMachine object created
- label = 0
Action:
- Execute println("Step 1") → outputs "Step 1"
- Set label = 1 (prepare for next state)
- Call fetchData(continuation)
- fetchData() starts network request
- fetchData() returns COROUTINE_SUSPENDED
Result:
- fetchAndProcess() returns COROUTINE_SUSPENDED to caller
- Stack frame is popped
- Main thread is now free
Frame 2: Time Passes
Thread: Main thread doing other work Stack: Other unrelated work Heap: - StateMachine object still exists - label = 1 (waiting to resume in State 1) - data field = null (not yet filled) Action: - Network request is in progress - Main thread handles other UI events, other coroutines Duration: - Maybe 100ms passes...
Frame 3: Network Response Arrives
Thread: Network callback thread (some background thread) Action: - Network library receives response: dataResult = "Hello" - Calls continuation.resumeWith(Result.success(dataResult)) Dispatcher Interception: - Dispatcher.Main intercepts the resumeWith call - Enqueues resumption on Main thread queue
Frame 4: Resumption on Main Thread
Thread: Main (switched back by dispatcher)
Stack:
- invokeSuspend() called
- result parameter = Result.success("Hello")
Heap:
- StateMachine object
- label = 1 (read to determine which state)
- data field will be filled
Action:
- Switch statement jumps to case 1
- data = result.getOrThrow() → "Hello"
- Store "Hello" in stateMachine.data field
- Execute println("Step 2") → outputs "Step 2"
- Set label = 2
- Call process(data, continuation)
- process() starts computation
- process() returns COROUTINE_SUSPENDED
Result:
- Function suspends again
- Returns COROUTINE_SUSPENDED
- Stack frame popped
Frame 5: Processing Completes
[Similar to frames 2-4, but for the process() suspension]
Eventually:
- processed = processedResult
- Execute println("Step 3") → outputs "Step 3"
- Return Result(processed)
- Coroutine completes!
This complete trace shows several crucial insights:
- State persists in heap: The StateMachine object lives in heap memory, surviving stack frame destruction
- Label guides resumption: The label field is the “program counter” that tells us which state to jump to
- Variables survive: Local variables are fields in the state machine, persisting across suspensions
- Thread switching is transparent: The dispatcher handles thread management without the state machine knowing
- Multiple suspensions work naturally: Each suspension follows the same protocol, creating a chain
By understanding this complete flow, you can reason about any coroutine code — tracing mentally where suspensions occur, what state is preserved, and which thread executes which code.
Bridging the Gap: Converting Callbacks to Suspend Functions
Now that we understand how continuations work under the hood, let’s see how you can use this mechanism directly in your code. One of the most powerful practical applications is converting callback-based APIs into suspend functions.
This is where the theory meets real-world development.
The Problem: Callback Hell
Many Android and JVM libraries still use callback-based APIs. Consider this typical scenario with a video client:
// Before: Callback-based API (awkward)
fun connect() {
val videoClient = VideoClient()
videoClient.connect(
object : VideoClient.ConnectionCallback {
override fun onConnected() {
println("Yaay")
// What if we need to do more async work here?
// Nesting callbacks leads to "callback hell"
}
override fun onError(t: Throwable) {
println("Oh no!")
}
}
)
}
This callback-based approach has several problems:
- Code becomes deeply nested with multiple async operations
- Error handling is scattered across callbacks
- Can’t use standard control flow (loops, try-catch)
- Hard to compose with other coroutine code
The Solution: suspendCoroutine
The suspendCoroutine function gives you direct access to the continuation mechanism we’ve been studying. You can use it to wrap any callback-based API into a clean suspend function:
// After: Clean suspend function
suspend fun awaitConnect(): Boolean {
val videoClient = VideoClient()
return suspendCoroutine { continuation ->
videoClient.connect(
object : VideoClient.ConnectionCallback {
override fun onConnected() {
continuation.resume(value = true)
}
override fun onError(t: Throwable) {
continuation.resume(value = false)
// Or: continuation.resumeWithException(t)
}
}
)
}
}
What’s happening here:
suspendCoroutinecreates a suspension point in your function- The lambda receives the continuation object — the same continuation we’ve been discussing!
- Inside the callback, you manually call
continuation.resume()with the result - This triggers the state machine to resume from exactly where it suspended
This is the same resumption mechanism the compiler uses internally, but now you’re calling it directly!
Usage: Clean Sequential Code
Now you can use your wrapped API with clean, sequential coroutine code:
class MainViewModel: ViewModel() {
fun connectToClient() {
viewModelScope.launch {
val success = awaitConnect() // Suspends until connected
if (success) {
println("yaay")
// Easy to continue with more async work
val data = fetchData()
processData(data)
} else {
println("Oh no!")
}
}
}
}
Notice how the code reads top-to-bottom, just like synchronous code. No callback nesting, no pyramid of doom. This is the power of the continuation mechanism applied in practice!
Advanced: suspendCancellableCoroutine
For production code, you should usually use suspendCancellableCoroutine instead, which supports cancellation:
suspend fun awaitConnect(): Boolean = suspendCancellableCoroutine { continuation ->
val videoClient = VideoClient()
val callback = object : VideoClient.ConnectionCallback {
override fun onConnected() {
continuation.resume(value = true)
}
override fun onError(t: Throwable) {
continuation.resumeWithException(t)
}
}
videoClient.connect(callback)
// Clean up if the coroutine is cancelled
continuation.invokeOnCancellation {
videoClient.disconnect()
}
}
Key improvements:
- Supports coroutine cancellation
- Can register cleanup logic with
invokeOnCancellation - Prevents resource leaks when operations are cancelled
The Connection to State Machines
This practical example demonstrates everything we’ve learned:
- Suspension:
suspendCoroutinecreates a suspension point (a state transition) - Continuation: You receive the actual continuation object from the state machine
- Resume: Calling
continuation.resume()triggers the state machine to move to the next state - State preservation: Local variables (like
videoClient) are automatically captured in the continuation
When you call continuation.resume(), you’re directly invoking the same mechanism that delay(), withContext(), and all other suspend functions use internally. You’re participating in the state machine protocol that powers all of Kotlin coroutines!
Mental model: Think of suspendCoroutine as opening a window into the state machine. The compiler normally handles all the continuation management automatically, but this function lets you manually control when and how resumption happens. It’s like being given the remote control to your coroutine’s state machine.
This bridges the gap between legacy callback-based code and modern coroutine-based code, letting you gradually migrate your codebase while maintaining clean, readable code throughout.
4. Deconstructing the State Machine: How Kotlin Compiles Coroutines
Now let’s see exactly what the compiler generates. This section shows the transformation from your elegant suspend code to the state machine implementation.
4.a. Continuation-Passing Style (CPS)
While it might seem like an abstract academic concept at first, CPS is actually a remarkably practical pattern that solves a fundamental problem in asynchronous programming.
It provides the theoretical foundation for understanding why the compiler transforms your code the way it does.
What Problem Does CPS Solve?
How can a function “return” a value that won’t be available until some future time?
The traditional solution has been callbacks: you pass a function that should be called when the result is ready. But callbacks quickly become unwieldy, leading to the infamous “callback hell” where your code nests deeper and deeper.
CPS provides an elegant alternative that gives us the benefits of callbacks while maintaining code that looks and feels synchronous.
Understanding CPS: From Direct Style to Continuation-Passing Style
Let’s start with a simple function in normal “direct style” where results are returned immediately:
// Direct style: immediate return
fun add(a: Int, b: Int): Int {
return a + b // Result returned directly to caller
}
// Usage
val result = add(3, 5) // result = 8
println(result)
This is how we normally write functions (straightforward and easy to understand). The function computes its result and returns it directly. The caller receives the result on the same line and can immediately use it. This is called “direct style” because the return value goes directly back to the caller.
Now let’s transform this same function into Continuation-Passing Style. Instead of returning the result directly, we’ll pass the result to a continuation function (essentially a callback that represents “what to do next”):
// CPS style: pass result to continuation
fun add(a: Int, b: Int, cont: (Int) -> Unit) {
val result = a + b
cont(result) // Pass result to "what comes next"
}
// Usage
add(3, 5) { result -> // The continuation: what to do with the result
println(result) // Prints: 8
}
Notice the transformation: instead of returning the result, we compute it and then pass it to a continuation function.
The continuation represents “the rest of the program”, that is, everything that needs to happen after this computation completes. This might seem unnecessarily complicated for addition, but the power becomes apparent when we need to handle asynchronous operations.
CPS Enables Asynchronous Operations
Here’s where CPS becomes crucial for coroutines: in CPS, a function doesn’t have to call its continuation immediately. It can call it later, potentially after an asynchronous operation completes.
Let’s see a realistic example where we fetch data from a network:
// Direct style - can't handle async operations properly
fun fetchUserData(userId: String): UserData {
// How do we wait for the network request?
// We can't block the thread, but we also can't return yet!
return ??? // Stuck!
}
// CPS style - handles async operations naturally
fun fetchUserData(userId: String, cont: (UserData) -> Unit) {
networkClient.makeRequest(userId) { response ->
val userData = parseResponse(response)
cont(userData) // Call continuation when data arrives
}
// Function returns immediately, but continuation is called later
}
In the CPS version, the function can return immediately (freeing up the thread) while the continuation holds onto “what to do next.”
When the network response arrives (maybe milliseconds or seconds later), the continuation is invoked with the result. This is the essence of how coroutines achieve non-blocking behavior.
The Double Return Pattern: Suspension Sentinel
The double-return pattern we already discussed in Section 3.c is actually CPS extended with an optimization.
Let’s see it again briefly:
For Kotlin coroutines, CPS is extended with a special pattern that allows a function to signal whether it actually suspended or completed immediately. This is crucial for optimization — if data is already cached, why pretend to suspend?
Here’s the pattern that suspend functions actually use:
// Suspend function CPS - can return immediately OR suspend and return later
fun suspendFunction(continuation: Continuation<T>): Any? {
// Option 1: Result is available immediately
if (cachedResultAvailable) {
return cachedResult // Direct return, no actual suspension
}
// Option 2: Need to wait for async operation
startAsyncOperation { result ->
continuation.resumeWith(Result.success(result)) // Resume later
}
return COROUTINE_SUSPENDED // Special sentinel: "I'm suspending"
}
This double-return pattern is ingenious. The function returns once immediately with either:
- The actual result (if available) — no suspension needed
- The sentinel value
COROUTINE_SUSPENDED– indicating “I’ll call your continuation later”
Then, if it suspended, it “returns” a second time by calling continuation.resumeWith() when the async operation completes. This is how a single function call can have two “return points”—one immediate, one delayed.
Why CPS Instead of Other Approaches?
You might wonder: why go through this transformation? Why not just use callbacks directly? The answer is that CPS gives us several crucial properties:
- Composability: CPS functions can call other CPS functions naturally, passing continuations through the call chain. This creates the smooth, sequential-looking code that makes coroutines feel synchronous.
- State preservation: The continuation object can hold onto local variables, enabling them to survive across suspension points. This is what allows your local variables to still exist when the coroutine resumes.
- Control flow preservation: Because continuations represent “the rest of the program,” they capture not just data but also control flow — where to jump to next, which exception handlers are active, etc.
- Optimization opportunities: The compiler can analyze the continuation chain and optimize away unnecessary allocations when functions don’t actually suspend.
Here’s the crucial insight that connects CPS to the state machine model: each suspension point in CPS becomes a state transition in the generated state machine. When you write code in CPS style, you’re essentially describing a state machine where each continuation represents a state.
Consider this CPS-style pseudo-code:
fun processOrder(orderId: String, cont1: (Order) -> Unit) {
fetchOrder(orderId) { order -> // State 0 → 1
cont1(order)
}
}
fun cont1(order: Order) {
processPayment(order) { payment -> // State 1 → 2
cont2(payment)
}
}
fun cont2(payment: Payment) {
generateReceipt(payment) { receipt -> // State 2 → 3
cont3(receipt)
}
}
fun cont3(receipt: Receipt) {
return receipt // State 3: Done
}
The Kotlin compiler takes your clean, sequential suspend function code and transforms it into something conceptually similar to the CPS chain above, but packaged into a single state machine class.
Each continuation becomes a case in the state machine’s switch statement:
// Your clean code
suspend fun processOrder(orderId: String): Receipt {
val order = fetchOrder(orderId)
val payment = processPayment(order)
val receipt = generateReceipt(payment)
return receipt
}
// Gets compiled to a state machine where each line becomes a state
when (label) {
0 -> { /* fetch order */ }
1 -> { /* process payment */ }
2 -> { /* generate receipt */ }
3 -> { /* return result */ }
}
The Continuation Object: Your CPS Controller
In CPS, the continuation is just a function that takes the result and does something with it. But for Kotlin coroutines, continuations are objects that carry additional information needed for the state machine to work properly.
Let’s look at the simplified Continuation interface that powers this entire system:
interface Continuation<in T> {
val context: CoroutineContext // Where and how should we resume?
fun resumeWith(result: Result<T>) // Resume with success or failure
}
This interface is beautifully minimal yet powerful. A continuation needs only two pieces of information:
- Context: This tells us where to resume execution. Which thread should we use? Is the coroutine still active? What’s the coroutine’s name for debugging? All these questions are answered by the
CoroutineContext. - Resume callback: The
resumeWithfunction is how the async world calls back into the coroutine world. When an operation completes, it calls this function with either success or failure, and the state machine springs back to life.
The actual implementation (ContinuationImpl) extends this with the state machine machinery:
- The
labelfield (which state are we in?) - The
invokeSuspend()method (execute the state machine) - Fields for local variables (preserve state across suspensions)
CPS in Action: A Complete Example
Let’s trace through a complete example to see how CPS enables coroutine suspension. We’ll watch the transformation from your code to the CPS-based state machine:
// Your code: looks sequential
suspend fun loadUserProfile(userId: String): Profile {
val user = fetchUser(userId) // Might suspend
val posts = fetchPosts(user.id) // Might suspend
val friends = fetchFriends(user.id) // Might suspend
return Profile(user, posts, friends)
}
// Conceptual CPS transformation
fun loadUserProfile(userId: String, completion: Continuation<Profile>): Any? {
// State 0: Start
return fetchUser(userId, object : Continuation<User> {
override fun resumeWith(result: Result<User>) {
val user = result.getOrThrow()
// State 1: After fetchUser
fetchPosts(user.id, object : Continuation<List<Post>> {
override fun resumeWith(result: Result<List<Post>>) {
val posts = result.getOrThrow()
// State 2: After fetchPosts
fetchFriends(user.id, object : Continuation<List<User>> {
override fun resumeWith(result: Result<List<User>>) {
val friends = result.getOrThrow()
// State 3: Final assembly
val profile = Profile(user, posts, friends)
completion.resumeWith(Result.success(profile))
}
})
}
})
}
})
}
Each nested continuation in the CPS version represents a state in the state machine. But instead of creating actual nested anonymous classes (which would be inefficient and hard to optimize), the Kotlin compiler flattens this into a single state machine class with a label field to track which state we’re in.
Why CPS Matters for Understanding Coroutines
Understanding CPS is essential for truly grasping coroutines because:
- It explains the transformation: CPS shows why the compiler needs to transform your code and what it’s transforming it into.
- It clarifies suspension: CPS makes it obvious why functions can “pause” — they’re not really pausing, they’re returning early and registering a callback (the continuation) to be invoked later.
- It explains the continuation parameter: Every suspend function secretly takes a continuation parameter because CPS requires passing continuations through the call chain.
- It motivates the state machine: The state machine is just an optimized implementation of the CPS transformation, flattening the nested continuations into sequential states.
With this CPS foundation in place, the rest of the coroutine machinery — state machines, labels, local variable storage — all makes logical sense. CPS is the “why” behind the “how” of coroutine compilation.
4.b. Synthetic Classes and Local Variables
Locals must persist across suspension. The compiler generates a synthetic class with fields for the locals and an integer label:
suspend fun doSomething() {
val token = getToken()
delay(100)
val result = fetchData(token)
println(result)
}
The compiler creates something like a DoSomethingStateMachine with fields for token, result, and an integer label. This class is updated before and after each suspension point.
4.c. The Switch/When on label
Every suspension point becomes a state. The compiler:
- Sets the next label
- Calls a suspend function
- If it returns
COROUTINE_SUSPENDED, returns immediately - On resume, reads the label and jumps to the corresponding case/when branch
This is how execution “pauses” mid-function and later continues at the exact spot.
4.d. Inlining and Optimization
When the compiler can prove that a suspend function has no real suspension points after inlining (for example, it calls only non-suspending code or inline suspend lambdas that the optimizer removes), it elides the entire ContinuationImpl class.
In such cases, the call executes like an ordinary function, demonstrating that the FSM transformation is applied only when needed.
Key Takeaway:
The label dispatch is a simple when/switch over integers; the exact low-level optimization (e.g., jump table vs. lookups) is JVM-dependent and not a semantic guarantee.
4.e. Concurrency with async/await (FSM-aware)
async launches a child coroutine and returns a Deferred<T>. Every await() is a suspension point.
suspend fun loadScreen(): UserAndPosts = coroutineScope {
val userD = async { fetchUser() } // child A starts
val postsD = async { fetchPosts() } // child B starts
val user = userD.await() // suspend until A completes (state S1)
val posts = postsD.await() // suspend until B completes (state S2)
UserAndPosts(user, posts)
}
- Latency: starting both tasks first and then awaiting gives concurrency (critical path ≈ max(timeA, timeB) vs. sequential ≈ timeA + timeB)
- Exceptions:
asynccaptures exceptions and rethrows them atawait(). In FSM terms, the transition at theawait()state can take the exception edge - Structured concurrency: using
coroutineScopekeeps children tied to the parent’s lifecycle; failures cancel siblings (unless you usesupervisorScope—see §8)
5. Understanding State Machine Execution
This section explores how the state machine actually executes at runtime, revealing the mechanisms that make coroutines work.
5.a. The Core Components
To understand state machine execution, we need to identify three fundamental components that work together:
- Program counter (label): An integer that indicates the current state
- State regions: Code segments between suspension points
- Memory (synthetic fields): Storage for local variables that must persist across suspensions
Think of these as analogous to a computer’s basic architecture:
The program counter tells us where we are, the state regions are like instructions in memory, and the synthetic fields are like registers or RAM that hold our working data.
5.b. The Execution Flow: A Visual Model
Let’s visualize how execution flows through a simple function with two suspension points:
[label=0] Initial ↓ (suspend S1) ← First suspension point ↓ [label=1] After S1 ↓ (suspend S2) ← Second suspension point ↓ [label=2] After S2 ↓ End
Here’s what happens at each step:
At S1 (First Suspension):
- Set
label = 1(marking where to resume next) - Store all local variables in synthetic fields
- Return
COROUTINE_SUSPENDEDto the caller - Execution pauses; the thread is now free for other work
On Resume from S1:
- The runtime calls
continuation.resumeWith(result) - Jump to
label = 1using the switch statement - Restore local variables from synthetic fields
- Continue executing with the result from S1
At S2 (Second Suspension):
- Set
label = 2 - Store updated local variables
- Return
COROUTINE_SUSPENDED - Pause again
On Resume from S2:
- Jump to
label = 2 - Restore variables
- Complete the function and return the final result
5.c. Detailed Execution Example
Let’s trace through a concrete example to see these mechanics in action:
suspend fun processOrder(orderId: String): Receipt {
val order = fetchOrder(orderId) // Suspension point 1
val payment = processPayment(order) // Suspension point 2
val receipt = generateReceipt(payment) // Suspension point 3
return receipt
}
The compiler transforms this into something conceptually like:
class ProcessOrderStateMachine extends ContinuationImpl {
int label = 0
String orderId
Order order
Payment payment
Object result // holds intermediate results
Object invokeSuspend(Object result) {
this.result = result
when (label) {
0 -> {
// State 0: Initial call
label = 1
val orderResult = fetchOrder(orderId, this)
if (orderResult == COROUTINE_SUSPENDED) return orderResult
order = orderResult
// Fall through to next state
}
1 -> {
// State 1: After fetchOrder
order = result as Order // Get result from previous suspension
label = 2
val paymentResult = processPayment(order, this)
if (paymentResult == COROUTINE_SUSPENDED) return paymentResult
payment = paymentResult
// Fall through to next state
}
2 -> {
// State 2: After processPayment
payment = result as Payment
label = 3
val receiptResult = generateReceipt(payment, this)
if (receiptResult == COROUTINE_SUSPENDED) return receiptResult
return receiptResult // Final result
}
}
}
}
5.d. State Transitions and Thread Safety
Each state transition happens atomically from the perspective of the coroutine. When we’re inside a state region (between suspensions), the code executes without interruption. This gives us important guarantees:
Within a State:
- No other code in this coroutine can interrupt you
- Local variables are stable and consistent
- You don’t need synchronization for these locals
Between States (at suspension points):
- The thread may change (dispatcher switches)
- Time may pass
- Other coroutines may execute
- External state may have changed
This is why suspension points are natural places to check for cancellation, handle exceptions, and switch execution contexts.
5.e. The Role of the Continuation Chain
In practice, coroutines often call other suspend functions, creating a chain of continuations. Each function in the call stack has its own state machine, and they’re linked together:
Caller State Machine ↓ (continuation) Callee State Machine ↓ (continuation) Deeper Callee State Machine
When the deepest function resumes, it returns to its immediate caller’s state machine, which then continues until it hits another suspension or completes. This chain ensures that:
- Local variables are preserved at every level
- Exceptions bubble up correctly
- Cancellation propagates through all layers
- Results flow back up the call stack
5.f. Key Insight: Boundaries Matter
The most important concept to understand is this: Results, exceptions, and cancellation are observed at boundaries between states.
Inside a state’s straight-line code, nothing interrupts you unless you explicitly probe (by calling ensureActive() or yield()). This is fundamentally different from preemptive threading models where a thread can be interrupted at any time.
Consider this code:
suspend fun processLargeDataset() {
val data = fetchData() // Suspension point - boundary
// This entire block runs atomically within State 1
for (item in data) {
// If this takes 10 seconds, the coroutine
// CANNOT be cancelled during this loop
expensiveCalculation(item)
}
saveResults() // Suspension point - boundary
}
The loop will run to completion even if the coroutine is cancelled, because there are no suspension points inside it. This is why you need to add explicit checks:
suspend fun processLargeDataset() {
val data = fetchData()
for (item in data) {
ensureActive() // Add a boundary check
expensiveCalculation(item)
}
saveResults()
}
5.g. Memory and Performance Characteristics
The state machine approach has specific performance characteristics worth understanding:
Memory:
Each coroutine instance is represented by one small continuation object that stores a label and any locals that must survive suspension. It’s orders of magnitude lighter than an OS thread (which also needs a large stack), which is why you can comfortably run huge numbers of coroutines.
- Each coroutine allocates a single continuation object
- Size depends on the number and type of local variables
- Much lighter than a thread (which needs ~1MB stack)
CPU:
- State transitions are just integer comparisons and field assignments
- No context switching at OS level when switching between coroutines
- The overhead is minimal compared to thread scheduling
Locality:
- All state for a coroutine is in one object
- Good cache locality when the coroutine is active
- Easy for the JVM to optimize
This efficiency is why you can have thousands or even millions of coroutines in a single application, whereas thousands of threads would exhaust system resources.
6. Pointer Movement: Resuming Execution After Suspension
Now, let’s understand exactly how the “pointer” (label) moves through the state machine.
6.a. The Resume Protocol
When an async operation completes, it calls:
continuation.resumeWith(Result.success(value)) // or continuation.resumeWith(Result.failure(exception))
This triggers the state machine’s invokeSuspend() method:
override fun invokeSuspend(result: Any?): Any? {
this.result = result // Store the result
when (label) { // Jump to the saved state
0 -> { /* initial state */ }
1 -> { /* after first suspension */ }
2 -> { /* after second suspension */ }
else -> throw IllegalStateException("Invalid label")
}
}
6.b. Detailed Resume Flow
Let’s trace a complete resume operation:
suspend fun example() {
val data = fetchData() // Suspension point
processData(data)
}
When fetchData() completes with value “Hello”:
Step 1: fetchData’s completion handler calls
continuation.resumeWith(Result.success("Hello"))
Step 2: Dispatcher intercepts (if needed)
dispatcher.dispatch {
actualContinuation.resumeWith(Result.success("Hello"))
}
Step 3: ExampleStateMachine.invokeSuspend() is called
- result = the resume payload (wraps either the value or an exception)
- label = 1 (was set before suspension)
- Jump to case 1 in when statement
Step 4: Extract result and continue
- data =
result.getOrThrow()(Extracts“Hello” or rethrows exception) - Execute
processData(data) - Either complete or suspend again
6.c. Thread Switching During Resume
The dispatcher determines which thread executes the resume:
// Initial call on Main thread
launch(Dispatchers.Main) {
val data = withContext(Dispatchers.IO) {
fetchData() // Suspends, switches to IO
}
updateUI(data) // Resumes back on Main
}
Main Thread: launch { ... }
Main Thread: withContext(Dispatchers.IO) suspends ↓ [Dispatcher.IO intercepts] IO Thread: fetchData() executes IO Thread: fetchData() completes ↓ [Dispatcher.Main intercepts] Main Thread: resumes after withContext Main Thread: updateUI(data)
Key insight:
The ContinuationInterceptor (i.e., the dispatcher in coroutineContext) determines the thread where invokeSuspend continues. This is why you can suspend on IO and resume on Main.
6.d. Multiple Concurrent Resumes: Race Conditions?
What if multiple operations try to resume the same continuation?
// ❌ This would be a bug in the suspend function implementation continuation.resumeWith(Result.success(value1)) // First resume continuation.resumeWith(Result.success(value2)) // Second resume - ERROR!
The contract is: Each suspension point resumes exactly once. Attempting to resume twice throws IllegalStateException.
The implementation uses atomic operations to ensure this:
// Simplified concept
class SafeContinuation<T> : Continuation<T> {
private val resumed = AtomicBoolean(false)
override fun resumeWith(result: Result<T>) {
if (!resumed.compareAndSet(false, true)) {
throw IllegalStateException("Already resumed!")
}
// Actually resume
}
}
This guarantees state machine integrity — you can’t accidentally jump to a state twice.
6.e. Resume with Result vs. Resume with Exception
The state machine handles both success and failure paths:
override fun invokeSuspend(result: Any?): Any? {
when (label) {
1 -> {
try {
val data = result as Data // Might throw if result is exception
processData(data)
} catch (e: Exception) {
// Exception handling state
handleError(e)
}
}
}
}
When resumeWith(Result.failure(exception)) is called:
- The exception is wrapped in
result - Accessing the result throws the exception
- Normal exception handling (try/catch) works as expected
7. Exception Handling in Coroutines
Exception handling in coroutines follows the same state machine principles we’ve been exploring, but with some fascinating nuances that make error handling both powerful and intuitive.
Understanding how exceptions flow through the state machine is crucial for writing robust asynchronous code.
Let’s explore how the compiler transforms your try-catch blocks into state machine logic that preserves all the exception handling semantics you expect from synchronous code.
7.a. How Exceptions Flow Through States
When you write a suspend function that might throw an exception, you might wonder: “How does the exception get from the suspended operation back to your code?” The answer lies in how the continuation’s resumeWith method handles failures.
Let’s start with a simple suspend function that calls a risky operation. When we call a suspend function that might fail, we’re essentially setting up a callback that can be invoked with either success or failure:
suspend fun riskyOperation() {
val data = fetchData() // Might throw
processData(data)
}
Now, imagine that fetchData() encounters an error during its asynchronous operation (like a network timeout or a server error). Instead of calling continuation.resumeWith(Result.success(data)), the operation calls continuation.resumeWith(Result.failure(exception)). This is the key mechanism that brings exceptions back from the async world into your coroutine.
Here’s what the state machine looks like under the hood. The compiler generates code that explicitly handles the failure case when resuming:
override fun invokeSuspend(result: Any?): Any? {
when (label) {
0 -> {
// State 0: Initial execution
label = 1
val fetchResult = fetchData(this)
if (fetchResult === COROUTINE_SUSPENDED) return fetchResult
// If fetchData threw synchronously, we never get here
}
1 -> {
// State 1: After fetchData resumes
// The result parameter contains either success or failure
val data = result as Data // ← This line throws if result was a failure!
// If an exception was passed, the cast will rethrow it
// Exception propagates up naturally from here
}
}
}
The beauty of this design is that exceptions flow naturally through your code.
When you try to access the result and it contains an exception, that exception is rethrown at exactly the point where you’d expect it… that is, right after the suspension point.
7.b. Try/Catch in State Machines: Preserving Exception Handling Semantics
One of the most impressive aspects of the coroutine compiler is how it transforms try-catch blocks into state machine code while preserving all the exception handling semantics you’re familiar with. When you write a try-catch block around suspend functions, the compiler needs to ensure that exceptions are caught correctly even though execution has been suspended and resumed.
Let’s look at a typical error-handling pattern where we want to catch specific exceptions and provide fallback behavior:
suspend fun safeOperation(): Int {
try {
val result = riskyApiCall() // State 0 → 1
return result
} catch (e: NetworkException) {
println("Network error: $e") // State 1 (exception path)
return -1
} catch (e: Exception) {
println("Other error: $e") // State 1 (exception path)
return -2
} finally {
cleanup() // State 2
}
}
The compiler transforms this into a state machine with multiple execution paths.
The try block becomes the main execution path, while each catch block becomes an alternative path that the state machine can transition to when an exception of the matching type occurs.
Here’s how the state machine is structured conceptually:
State 0: try block entry ↓ riskyApiCall() suspends ↓ State 1a: Normal path (success) return result State 1b: Exception path (NetworkException caught) handle network error return -1 State 1c: Exception path (Other Exception caught) handle other error return -2 State 2: finally block (always executed) cleanup()
This transformation ensures that when riskyApiCall() resumes with an exception, the state machine can route execution to the appropriate catch handler based on the exception type.
The finally block is guaranteed to execute regardless of whether the try block succeeded or an exception was caught (just like in normal Java/Kotlin code).
7.c. Uncaught Exceptions and Propagation
We have seen what happens with try/catch blocks in exception handling with coroutines.
What happens when an exception isn’t caught within a coroutine? This is where the Job state machine and structured concurrency rules determine exception propagation behavior.
Consider this scenario where we launch a coroutine that throws an unhandled exception:
launch {
performOperation() // Throws exception
thisNeverRuns() // ← Job is cancelled, this line never executes
}
suspend fun performOperation() {
throw RuntimeException("Oops!")
// Exception propagates according to structured concurrency rules
}
When an exception isn’t caught within the coroutine, it triggers a cascade of events through the Job state machine:
performOperation()throws the RuntimeException- The
launchcoroutine builder receives the exception through its continuation - The Job associated with the coroutine transitions from
ActivetoCancellingstate - All child coroutines spawned within this scope are cancelled immediately
- The exception propagates to parent/siblings according to structured concurrency rules (see Chapter 9)
7.d. Advanced: Exception Handling Across Multiple Suspension Points
Now let’s tackle a more complex scenario: nested try-catch blocks with multiple suspension points. This is where the state machine really shows its sophistication, as it must track not just which state we’re in, but also which exception handlers are currently active.
Imagine you’re building a data processing pipeline where different stages might fail with different types of exceptions, and you want specific handling for each:
suspend fun complexOperation() {
try {
val step1 = operation1() // Suspension point 1
try {
val step2 = operation2(step1) // Suspension point 2
val step3 = operation3(step2) // Suspension point 3
} catch (e: ValidationException) {
handleValidation(e)
}
} catch (e: NetworkException) {
handleNetwork(e)
} finally {
cleanup()
}
}
This function has nested try blocks, multiple suspension points, and different exception handlers at different levels. The compiler needs to generate a state machine that tracks several things simultaneously:
- Which try block’s scope are we currently executing in?
- Which catch handlers are active for the current execution context?
- Has the finally block been executed yet?
- What happens if an exception occurs after we’ve partially exited a try block?
The compiler solves this by tracking which exception handlers are active at each suspension point. Conceptually, you can think of this as the compiler maintaining additional state alongside the regular label to determine which catch blocks should handle exceptions. Here’s a conceptual model of how this works:
var exceptionState: Int = 0 // Track exception handling state
when (label) {
0 -> {
exceptionState = 1 // Outer try active (NetworkException can be caught)
label = 1
val step1Result = operation1(this)
if (step1Result === COROUTINE_SUSPENDED) return step1Result
step1 = step1Result
}
1 -> {
step1 = result as Step1Result
exceptionState = 2 // Inner try active (ValidationException can be caught)
label = 2
val step2Result = operation2(step1, this)
if (step2Result === COROUTINE_SUSPENDED) return step2Result
step2 = step2Result
}
// ... more states for step3 ...
// Exception handling states
99 -> { // ValidationException handler
val e = result as ValidationException
handleValidation(e)
label = 100 // Jump to finally
}
98 -> { // NetworkException handler
val e = result as NetworkException
handleNetwork(e)
label = 100 // Jump to finally
}
100 -> { // Finally block (always executed)
cleanup()
// Continue or rethrow depending on whether exception was handled
}
}
The exceptionState variable tells the state machine which catch handlers should be considered when an exception is thrown. When operation2() fails with a ValidationException, the state machine knows to jump to state 99 (the ValidationException handler) because exceptionState indicates we’re in the inner try block. If instead it fails with a NetworkException, the state machine jumps to state 98 because that exception should be caught by the outer try block.
This sophisticated transformation ensures that your exception handling works exactly as you’d expect, even though execution is being suspended and resumed across potentially different threads. The state machine faithfully preserves the semantics of try-catch-finally blocks while enabling all the benefits of asynchronous, non-blocking execution.
Implementation Note:
This is a conceptual model that illustrates how exception handling works across suspension points. The actual implementation uses the JVM’s standard exception table mechanism combined with label-based state tracking.
The key insight is that the compiler ensures your try-catch-finally semantics are preserved exactly as you’d expect, even though execution may pause and resume across different threads.
8. Cancellation Mechanics
Cancellation is one of the most important aspects of coroutine design, yet it’s also one of the most misunderstood. Unlike thread interruption in traditional Java, coroutine cancellation is cooperative, structured, and built directly into the state machine model.
Understanding how cancellation flows through your code is essential for writing robust, leak-free applications. Let’s explore the full picture of scopes, contexts, and the cancellation mechanisms that make structured concurrency possible.
Kotlin coroutines also introduce the idea of structured concurrency through scopes. A scope contains a Job, and that job can be cancelled, propagating cancellation to all child coroutines.
Cancellation is cooperative: every cancellable suspend function (and any explicit call to ensureActive() or yield()) checks whether its Job is still active. If it isn’t, a CancellationException is thrown, effectively halting the coroutine’s progress.
8.a. The Job State Machine: Lifecycle Management
Before we can understand cancellation, we need to understand the Job — the object that represents a coroutine’s lifecycle. Interestingly, a Job is itself a state machine, running parallel to your coroutine’s execution state machine. While your coroutine transitions through its execution states (State 0, State 1, State 2…), the Job simultaneously tracks the coroutine’s lifecycle.
The Job state machine has these distinct states, each representing a phase in the coroutine’s life:
New ↓ Active ←──────────┐ ↓ │ Completing ←───────┤ (waiting for children) ↓ │ Completed │ │ Cancelling ↓ Cancelled
State transitions in the Job are atomic and thread-safe, which is crucial because multiple threads might be trying to query job status or request cancellation simultaneously.
When a coroutine is running normally, the Job is in the Active state. When you call job.cancel(), it transitions to Cancelling (giving children a chance to finish cleanup), and eventually reaches the terminal Cancelled state.
The brilliance of this dual state machine design is that your execution state machine and the Job lifecycle state machine communicate at well-defined points — the suspension boundaries. Your code only needs to check the Job’s state at these boundaries to implement cooperative cancellation.
8.b. Cancellation Checking: The Cooperative Model
Here’s a fundamental truth about coroutine cancellation that surprises many developers: The compiler does not automatically add cancellation checks everywhere. This is a deliberate design decision that gives you control over cancellation granularity while keeping the state machine efficient.
Cancellation is detected only when you explicitly call functions that check for it. The most common cancellation-aware operations are built-in suspend functions like delay, yield, and context-switching functions like withContext. If you write a long-running loop that never calls any of these functions, your coroutine will keep running even after its Job is cancelled.
Here’s a common pitfall — a CPU-intensive loop that ignores cancellation:
// ❌ BAD: This loop never checks cancellation
suspend fun processLargeDataset() {
// No automatic check here
for (i in 1..1000) {
heavyCalculation() // Runs even if cancelled!
}
}
The loop above will run to completion regardless of cancellation requests because there are no suspension points inside it. From the state machine’s perspective, this entire loop is within a single state, and there’s no transition where cancellation could be checked. This is by design — forcing cancellation checks at every line would be prohibitively expensive.
The fix is to explicitly add cancellation checks at appropriate intervals:
// ✅ GOOD: Regular cancellation checks
suspend fun processLargeDataset() {
for (i in 1..1000) {
ensureActive() // cheap Check-Only cancellation
// or: yield() // creates a suspension boundary + cancellation check
heavyCalculation()
}
}
Rule of thumb: add a boundary every some iterations (or per chunk) for CPU-bound loops.
By calling ensureActive() or yield(), you’re creating a micro-suspension point where the state machine can check if the Job is still active. If it’s been cancelled, these functions throw a CancellationException, which unwinds the state machine and triggers cleanup.
The practical rule: Each suspend function you call must be either already cancellable (like delay, yield, withContext) or wrapped with an explicit check if you need prompt cancellation.
This cooperative model gives you fine-grained control over cancellation points while keeping non-cancellable sections efficient.
yield() vs ensureActive()
yield() pauses a coroutine to allow other coroutines to run, checking for cancellation, while ensureActive() is a more direct and forceful check that throws a CancellationException immediately if the coroutine is cancelled.
yield():
- A suspending function used to make a coroutine cooperative by yielding control to the coroutine dispatcher, allowing other tasks to run. It also checks for cancellation and can trigger it if the coroutine is canceled while yielding.
ensureActive():
- A non-suspending function that performs an immediate check for cancellation; it throws a
CancellationExceptionif the coroutine’s job is no longer active. It’s ideal for immediately terminating long-running computations that don’t suspend on their own.
8.c. How Cancellation Actually Works: The Exception Path
Understanding the mechanics of cancellation helps demystify how it integrates with the state machine. Cancellation isn’t some special control flow mechanism — it’s simply an exception (a specific type called CancellationException) that propagates through your state machine just like any other exception.
When you call job.cancel(), here’s the sequence of events that unfolds:
// 1. Job transitions to Cancelling
job.cancel(CancellationException("User requested"))
// 2. All child jobs are cancelled
children.forEach { it.cancel() }
// 3. At next suspension point in each coroutine:
suspend fun work() {
delay(100) // ← Internally calls ensureActive()
}
// Inside delay() - conceptual implementation:
fun ensureActive() {
if (coroutineContext[Job]?.isActive != true) {
throw CancellationException("Job was cancelled")
}
}
Note:
The actual stdlib implementation checks !isActive and uses getCancellationException(), but the behavior is equivalent.
The key point is that cancellation checks throw CancellationException when the Job is no longer active.
Let’s trace through what happens when a running coroutine hits a cancellation check. This is where the Job state machine and your execution state machine interact:
- Your coroutine is in the middle of some work, perhaps in State 2
- Another thread calls
job.cancel(), transitioning the Job from Active to Cancelling - Your coroutine continues running until it hits a suspension point (because cancellation is cooperative)
- At the suspension point, the suspend function (like
delay) checkscoroutineContext[Job].isActive - The check returns false because the Job is Cancelling
- A
CancellationExceptionis thrown - The exception propagates through your state machine, triggering finally blocks
- The Job transitions from Cancelling to Cancelled
The CancellationException propagates through the state machine, unwinding execution just like any other exception would. This means your finally blocks run, resources are cleaned up, and the coroutine terminates gracefully. The beauty is that cancellation uses the same exception-handling machinery we’ve already explored—no special cases needed.
8.d. Non-Cancellable Blocks: Guaranteed Cleanup
Sometimes you need to perform cleanup operations that must complete even if the coroutine has been cancelled. A common example is closing files or network connections — you can’t leave these resources dangling just because someone cancelled the operation. This is where NonCancellable context comes in.
Consider this problematic scenario where cancellation could interrupt crucial cleanup:
suspend fun writeFile(data: Data) {
withContext(Dispatchers.IO) {
val file = openFile()
try {
file.write(data) // Might be cancelled here
} finally {
// ⚠️ Problem: If cancelled, close() might not complete!
file.close()
}
}
}
The issue is subtle but serious: if cancellation happens while we’re in the finally block, even file.close() might throw a CancellationException if it internally suspends. This could leave the file handle open, causing resource leaks.
The solution is to wrap critical cleanup code in a NonCancellable context, which temporarily suppresses cancellation checking:
suspend fun writeFileSafe(data: Data) {
withContext(Dispatchers.IO) {
val file = openFile()
try {
file.write(data)
} finally {
withContext(NonCancellable) {
// ✅ This always completes, even if parent is cancelled
file.close()
}
}
}
}
Inside the NonCancellable block, calls to ensureActive() or yield() will not throw CancellationException, even though the parent Job might be cancelled. This guarantees that your cleanup code runs to completion. Once you exit the NonCancellable block, normal cancellation checking resumes.
8.e. Timeout: Time-Based Cancellation
One of the most practical cancellation patterns is timeout — cancelling an operation if it takes too long. The withTimeout function combines cancellation with a timer to implement this pattern elegantly.
Here’s how you use it to protect against operations that might hang indefinitely:
try {
withTimeout(5000) {
val data = slowNetworkCall() // Must complete in 5 seconds
processData(data)
}
} catch (e: TimeoutCancellationException) {
handleTimeout()
}
The implementation concept behind withTimeout is straightforward but clever. It creates a timer that will cancel the coroutine’s Job after the specified delay:
// Conceptual implementation showing the pattern
fun <T> withTimeout(millis: Long, block: suspend () -> T): T {
val job = coroutineContext[Job]!!
val timer = Timer()
timer.schedule(millis) {
job.cancel(TimeoutCancellationException())
}
try {
return block()
} finally {
timer.cancel()
}
}
When the timeout expires, the timer fires and cancels the Job, which triggers the normal cancellation flow we’ve already discussed. If the operation completes before the timeout, the timer is cancelled in the finally block.
This pattern demonstrates how coroutine primitives compose beautifully — timeouts are just cancellation triggered by a timer rather than explicit user action.
8.f. Cancellation and State Machine Integration: The Complete Picture
Now let’s see how cancellation integrates with the state machine at a deep level. The CoroutineScope ties the Job’s state machine together with your coroutine’s execution state machine through the coroutineContext. Every time your coroutine suspends and resumes, there’s an opportunity to check the Job’s state.
Here’s a conceptual view of how the compiler might generate cancellation-aware code:
public final class ExampleKt {
public static final Object example(Continuation<? super Unit> continuation) {
ExampleStateMachine stateMachine = new ExampleStateMachine(continuation)
// Check if Job is still active before executing
if (JobSupport.get(continuation.getContext()).isActive()) {
Object result = someOperation(stateMachine)
if (result == COROUTINE_SUSPENDED) {
return result
}
} else {
// Job was cancelled, throw immediately
throw new JobCancellationException("Job was cancelled", null, null)
}
// ... rest of the state machine
}
}
This integration ensures that cancellation checks are performed as part of the state machine’s normal operation, not as a separate concern. The Job’s state and your execution state are synchronized at suspension boundaries.
8.g. Cooperative Cancellation Implementation: Why Boundaries Matter
The reason cancellation is cooperative becomes crystal clear when we look at the generated state machine code in the context of FSM theory. Remember that in our Finite State Machine model, we have states (code regions) and transitions (suspension points).
The cancellation check only happens during the transitions — specifically, during suspension points that call cancellable functions. In terms of our FSM model:
- The state machine only checks for cancellation when moving between states (at suspension points)
- There’s no external force that can stop execution while inside a single state (within a code region between suspensions)
This is fundamentally different from thread interruption, where the operating system can preemptively stop a thread at any instruction. With coroutines, interruption only happens at well-defined, explicit points — the suspension boundaries.
Inside a single state (the straight-line code between suspensions) the coroutine won’t be interrupted automatically. Only at boundaries — calls like delay, withContext, yield, or explicit ensureActive()—does cancellation take effect. For best responsiveness, insert such boundaries at sensible cadences in long CPU sections.
suspend fun cpuIntensiveWork() {
for (i in 1..1_000_000) {
if (i % 1_000 == 0) {
// Create a suspension boundary + cancellation check
yield()
}
doHeavyThing(i)
}
}
If you don’t want to hand off the thread (just check cancellation), probe the job explicitly:
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
suspend fun cpuIntensiveWork() {
val ctx = currentCoroutineContext()
for (i in 1..1_000_000) {
if (i % 1_000 == 0) {
// Cheap, no rescheduling — throws CancellationException if cancelled
ctx.ensureActive()
}
doHeavyThing(i)
}
}
Guidelines:
- Long, tight loops or CPU-bound code must insert explicit boundaries (
yield()orensureActive()) at a sensible cadence (e.g., every 1k–10k iterations, or per chunk). - I/O or blocking calls should be invoked from the right dispatcher (e.g.,
Dispatchers.IO) and, when possible, use cancellable APIs; otherwise add periodic cancellation checks around them. - Inside a single state (between suspensions), the coroutine won’t stop on its own — only transitions are cancellation-aware.
9. Structured Concurrency: The Complete Picture
Throughout this article, we’ve explored how a single coroutine operates as a Finite State Machine. But real applications involve many coroutines working together, each with its own execution FSM and Job lifecycle FSM.
Structured concurrency is the system that coordinates these multiple state machines into a predictable hierarchy.
We’ve seen hints of this coordination in Chapter 7 (exception propagation between coroutines) and Chapter 8 (how cancellation flows through Job hierarchies).
Now let’s see how these pieces fit together into a complete picture.
The core principle of Structured Concurrency:
“A scope cannot complete until all its children complete.”
9.a. The Coordination Problem
When you have multiple concurrent coroutines, you face fundamental questions:
- Lifetime: When can a parent coroutine complete if it has children still running?
- Failure: What happens when one child’s state machine hits an exception?
- Cancellation: How does cancelling one Job’s state machine affect related Jobs?
Structured concurrency answers these questions with a simple tree model: coroutines form parent-child relationships through their Jobs, and three rules govern how their state machines interact.
The Binding Mechanism: How State Machines Connect
Coordination provides the solution to orchestrate the individual coroutines under a cohesive Job.
You might wonder:
“if each coroutine has its own independent FSM, how does structured concurrency actually bind them together? How does a parent “know about” its children?”
The answer lies in two interconnected mechanisms:
1. The Job Hierarchy (The Family Tree)
When you launch a child coroutine, the parent’s Job is automatically inherited through the CoroutineContext:
val parentJob = Job()
val scope = CoroutineScope(parentJob)
scope.launch { // This launch creates a child Job
// child Job's parent = parentJob
}
The Job isn’t just a lifecycle state machine, but it’s also a data structure that maintains references to its children.
Internally, it looks conceptually like:
class JobImpl : Job {
private val children = mutableSetOf() // References to children
val parent: Job? = ... // Reference to parent
fun attachChild(child: Job) {
children.add(child)
child.parent = this
}
}
This creates a tree structure in heap memory where each Job node holds references to its parent and children.
When you cancel a parent, it simply iterates through its children sets and cancels each one.
2. The Continuation Chain (The Call Stack)
Each coroutine’s continuation also forms a chain:
Parent Continuation (label=2) ↓ (completion field) Child Continuation (label=1) ↓ (completion field) Grandchild Continuation (label=0)
When a child completes or fails, it calls completion.resumeWith() which resumes its parent’s state machine, passing results or exceptions up the chain.
The Complete Picture:
[Parent's Execution FSM] ←──→ [Parent's Job FSM] ↓ ↓ (continuation) (children set) ↓ ↓ [Child's Execution FSM] ←──→ [Child's Job FSM]
Both the Job tree (for lifecycle) and continuation chain (for execution flow) work together to implement structured concurrency.
The Jobs manage cancellation propagation, while continuations manage result/exception flow.
9.b. The Three Coordination Rules
We will examine the three fundamental rules that define “Structured Concurrency”.
Rule 1: Parent Waits for All Children
“When a coroutine scope reaches the end of its code block, it doesn’t complete immediately if it has launched children. Instead, it waits for all its children to finish first. ”
This ensures that you never accidentally destroy resources while child coroutines are still using them.
Let’s see this in action:
coroutineScope {
launch {
delay(1000)
println("Child 1 done")
}
launch {
delay(2000)
println("Child 2 done")
}
println("Parent created children")
}
println("Parent completed") // This won't print until both children finish
// Output:
// Parent created children
// Child 1 done (after 1 second)
// Child 2 done (after 2 seconds)
// Parent completed (after 2 seconds, when all children are done)
Notice how “Parent completed” doesn’t print until both children have finished, even though the parent reached the end of its code block immediately after launching them.
The coroutineScope function suspends until all launched coroutines complete. This is the foundation of structured concurrency – scopes define the lifetime boundaries of concurrent work.
Rule 2: Cancelling a Parent Cancels All Children
“The second rule is equally important: when you cancel a parent Job, that cancellation automatically propagates down to all its children, recursively through the entire subtree. ”
This prevents resource leaks and ensures that when you’re done with a scope, everything it started gets cleaned up.
val job = launch {
launch {
repeat(10) {
delay(100)
println("Child working...")
}
}
delay(300)
}
delay(250)
job.cancel() // Cancels both parent and child
// Output:
// Child working...
// Child working...
// (Then both are cancelled)
After 250ms, we cancel the parent job. Even though the child was in the middle of its work (planning to print 10 times), it gets cancelled along with the parent.
This automatic propagation is what makes structured concurrency “structured” — you don’t need to manually track and cancel every piece of work you started. Cancel the root, and the entire tree cleans up automatically.
This is particularly powerful in Android development. When an Activity is destroyed, cancelling its coroutine scope automatically cancels all network requests, database queries, and background work that scope launched. No memory leaks, no crashes from trying to update destroyed UI.
Rule 3: Child Failures Propagate Up (with variations)
“When a child’s execution FSM throws an unhandled exception, how it affects the parent depends on the scope type. This is where the two scope behaviors diverge.”
The third rule determines what happens when a child coroutine fails with an uncaught exception.
This is where structured concurrency becomes nuanced, because there are two different behaviors depending on what kind of scope you’re using:
- Regular scope (
coroutineScope): Child failure cancels the parent and all siblings (fail-fast behavior) - Supervisor scope (
supervisorScope): Child failures are isolated (fail-independent behavior)
The choice between these two behaviors depends on whether your operations are interdependent or independent, which we’ll explore in detail in the next section.
9.c. Two Scope Behaviors: Fail-Fast vs Fail-Independent
The relationship between parent and child state machines comes in two fundamentally different flavors. The choice between them determines whether failures in one state machine affect related state machines, and this decision shapes the resilience characteristics of your entire coroutine hierarchy.
coroutineScope — Fail-Fast (Synchronized Failure)
When you use coroutineScope, you’re establishing a strong coupling between the state machines of all children. If any child’s execution FSM encounters an unhandled exception, it triggers a coordinated shutdown of the entire scope. The parent Job immediately transitions to the Cancelling state, which cascades down to cancel all sibling Jobs.
This synchronized failure model is perfect for operations where partial completion is meaningless. Consider loading a user profile that requires user data, their posts, and their friends list. If fetching the user data fails, there’s no point continuing to fetch posts and friends — you can’t display a meaningful profile without the core user data.
try {
coroutineScope {
launch { throw Exception("Child failed") }
launch { delay(5000) } // Cancelled when sibling fails
}
} catch (e: Exception) {
// Parent catches the exception after cancelling all children
}
Let’s trace the state machine transitions when the first child fails. The child’s execution FSM throws an exception, which causes its Job to transition to the Cancelling state.
The coroutineScope parent receives notification of this child failure and immediately transitions its own Job to Cancelling. This parent transition triggers cancellation propagation to all other children – their Jobs transition to Cancelling as well, and their execution FSMs receive CancellationException at the next suspension point.
Once all children reach the Cancelled terminal state, the parent Job also reaches Cancelled and rethrows the original exception to the parent’s caller.
This fail-fast behavior ensures that no resources are wasted on operations that will ultimately be discarded. All related state machines fail together as a coordinated unit.
Job Offers
supervisorScope — Fail-Independent (Isolated Failure)
In contrast, supervisorScope creates isolation boundaries between child state machines. When a child’s execution FSM throws an exception, that failure is contained within that child’s Job. The exception causes the child’s Job to transition to Cancelled, but sibling Jobs continue executing unaffected. The parent Job remains in the Active state, supervising its children but not letting their failures affect each other.
This isolation model is ideal when you’re working with truly independent operations where partial success is valuable. A dashboard that loads user statistics, recent activity, and notifications is a perfect example. If the statistics service is down, users should still see their activity and notifications.
Each section’s success or failure is independent.
supervisorScope {
launch {
try { fetchData() }
catch (e: Exception) { handleError(e) }
}
launch {
// Continues even if sibling fails
fetchOtherData()
}
}
The critical difference with supervisor scopes is that exceptions don’t propagate automatically. When a child’s execution FSM throws an exception, the supervisor catches it and transitions that specific child’s Job to Cancelled, but the exception stops there.
The parent Job stays Active, and sibling Jobs continue running. This means you must explicitly handle exceptions within each child coroutine using try-catch blocks. If you don’t, the exception is logged but otherwise silently swallowed by the supervisor — a potential source of bugs if you’re not aware of this behavior.
Understanding when to use each scope type is crucial. If your operations are interdependent and the result only makes sense if everything succeeds, use coroutineScope for fail-fast behavior. If your operations are independent and partial results are useful, use supervisorScope but remember to handle exceptions explicitly in each child.
9.d. State Machine Coordination in Action
To see how these concepts work in practice, let’s trace through a typical Android data-loading scenario. This example demonstrates how multiple state machines coordinate their transitions, and how exceptions flow through the Job hierarchy.
viewModelScope.launch { // Job 1: Parent
try {
coroutineScope { // Job 2: Coordinating scope
val user = async { fetchUser() } // Job 3: Child A
val posts = async { fetchPosts() } // Job 4: Child B
Profile(user.await(), posts.await())
}
} catch (e: Exception) {
showError()
}
}
This structure creates a hierarchy of four Jobs, each with its own state machine. Job 1 (viewModelScope.launch) is the parent, Job 2 (coroutineScope) coordinates the data fetching, and Jobs 3 and 4 (the two async calls) fetch data concurrently.
Now let’s trace what happens if fetchUser() fails with a network exception. The failure starts in Job 3’s execution FSM when the network call throws an exception. Because this is inside an async block, the exception doesn’t immediately propagate – instead, it’s captured and stored in the Deferred object, and Job 3 transitions to the Completing state.
The real action begins when we call user.await() in Job 2’s execution FSM. The await() call retrieves the stored exception from the Deferred and rethrows it in Job 2’s context. Now we have an active exception in Job 2’s execution FSM, which causes Job 2 to transition to the Cancelling state. Because this is a coroutineScope, Job 2 immediately propagates cancellation to all its children.
Job 4 (still actively fetching posts) receives the cancellation signal. Its Job transitions to Cancelling, and at its next suspension point, its execution FSM receives a CancellationException that interrupts the fetch operation. Both Job 3 and Job 4 now work through their cleanup code (finally blocks) before reaching the terminal Cancelled state.
Once all children are cancelled, Job 2’s state machine completes its own cancellation and reaches the Cancelled state. The original exception is then rethrown to Job 1’s execution FSM, where it’s caught by our try-catch block and we can show an error to the user.
This choreography of state machine transitions — spanning four different Jobs, each with its own execution and lifecycle state machines — is what makes structured concurrency “structured.” The failure follows a predictable path through the hierarchy, ensuring consistent cleanup and preventing resource leaks.
9.e. Choosing the Right Coordination Strategy
When you’re structuring concurrent operations, the key decision is whether state machines should fail together or independently. This choice determines which scope builder to use and how to handle exceptions.
Use coroutineScope when operations are interdependent. If completing one operation without the others produces a meaningless or incorrect result, you want fail-fast behavior where any failure cancels everything. Loading a user profile that consists of user data, posts, and friend lists is a perfect example – without the user data, the posts and friends are useless. When you use coroutineScope, all child state machines are tightly coupled, and a failure in any one triggers a coordinated shutdown of all related state machines.
Use supervisorScope when operations are truly independent. If each operation produces value on its own and partial results are useful even when some operations fail, you want failure isolation. Loading a dashboard with statistics, activity feed, and notifications is the canonical example – if statistics fail to load, users still benefit from seeing their activity and notifications. With supervisorScope, each child’s state machine is isolated, and failures don’t cascade to siblings. However, you must explicitly handle exceptions in each child using try-catch blocks, because the supervisor won’t propagate them for you.
For operations that might hang indefinitely, wrap them with withTimeout. This creates a timer that will force the Job’s state machine to transition to Cancelling if the operation exceeds the specified duration:
try {
withTimeout(5000) { slowOperation() }
} catch (e: TimeoutCancellationException) {
handleTimeout()
}
The timeout mechanism works by creating a timer that monitors the operation’s Job. If the timeout expires, the timer triggers a transition from Active to Cancelling, which propagates through the operation’s state machine just like a manual cancellation would.
9.f. Connecting Back to the FSM Model
Now we can see how structured concurrency fits into the finite state machine model that we’ve been building throughout this article. Structured concurrency isn’t a separate concept — it’s simply the coordination layer for multiple state machines working together.
Remember that each coroutine has two FSMs running in parallel.
The execution FSM (with its label-based states: State 0, State 1, State 2…) handles the actual code execution and suspension points.
The Job FSM (with its lifecycle states: New, Active, Completing, Completed, Cancelling, Cancelled) manages the coroutine’s lifetime and coordination with other coroutines.
Structured concurrency defines how these state machines form relationships and interact. When you launch a child coroutine, you’re creating a new pair of state machines (execution + Job) and establishing a parent-child link through Job inheritance. This link determines how state transitions in one machine trigger transitions in related machines.
The three coordination rules we covered are essentially protocols for state machine interaction.
- Rule 1 (parent waits for children) means the parent’s Job FSM cannot transition from Completing to Completed until all child Job FSMs reach terminal states.
- Rule 2 (parent cancellation cascades) means transitioning the parent’s Job FSM to Cancelling triggers transitions to Cancelling in all child Job FSMs.
- Rule 3 (child failures propagate conditionally) defines when an exception in a child’s execution FSM should trigger transitions in parent and sibling Job FSMs.
The choice between coroutineScope and supervisorScope determines whether these coordination protocols enforce tight coupling or isolation. With coroutineScope, state transitions cascade freely – one failure triggers a wave of coordinated transitions throughout the hierarchy. With supervisorScope, state transitions are isolated – each child’s state machines operate independently, and failures don’t propagate to siblings.
This is why structured concurrency feels like a natural extension once you understand the FSM model. It’s not adding complexity on top of state machines — it’s simply defining clear rules for how multiple state machines coordinate their transitions. The compiler-generated code and the kotlinx.coroutines runtime implement these rules, ensuring that your hierarchy of state machines behaves predictably according to the coordination strategy you’ve chosen.
10. Performance and Advantages
Despite the seemingly complex transformations, coroutines are still quite efficient:
- Low Overhead: Storing local variables in an object and switching on an integer state is typically cheaper than using multiple OS threads.
- Structured Concurrency: Easy to cancel or manage multiple coroutines in a scope.
- Readable Code: You write straightforward sequential code, but it executes in a non-blocking fashion.
A neat analogy is that each coroutine is a reader with their own bookmark, and they all share a small set of reading rooms (threads). When a reader hits a suspenseful cliffhanger, they place their bookmark in the book and free up the room for another reader. When they’re ready to continue, they return to the library and pick up precisely where they left off.
11. Common Pitfalls and Misconceptions
- Coroutines vs. Threads: A single thread can handle many coroutines by switching between them at suspension points. Don’t confuse coroutines with actual threads at the OS level.
- Blocking vs. Suspending: A blocking function will stop the thread, not just the coroutine. Use proper suspending equivalents when possible.
- Cancellation: Coroutines rely on cooperative checks at suspension points. If you do heavy work without suspending, cancellation may be delayed.
- Exception Propagation: Exceptions bubble up through coroutines, potentially cancelling their parent scope unless you use something like a
SupervisorJob.
12. Disclaimer on Technical Notes and Implementation Details
While this article provides an accurate mental model of how coroutines work, some details are simplified for clarity:
State Machine Generation: The actual bytecode generated by the Kotlin compiler is more optimized than the conceptual Java code shown in examples. The compiler applies various optimizations including inline caching, label elimination, and state compression.
Exception Tables: The JVM’s exception handling mechanism uses bytecode exception tables rather than explicit state variables. Our conceptual models illustrate the behavior, not the exact bytecode implementation.
JVM Optimizations: Modern JVMs apply additional optimizations like method inlining, escape analysis, and just-in-time compilation. The performance characteristics described reflect typical behavior but may vary across JVM implementations.
Continuation Implementation: The actual ContinuationImpl and related classes include additional machinery for debugging, coroutine introspection, and integration with the coroutines framework that we haven’t covered in detail.
For the most authoritative information, consult:
These implementation details don’t change the fundamental concepts: coroutines compile to state machines, suspension preserves local state, and the continuation protocol enables non-blocking asynchronous code.
13. Conclusion
By now, you should have a solid understanding of the finite state machine model that powers Kotlin coroutines.
What once seemed like compiler magic is now a clear, mechanical transformation: your sequential-looking suspend functions become state machines with labels, continuations, and carefully preserved local variables.
Each suspend call creates a state transition, storing locals as fields and a label as your program counter. When the async operation completes, the compiler-generated code checks that label and jumps to the exact resumption point.
The genius of Kotlin coroutines is that you rarely need to think about state machines while coding — the abstraction just works. But when debugging unexpected behavior, optimizing performance, or pushing boundaries, understanding the FSM underneath empowers you to make informed decisions.
You’re no longer a developer who uses coroutines… you’re a developer who understands them.
Key Takeaways
- Coroutines compile to Finite State Machines with one state per suspension point (plus initial state).
- Suspension points are state transitions; code between them runs atomically within a state.
- Local variables become fields in the continuation object, surviving across suspensions.
- The label field acts as a program counter, directing resumption to the correct state
- Each coroutine has two FSMs: execution FSM (label-based) and Job FSM (lifecycle management)
- Structured concurrency coordinates multiple FSMs through Job trees and continuation chains
- Cancellation is cooperative, checked only at suspension boundaries
- Understanding the FSM model helps you debug, optimize, and architect better coroutine code
Further Reading
For additional insights into how Kotlin compiles suspending functions into a state machine and how continuations power this process, check out these resources:
Official Documentation
- Kotlin Language Specification — Coroutines
- Kotlin Coroutines Guide
- Suspending Functions | kotlinlang.org
- Kotlin Standard Library — Continuation
- kotlinx.coroutines API Reference
Source Code
Video Presentations
- KotlinConf 2017 — Deep Dive into Coroutines on JVM by Roman Elizarov
- KotlinConf 2018 — Kotlin Coroutines in Practice by Roman Elizarov
Android-Specific
Additional Resources
This article was previously published on proandroiddev.com




