skip to content

How does a compiler turn a sequential-looking function containing await points into something that can pause and resume? Explain the continuation-passing / state-machine transform.

level: seniorimportance: should knowfreq 34%

answer

  1. continuation = the rest of the computation
  2. split at awaits, dispatch on a state field
  3. live-across-suspension locals become frame fields
  4. suspension confined to transformed frames
  5. resume path also carries errors and cancellation

basics

~20 s

It splits the body at each await into separate blocks and rewrites it as a state machine: an object holds a state number plus every local that outlives an await. Resuming calls a resume function that switches on the state and jumps to the right block. The continuation is the callback you never wrote.

solid answer

~60 s

A **continuation** is "the rest of the computation" from a given point. Continuation-passing style makes it explicit: instead of returning, a function takes a callback and calls it with the result. A compiler applies that idea mechanically. It cuts the function body at every suspension point, producing blocks 0..N. It performs liveness analysis to find which locals are still needed after each cut, and hoists exactly those into fields of a generated object — the coroutine frame — along with a `state` field. The body becomes a single resume function shaped like `switch (state) { case 0: ...; case 1: ... }`. At a suspension point the code stores `state = k`, registers the frame as the continuation of the awaited operation, and returns. When the operation completes, the runtime calls resume, which switches to case `k` and continues with locals restored from fields. This is why suspension can only happen in transformed functions, why loops containing awaits become states you re-enter, and why the resume path is also the natural place to deliver exceptions and cancellation.

code

text · 11 lines
text
source:
  a = await stepOne()
  b = await stepTwo(a)
  return a + b

lowered:
  frame { state=0, a }
  resume(frame, in):
    0: frame.state=1; start stepOne(cont=frame); return SUSPENDED
    1: frame.a=in; frame.state=2; start stepTwo(frame.a, cont=frame); return SUSPENDED
    2: return DONE(frame.a + in)   // b never needed a field

go deeper

for a junior

Say the compiler chops the function at each await and turns it into a state machine whose object remembers which step you were on plus the variables you still need.

for a middle

Add liveness — only variables live across a suspension point become fields — and explain that the resume function is a switch on the state.

for a senior

Derive the consequences: suspension is confined to transformed frames, resumption is thread-agnostic, errors and cancellation ride the resume path, and physical stack traces need continuation-link stitching.

for a principal

Discuss it as a cost model: heap frames sized by liveness make per-task memory predictable and analysable, while the transform's inability to cross untransformed frames is the constraint that shapes your entire API surface and library ecosystem.

## Continuations A continuation is the rest of the program from a chosen point, viewed as a function. If you write `x = f(); g(x); h();`, the continuation of `f()` is "take a value, bind it to x, then run g and h". Continuation-passing style (CPS) rewrites code so that continuation is passed explicitly: `f(value => { g(value); h(); })`. Nothing returns; everything hands off. CPS is expressive but painful to write by hand — that is exactly the callback-nesting problem. The insight behind coroutines is that a compiler can perform this rewrite for you while you keep writing sequential code. ## The transform, step by step Given a function with suspension points: 1. **Split**: cut the body at each suspension point. Straight-line code between cuts becomes a block. Loops and branches containing suspension points get their entry points turned into states you can re-enter, so a loop body with an await becomes a state the machine returns to repeatedly. 2. **Liveness analysis**: determine which locals are live *across* each cut — that is, written before and read after. Only those must survive. 3. **Hoist**: allocate one object (the coroutine frame) containing an integer `state` field and one field per live-across-cut local. Locals used entirely inside one block stay on the ordinary stack; that is why frames are small. 4. **Rewrite**: replace the body with a resume function whose top is a dispatch on `state`, with each block as a case. 5. **Wire**: at each suspension point, set `state` to the next block, register the frame with the awaited operation as its continuation, and return to the caller/scheduler. ## A sketch Source: ``` function handle(id): user = await loadUser(id) orders = await loadOrders(user) return render(user, orders) ``` After transform (conceptually): ``` frame { state, id, user } resume(frame, incoming): switch frame.state: case 0: frame.state = 1 start loadUser(frame.id), continuation = frame return SUSPENDED case 1: frame.user = incoming frame.state = 2 start loadOrders(frame.user), continuation = frame return SUSPENDED case 2: return DONE(render(frame.user, incoming)) ``` `orders` never became a field: it is live only inside the last block. ## What the shape explains - **Why suspension is confined to transformed functions.** A helper the compiler did not rewrite has an ordinary stack frame with no state field and no place to store live locals. Returning to the scheduler would destroy it. So suspension must be declared, and every caller that wants to wait must itself be transformed. - **Why frames are small and predictable.** Their size is the live-across-suspension set, computed statically, not a worst-case stack reservation. - **Why resumption can land on another thread.** Resuming is just "call resume with this frame", and any carrier thread can make that call. Nothing is tied to the original stack. - **Where errors and cancellation go.** The frame is the single place the runtime holds a reference to the paused computation, so failures and cancellation signals are delivered through the same resume path — typically by resuming with an exceptional result instead of a value. - **Why recursion across suspension points is heap-allocated.** Each recursive call needs its own frame, so a deep recursive chain becomes a linked chain of frames rather than stack depth; unbounded recursion exhausts the heap instead of the stack. - **Why debugging looks strange.** The physical call stack at a resume shows the scheduler calling resume, not the logical chain of awaits. Runtimes reconstruct "async stack traces" by walking continuation links stored in frames. ## Relation to hand-written callbacks The transform produces the same machine you would build by hand with callbacks, minus the errors: it cannot forget a branch, it gets liveness exactly right, and it keeps one uniform error path. The gain is not performance over careful callbacks — it is that the source stays linear and reviewable while the executable form is CPS.

  • Why does a local variable used only between two awaits sometimes not appear in the coroutine frame?
    Because the frame stores only variables live across a suspension point. A local written and last read inside a single block never has to survive a pause, so it stays in ordinary registers or stack slots. That liveness analysis is why frames are far smaller than a whole stack.
  • What does a stack trace taken at a resume point actually show, and why?
    It shows the scheduler or event loop calling the coroutine's resume function, not the chain of awaits that led there, because the logical callers already returned when the coroutine first suspended. Runtimes recover a readable trace by walking the continuation links between frames and stitching a synthetic async stack.

saying these in an interview costs you the question

  • Claiming the compiler keeps the original stack frame alive somewhere — it does not; state moves to a heap object
  • Saying every local is copied into the frame regardless of liveness
  • Believing the transform gives a performance win over callbacks rather than an ergonomics and correctness win
  • Assuming the frame can capture callers, so any function can suspend
  • Treating exceptions as a separate mechanism instead of a value delivered through the same resume entry point

context