Let’s gaze inside a Kotlin Coroutine structure and see the Finite State Machine (FSM) inside it. Suspension mechanics revealed.

1. Introduction
Kotlin coroutines have fast become a favorite tool for writing clean, maintainable asynchronous code on the JVM. 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.
By the end of this article, you’ll see how coroutines “pause” and “resume” seamlessly at the JVM level, preserving local variables and continuing right where they left off.
. . .
2. A Primer on Finite State Machines (FSM)
Finite State Machine (FSM)
A Finite State Machine (FSM) is a model from computer science that helps represent computations in a very structured way. An FSM has:
- A finite set of states, such as
STATE_0
,STATE_1
,STATE_2
, and so on. - Transitions between states, which define how the machine moves from one state to another based on the inputs it reads or events that occur.
- A starting state, which is typically where the machine begins execution.
- (Optionally) an accepting condition or final state, if you’re using the FSM to recognize patterns.
Each suspension is a potential transition, and each block of code before or after a suspension is a distinct state.
FSMs vs. Turing Machines
By contrast, a Turing Machine is a more powerful theoretical model with an infinite tape of symbols and a read/write head that can move in both directions.
However, when Kotlin compiles your coroutines, it just needs to track a finite set of suspension points within each function. That’s where the FSM concept fits perfectly.
In short, the decompiled code is best viewed as an FSM, not a Turing Machine because the compiler is dealing with a finite number of suspend points in your function.
. . .
3. Suspending Functions and the suspend
Keyword
When you see a function like:
suspend fun fetchData(): String { // maybe an HTTP call or a database operation return "some result" }
- it’s essentially being transformed into a function that takes an additional parameter: a
Continuation<T>
. - If the function needs to pause (i.e., wait for an external resource), it will return
COROUTINE_SUSPENDED
, along with a special object (often a synthetic class) that holds your local variables. - Later, when data arrives, the coroutine is resumed by calling something like
continuation.resume(theResult)
, at which point the code picks up exactly where it left off.
. . .
4. Deconstructing the State Machine: How Kotlin Compiles Coroutines
4.a. Continuation-Passing Style (CPS)
In Continuation-Passing Style, instead of returning your final answer as a single atomic operation, your function can “return” multiple times:
- It can return early if it needs to pause.
- It can return again (resumed) once the data is ready.
Continuation
The continuation-passing-style, mentioned above, is all managed by a Continuation object, which acts like a bookmark. If you think of each suspend function as a short story, the continuation is a notecard that says “Here’s the last sentence you read, and here’s what happened with your characters (variables).”
4.b. Synthetic Classes and Local Variables
Local variables must persist across suspensions. If a function’s call stack is blown away on suspension, how do those variables remain in memory?
The trick: the Kotlin compiler generates a synthetic class (often extending ContinuationImpl
) that contains fields for each local variable. So if you had:
suspend fun doSomething() { val token = getToken() delay(100) val result = fetchData(token) println(result) }
then the compiler creates something like a DoSomethingStateMachine
with fields for token
, result
, and an integer label
.
That class is updated before and after each suspend point.
4.c. The Big Switch Statement
- Each point where you call a suspend function is basically a state transition. The compiler sets
label = 1
, checks if your call returnsCOROUTINE_SUSPENDED
, and so on. - Then, when the coroutine resumes, it looks at the label to know if it should jump to case 1, 2, or 3 in a
switch
statement.
This is precisely how you can “pause” in the middle of a function and then continue from that same spot later.
. . .
5. Meet the Tape: Using FSM Terminology to Understand Execution
When we say “FSM,” we often imagine a tape of symbols that the machine reads. In coroutines, we don’t literally have an infinite tape, but we do have a finite set of instructions and suspension points:
- Tape: The sequential list of suspend calls and the code between them — like stepping through instructions.
- Pointer: The
label
variable that tells you which state/case you’re in. - Memory: The synthetic class fields that store local variables so they survive across suspensions.
5.a. Visual Diagram: The “Tape” of Execution
Imagine a horizontal strip that represents your function’s flow:
[ Step 1 ] - [ Step 2 ] - [ (S) Suspend Point ] - [ Step 3 ] - [ (S) Suspend Point ] - [ End ]
- You begin at
Step 1
(label 0). Once you reach the first suspension, you “park” the coroutine by savinglabel = 1
and returningCOROUTINE_SUSPENDED
. - Later, the runtime calls
resume()
, telling your code, “You can keep going from label 1.” Thus, you jump straight toStep 2
’s aftermath.
. . .
6. Pointer Movement: Resuming Execution After Suspension
When an operation like delay(100)
completes, the coroutine runtime calls continuation.resume(Unit)
. The pointer (i.e., the label) is already stored in the synthetic state machine object, so the code checks:
switch (label) { case 0: // ... case 1: // ... // ... }
The correct case is executed, effectively picking up the storyline exactly where we left off. This is all thanks to storing local variables in that synthetic object.
. . .
7. Exception Handling in Coroutines
Coroutines handle exceptions using similar state-machine logic. If a suspend call is within a try/catch, the compiler ensures that if an exception occurs, it transitions to the catch block or bubbles the exception up if uncaught.
Calls like continuation.resumeWithException(error)
trigger the jump to the appropriate state (the catch block), or they propagate further if there’s no catch in scope.
7.a. A Concrete Example: How Exceptions Flow Through the State Machine
suspend fun processData(): Int { println("Starting processData") try { val result = riskyApiCall() // might throw return result } catch (e: Exception) { println("Caught: $e") return -1 } }
If riskyApiCall()
throws an exception during suspension, the compiler logic calls resumeWithException(e)
. That triggers the jump into the catch section of your state machine, preserving the intuitive structure of try/catch even though we’re effectively “jumping” around code blocks at the bytecode level.
. . .
8. Scopes, Contexts, and Cancellation Mechanics
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 in that each suspension point checks if the job is still active. If it’s not, a CancellationException
is thrown, effectively halting the coroutine’s progress.
8.a. Cancellation Checks in the State Machine
The compiler injects cancellation checks into the state machine before each suspension point. When decompiled, we see the actual structure:
// Inside the state machine's invokeSuspend if (!isActive) { throw CancellationException() }
This check is inserted before each state transition in the switch statement, ensuring the coroutine can be cancelled at any suspension point.
8.b. Job Implementation
A Job in Kotlin coroutines is essentially another state machine that tracks its own states:
- New (initial state)
- Active (running)
- Completing (finishing work)
- Completed (terminal state)
- Cancelling (processing cancellation)
- Cancelled (terminal state)
The Job’s state transitions are atomic operations that affect all child coroutines in the scope. When a parent Job transitions to Cancelling, it triggers a cascade of state machine transitions in its children.
8.c. Scope and Context Integration
The CoroutineScope ties together the Job’s state machine with the coroutine’s state machine through the coroutineContext. When decompiled (greatly simplified and conceptual), you might see something like:
public final class ExampleKt { public static final Object example(Continuation<? super Unit> continuation) { ExampleStateMachine stateMachine = new ExampleStateMachine(continuation); if (JobSupport.get(continuation.getContext()).isActive()) { Object result = someOperation(stateMachine); if (result == COROUTINE_SUSPENDED) { return result; } } else { 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.
8.d. Cooperative Cancellation Implementation
The reason cancellation is cooperative becomes clear when we look at the generated state machine code. The cancellation check only happens during state transitions (suspension points). In terms of our FSM model:
- The “tape” only checks for cancellation when moving between states
- There’s no external force that can stop the execution within a state
- This is why CPU-intensive work should call
yield()
periodically, which creates an artificial suspension point and state transition where cancellation can be checked
9. Decompiled Code Examples
The following examples are simplified illustrations of how the compiler might transform your suspending functions. They’re designed to highlight the key concepts behind the state machine and continuation mechanics, rather than match the exact bytecode you would see in a real decompilation.
Compiler optimizations, different Kotlin versions, and build settings can all produce variations in the actual decompiled output.
9.a. fetchUserData()
Example (Detailed State Machine)
Let’s look at a more practical example:
suspend fun fetchUserData(): UserData { val token = getToken() // First suspension delay(100) // Second suspension val userData = fetchUser(token) // Third suspension return userData }
When decompiled (greatly simplified), we might see something conceptually like:
Object fetchUserData(Continuation<? super UserData> continuation) { final class FetchUserDataStateMachine extends ContinuationImpl { int label = 0; Object token; Object result; // holds return values or intermediate results @Override Object invokeSuspend(Object result) { this.result = result; switch (label) { case 0: label = 1; Object tokenResult = getToken(this); if (tokenResult == COROUTINE_SUSPENDED) { return tokenResult; // Pause } token = tokenResult; // Store the token case 1: label = 2; Object delayResult = delay(100, this); if (delayResult == COROUTINE_SUSPENDED) { return delayResult; // Pause } case 2: label = 3; Object userDataResult = fetchUser(token, this); if (userDataResult == COROUTINE_SUSPENDED) { return userDataResult; // Pause } return userDataResult; // Final return } return Unit.INSTANCE; } } // ... }
Each labeled case corresponds to a different state:
- Getting the token.
- Delaying for 100ms.
- Fetching user data with the token.
If any of these operations returns COROUTINE_SUSPENDED
, we exit the function early, effectively “parking” execution until resume
or resumeWithException
is called.
9.b. example()
FSM Parallels
To reinforce the FSM idea, consider:
suspend fun example() { val data = fetchData() // State 0 processData(data) // State 1 validateData(data) // State 2 }
A simplified decompiled version might could conceptually be:
class ExampleStateMachine extends ContinuationImpl { int label = 0; // "pointer" Object data; // memory to store intermediate result @Override Object invokeSuspend(Object result) { switch (label) { case 0: label = 1; return fetchData(this); // Could suspend case 1: data = result; label = 2; return processData(data, this); // Could suspend case 2: label = 3; return validateData(data, this); // Could suspend } return Unit.INSTANCE; } }
- Tape: The sequence of calls (
fetchData
→processData
→validateData
). - Pointer: The
label
variable, initially 0, then 1, then 2, etc. - Memory: The
data
field storing the result offetchData
.
You can see how each step in the tape is associated with a state transition in the switch
.
Job Offers
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. Conclusion
By now, you should have a solid understanding of how and why Kotlin coroutines compile down to a finite state machine rather than a full-blown Turing Machine. Each suspend call creates a “cliffhanger,” storing local variables and the current label in a synthetic class. When the async operation finishes, the compiler-generated code checks that label and jumps to the correct spot, resuming the narrative.
Key Takeaways
- Coroutines map neatly to Finite State Machines because each function has a finite number of suspension points.
- Turing Machines are more powerful theoretical constructs (with infinite tape), which we don’t need for the finite logic of suspend/resume.
- The generated code uses a
Continuation
object to keep track of state, local variables, and exceptions in a bigswitch
statement. - This approach allows you to write code in a synchronous-looking style, but under the covers, it’s entirely non-blocking and efficient.
Now that you’ve seen how the sausage is made, hopefully coroutines feel a bit less magical and a lot more ingenious. Keep this finite state machine model in mind next time you’re writing a suspending function, and you’ll better understand how your code pauses and resumes with minimal fuss.
. . .
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:
This article is previously published on proandroiddev.com.