skip to content

Where are a suspend function's local variables stored across a suspension, and what is the `$continuation` / `ContinuationImpl` object?

level: middleimportance: should knowfreq 45%

answer

  1. Stack unwinds, so locals spill to heap fields
  2. $continuation extends ContinuationImpl
  3. Fields: locals + label + result + completion
  4. Allocated once on first suspension, reused
  5. completion chain = coroutine logical stack

basics

~10 s

Locals that must survive a pause are saved as fields on a generated continuation object instead of living on the stack. That object also holds the label, so resuming restores everything needed to continue.

solid answer

~40 s

Because the call stack unwinds at a suspension point, locals that span suspensions cannot stay on the JVM stack. The compiler generates a continuation class (often named `$continuation`, a subclass of `ContinuationImpl`) per suspend function. Its fields hold: the saved local variables, the `label` state index, the `result` of the last resumed call, and a reference to the *completion* continuation (the caller). When the function first suspends, it allocates this object once and reuses it for every later state. On resume, the runtime calls its `invokeSuspend`, which re-invokes the function body; the body reads its locals back out of these fields. So a coroutine's 'stack' is effectively a heap-allocated linked list of continuation objects, each pointing to its completion — that chain is what produces coroutine stack traces.

code

kotlin · 10 lines
kotlin
// Locals a and b live across suspensions, so they become fields:
suspend fun pipeline(): Int {
    val a = stepOne()   // 'a' must survive while stepTwo suspends
    val b = stepTwo(a)
    return a + b
}
// Generated: class Pipeline$cont : ContinuationImpl {
//   var label; var a; var result; val completion
//   override fun invokeSuspend(r) = pipeline(this)
// }

go deeper

for a junior

Knows locals are saved somewhere off the stack so the function can resume with them intact.

for a middle

Names the generated $continuation/ContinuationImpl object and its fields (locals, label, result, completion).

for a senior

Explains lazy allocate-once on first suspension, the fast-path no-allocation property, and invokeSuspend re-entry.

for a principal

Connects the completion chain to coroutine stack traces and reasons about heap pressure / escape-analysis implications of spilled locals.

## The stack problem When a suspend function suspends, the native JVM call stack **unwinds** — control returns up to the caller and the thread is freed. Anything stored in stack slots (local variables) would be lost. So the compiler must spill locals that are live across a suspension onto the **heap**. ## The generated continuation class For a suspend function, the compiler emits an inner continuation class — in bytecode you'll see a name like `MyClass$myFun$1`, commonly described as `$continuation`. It extends **`ContinuationImpl`** (which extends `BaseContinuationImpl`, which implements `Continuation`). Its fields include: - **the live locals** that cross suspension points (e.g. `var a`, `var b`), - **`label`** — the state-machine cursor, - **`result`** — where a resumed value/exception is stashed before re-entry, - **`completion`** — the *parent* continuation to resume when this function finishes. ```kotlin // Conceptual class PipelineContinuation( val completion: Continuation<Int> ) : ContinuationImpl(completion) { var label = 0 var a = 0 var result: Any? = null override fun invokeSuspend(r: Result<Any?>): Any? { result = r.getOrThrow() return pipeline(this) // re-enter the body } } ``` ## Allocate-once The continuation object is created **lazily on the first suspension** and reused for all subsequent states of that call. If the function never suspends (fast path), no continuation object is allocated at all — an important performance property. ## How resume works 1. The awaited operation calls `continuation.resumeWith(result)`. 2. `BaseContinuationImpl.resumeWith` stores the result and calls **`invokeSuspend`**. 3. `invokeSuspend` re-invokes the function body; `when (label)` jumps to the right branch and locals are read back from fields. 4. When the body completes (returns a real value, not the sentinel), `resumeWith` propagates that value to **`completion`** — the parent continuation. ## The continuation chain = the coroutine stack Each continuation references its `completion`, forming a singly linked list from the innermost suspend function up to the root builder. This heap-resident chain is the coroutine's logical call stack, and `kotlinx.coroutines` walks it to build **coroutine-aware stack traces** (and what `DEBUG` mode / `CoroutineStackTraces` enrich). ## Terms to name - `ContinuationImpl` / `BaseContinuationImpl` — base classes. - `invokeSuspend` — the resume entry point. - `completion` — the parent continuation reference. - 'spilled locals' — locals moved from stack to continuation fields.

  • Is the continuation object allocated even when the function never suspends?
    No. On the fast path it runs straight through and returns; the continuation object is allocated lazily only at the first real suspension.
  • How do coroutine-aware stack traces relate to continuation objects?
    Each continuation holds a `completion` reference to its parent, forming a chain from the innermost suspend call to the root builder; the library walks that chain to reconstruct a logical async stack trace.

Like saving a video game: your inventory (locals) and current level (label) are written to a save file (the continuation object) so you can power off and resume exactly where you were.

saying these in an interview costs you the question

  • Says locals stay on the JVM stack across suspension
  • Doesn't know a continuation class is generated per function
  • Thinks a new continuation is allocated for every suspension point
  • Cannot name the completion/parent reference
  • Confuses the continuation object with a Thread or stack frame

context