advanced24 min studyLesson 164 in Engineering

Coroutines as explicit state machines

Recover suspended state, ownership, and scheduling transitions hidden behind sequential-looking asynchronous code.

On this page

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.

Mretained≈∑i=1n(fi+bi+ri)M_{\mathrm{retained}}\approx\sum_{i=1}^{n}(f_i+b_i+r_i)
For n live tasks, f_i estimates continuation/frame bytes, b_i uniquely owned retained payload bytes, and r_i other uniquely attributed resources measured in bytes. Shared objects must not be counted repeatedly; runtime overhead and external resources need separate accounting.↔ Scroll wide equations horizontally.

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.

Tresume=Tdependency+DrunnableT_{\mathrm{resume}}=T_{\mathrm{dependency}}+D_{\mathrm{runnable}}
Measured from a common suspension instant, T_dependency is seconds until the dependency resolves and D_runnable is additional seconds until this continuation starts. The equality is bookkeeping for one trace, excluding cases where cancellation changes the awaited dependency.↔ Scroll wide equations horizontally.

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.

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.

  1. Each request retains 2 + 64 = 66 KiB under the stated unique-ownership model. Suspension releases execution time, not those referenced bytes.

  2. Multiply 2,000 × 66 KiB = 132,000 KiB. Dividing by 1,024 gives approximately 128.91 MiB of attributed retained memory.

  3. 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.

The result

An ownership-safe lifetime change saves 125 MiB in this constructed example. An async keyword by itself saves none of those retained payload bytes.

Explore the model

Interactive lab

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.

Suspended work can still accumulateFluid backlog starts at 4 and follows max(0, 4 + (λ − μ)t). The separate M/M/1 residence-time formula assumes Poisson arrivals, exponential service and λ < μ. Current readings: Fluid backlog: 4; Net rate · λ − μ: −1 /s; M/M/1 mean residence: 1 s. All parameters are adjustable with the labeled controls below.02.557.51001.534.56Elapsed time · sFluid backlogλ < μ
Fluid backlogCurrent time
Fluid backlog
4
Net rate · λ − μ
−1 /s
M/M/1 mean residence
1 s
t = 0 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.

Check your understanding

A dependency resolves while its coroutine’s event-loop thread is executing another handler. What follows?

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.

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.