Coroutines as explicit state machines
Recover suspended state, ownership, and scheduling transitions hidden behind sequential-looking asynchronous code.
On this page
The question for today
What you’ll understand
- Trace running, suspended, runnable, and terminal coroutine states.
- Identify values that remain live across a suspension.
- Calculate retained-memory costs without confusing tasks with threads.
A coroutine lets code read like a sequence while execution pauses and resumes around asynchronous dependencies. The convenience is real, but the suspended computation has not vanished. Its next instruction, local values, and cleanup obligations must remain representable. Reading a coroutine as a state machine reveals hidden memory retention, reentrancy hazards, and the difference between being ready to resume and actually receiving CPU time.
Suspension preserves a continuation
Imagine a handler that reads a header, allocates a body buffer, waits for the body, and sends a response. At each genuine suspension, it needs a continuation: enough information to resume at the right step with the relevant values intact. Compilers and runtimes represent this in different ways, but the conceptual state machine is portable. A suspended task is not the same as a blocked operating-system thread. Many suspended tasks can share one loop thread while still consuming memory and external resources.
Distinguish running, suspended on a dependency, runnable after that dependency resolves, and terminal after success, failure, or completed cancellation handling. Resolution does not imply immediate execution. A continuation may wait behind other runnable work. Conversely, awaiting an already-ready result may not cause meaningful suspension. Avoid writing correctness arguments that depend on every await permitting another task to run. Mark actual state transitions in a trace rather than counting keywords in the source.
State may change while a task is suspended
A task can check a shared condition, suspend, and resume after another task has changed that condition. Even without simultaneous execution on multiple CPUs, interleaving across suspension points can invalidate assumptions. If a connection changes generation or a request is superseded, the resumed continuation must verify that it still owns the right to update state. Use explicit ownership transfers, synchronization appropriate to the runtime, or single-owner message handling. Sequential-looking syntax is not a transaction spanning every suspension.
Local references also keep objects alive. A parser that retains a large receive buffer across a slow downstream await can consume substantial memory under concurrency, even if its CPU time is tiny. Release unnecessary references before the suspension when ownership permits, copy only the needed compact result when beneficial, or bound the number of such tasks. Do not free a buffer still used by an outstanding low-level operation. The continuation’s lifetime and the kernel operation’s lifetime may end at different moments.
Make hidden state observable
Log stable task and request identities, the current logical stage, suspension cause, and terminal reason. A stack snapshot can show where a task is waiting, but interpreting it requires understanding the dependency and ownership relationship. Count tasks by stage instead of reporting only a total. Thousands awaiting a bounded pool may suggest different action from thousands blocked on one downstream connection. Resource limits should follow the stage that actually retains expensive buffers, not merely a global estimate of average task cost.
The queue lab offers a related capacity view, not a coroutine scheduler. Press Reset, set arrival rate to 4 and service rate to 3, predict accumulation from the initial four-job backlog, then press Play. To compare draining, press Reset again, explicitly set arrival rate to 2 and service rate to 3, predict the new run, then press Play. These independent runs do not carry the earlier backlog forward. The controls do not create, suspend, or resume individual coroutines and cannot measure frame sizes. Alongside the lab, draw the header-read, body-read, response-write states and annotate which objects survive each suspension. Ask which reference can be released before a slow response operation without violating the remaining operation’s buffer contract.
Work it through
Count buffers retained at suspension
An assumed runtime keeps 2 KiB of continuation state per live request. Each request has a unique 64 KiB body buffer that remains referenced while waiting for a downstream result. Exactly 2,000 such requests are suspended; ignore shared objects and other overhead.
Each request retains 2 + 64 = 66 KiB under the stated unique-ownership model. Suspension releases execution time, not those referenced bytes.
Multiply 2,000 × 66 KiB = 132,000 KiB. Dividing by 1,024 gives approximately 128.91 MiB of attributed retained memory.
If a validated design releases each body buffer before this wait while retaining the 2 KiB continuation, this category becomes 4,000 KiB, about 3.91 MiB. Verify that no outstanding operation or future parsing step still needs the body.
An ownership-safe lifetime change saves 125 MiB in this constructed example. An async keyword by itself saves none of those retained payload bytes.
The living notebook
Explore the model
Interactive lab · queue
Suspended work can still accumulate
Press Reset, set arrival rate to 4 and service rate to 3, predict growth, then press Play. Compare draining from a separate Reset with both rates explicitly set to 2 and 3. Each run starts at four jobs; it illustrates pending-work pressure without coroutine frames, task scheduling, or buffer-lifetime measurement.
- Fluid backlog
- 4
- Net rate · λ − μ
- −1 /s
- M/M/1 mean residence
- 1 s
Fluid backlog starts at 4 and follows max(0, 4 + (λ − μ)t). The separate M/M/1 residence-time formula assumes Poisson arrivals, exponential service and λ < μ. Parameter changes pause playback and recompute the model at the current elapsed time. Reset restores both parameters and returns time to zero. Playback starts paused. Adjust either parameter, then choose Play or Step.
Look forSuspending execution does not remove a task’s retained memory or cleanup obligations.
Pause & reason
Check your understanding
Your turn
Try it yourself
A task suspends at time zero. Its dependency resolves at 12 milliseconds, but the task resumes at 19 milliseconds. Find runnable delay. If 500 tasks each uniquely retain 20 KiB at the same stage, calculate that payload total.
Take a moment to make a prediction or work it out on paper. Open a hint when you need one.
Give me a hint
Use one clock domain and distinguish waiting time from retained bytes.
Compare with the solution
Runnable delay is 19 − 12 = 7 milliseconds. The retained payload is 500 × 20 = 10,000 KiB, about 9.77 MiB. That memory estimate excludes frame/runtime overhead and assumes buffers are distinct. Neither quantity can be inferred solely from the number of await expressions.
Keep the idea
Take it with you
- A coroutine continuation preserves the state needed after suspension.
- Ready to resume and actually running are different scheduling states.
- References and in-flight operations determine retained-resource lifetimes.
Read further
Original sources and deeper explanations to follow at your own pace.
Optional progress, saved in this browser when available. Revisit any lesson at any time.
Lesson not marked complete.