Lowering Async and Await
An async function is a coroutine whose resumptions are driven by completing operations rather than by a consumer asking for the next value. The transformation is the same state machine, plus a continuation: something has to know what to call when the awaited thing finishes.
What does async actually do to a function, and why can I only await inside one?
Before: a function whose body contains await expressions. After: a state object exactly as in [[coroutine-lowering]] — a state tag plus the locals live across suspensions — plus a *continuation*: a callable that resumes this state machine, handed to whatever the function awaited. The extra piece over a generator is that a generator is pulled by its consumer and an async function is pushed by its dependency, so the state object must also record who to notify.
The lowering preserves observable behavior only if every local live across an await is stored in the state object, the resumption point is immediately after the await rather than at the start of the enclosing expression, and the continuation is invoked exactly once per completion. Invoking it twice re-runs code that already ran; never invoking it deadlocks the caller with no error. Any effect ordering the language guarantees — that the code before the first await runs synchronously on the calling thread, if the language says so — must survive the split.
Key points
- An async function is lowered to the same state machine as a generator, plus a continuation registered with whatever it awaits.
- The return type is transformed from
Tto a future or promise, which is what forces the property to propagate to callers. - The state object holds the locals live across each await; nested async calls embed the callee state inside the caller's.
- The compiler produces resume and registration; the executor decides who calls resume, when and on which thread.
- A continuation invoked twice re-runs code; a continuation never invoked hangs the caller with no error.
- Async removes a stack per concurrent task, not CPU work — it helps I/O-bound concurrency and does nothing for compute.
- A blocking call inside an async function holds the executor thread, and the symptom appears in unrelated tasks.
Same machine, different driver
Everything in [[coroutine-lowering]] applies unchanged: split the function at every suspension point, hoist the locals live across those points into a heap object, replace the body with a switch on a state tag. If you understand generators, you understand nine-tenths of async.
The remaining tenth is the direction of control. A generator suspends and its consumer decides when to resume it — next() is a pull. An async function suspends because something it needs has not happened yet, and it must be resumed when that thing happens, which the function itself cannot arrange. So the transformation adds one thing: at each suspension, the state machine registers a *continuation* with the awaited operation — "when you complete, call this" — and returns to its caller.
That is the whole of what async/await adds over a generator, and it is why the two are so often implemented on top of each other. C# built async on the iterator transformation. JavaScript async functions are specified in terms of generators plus a driver that feeds promise results back in. Python coroutines and generators share a frame implementation. Rust's async fn compiles to a generator implementing Future, where poll is the resume and the waker is the continuation.
| Aspect | Generator | Async function |
|---|---|---|
| Who resumes | The consumer, by calling next | The completion of the awaited operation, via a registered continuation |
| What suspension yields | A value to the consumer | Control to the caller, plus a pending future/promise |
| State object holds | State tag plus locals live across yields | The same, plus how to resume — a waker, a callback, or a task handle |
| How completion is signalled | A done flag on the next call | The returned future is resolved, and anything awaiting it is scheduled |
| What runs it | A for loop, or any consumer | An executor or event loop, which owns the thread the resume happens on |
| Colouring | Only a generator function may yield | Only an async function may await |
The transformation, with the continuation made explicit
The example below has two awaits and a local that must survive both. Read the after side for the two lines a generator would not have: the registration of the continuation before returning, and the fact that resume is what the completion calls rather than what the caller calls.
Note also what the function returns *immediately*: not the result, but a handle representing the eventual result. That is forced. The function has to give control back to its caller at the first suspension, and the caller has to be given something — so the return type of every async function is transformed from T to Future<T> or Promise<T> or Task<T>. This is a change to the signature, which is why it is visible in the type system and why it propagates: a caller that wants the value has to await it, which makes the caller async too.
That propagation is the whole of the "coloured functions" complaint, and it is a consequence of two decisions taken together: lowering per function, and representing "not finished yet" in the return type. A stackful implementation makes neither decision and therefore has neither property.
async fn handle(id: int) -> Response {
let user = await fetch_user(id) // suspension 1
let posts = await fetch_posts(user.id) // suspension 2
return render(user, posts)
}struct HandleState {
state: int // 0 = start, 1 = awaiting user, 2 = awaiting posts, 3 = done
id: int // live across suspension 1
user: User // live across suspension 2
awaited: Handle // what we are currently waiting on
output: Promise<Response> // what our own caller is waiting on
}
fn resume(s: *HandleState) {
switch s.state {
case 0: goto start
case 1: s.user = s.awaited.result(); goto after_await_1
case 2: goto after_await_2
}
start:
s.awaited = fetch_user(s.id)
s.state = 1
s.awaited.on_complete(|| resume(s)) // the continuation
return // control goes back to the caller
after_await_1:
s.awaited = fetch_posts(s.user.id)
s.state = 2
s.awaited.on_complete(|| resume(s))
return
after_await_2:
let posts = s.awaited.result()
s.state = 3
s.output.resolve(render(s.user, posts)) // now our own awaiters are scheduled
}
// The original call site: let r = await handle(7)
// becomes: alloc HandleState, resume(it), and await its output promiseOnly if id and user — the locals live across a suspension — are fields rather than registers, the continuation is registered before control returns so no completion can be missed, the resumption target is the instruction after the await, and each continuation fires exactly once. If the language guarantees that the body up to the first await runs synchronously on the calling thread, start must execute on the caller's stack before the first return, which constrains where the state object can be allocated.
If the continuation is registered after the awaited operation could already have completed, the completion is lost and the state machine never resumes — the caller waits forever with no error. If a completion can fire twice, after_await_1 runs twice and fetch_posts is called twice. And it is wrong to hoist a value out of the state object on the grounds that it "will still be in the register", because between the return and the resume an unbounded amount of unrelated code has run on that thread.
Where the resume happens, and why that is not the compiler's problem
ConfigureAwait(false) opts out — this is the source of a well-known class of deadlocks. Rust resumes wherever the executor polls, which may be any worker thread, and the future must therefore be Send to cross threads. The generated state machine is nearly identical in all three; every difference above comes from the runtime.The transformation produces a resume function and a registration call. It does not say who calls resume, on which thread, or in what order relative to other pending work. That is the executor or event loop, and it is a runtime component, not a compiler one. This split is worth stating plainly because it is the source of most confusion about async: the language feature is a lowering, and the behaviour you actually observe — fairness, ordering, which thread your code resumes on, whether blocking one task blocks others — comes from a scheduler the compiler knows nothing about.
It is also why the same lowering supports wildly different runtimes. Rust's async fn produces a state machine implementing Future and nothing else; the executor is a library, and swapping it changes every scheduling property while the generated code is identical. JavaScript's is fixed to the event loop. C#'s uses a synchronization context that can be swapped, which is why "await resumes on the UI thread" is true in one context and false in another with identical source.
The compiler-side obligations that remain are real, though. It must emit metadata sufficient to reconstruct a logical stack, or every async stack trace stops at the executor. It must arrange cleanup if a state machine is dropped while suspended, running the destructors for whatever locals were live — the same problem as [[stack-unwinding]], solved with the same kind of table. And it must not move a state object that contains a pointer into itself, which is a routine occurrence once a local borrows another local across an await.
What it costs, and the cost that surprises people
The direct costs are the state object per in-flight call and an indirect call per resumption. Both are small individually and both scale with concurrency: a server holding a hundred thousand in-flight requests holds a hundred thousand state objects, plus one per nested async call in each, since an async function that awaits another async function contains the callee's state inside its own. That nesting is why a deep async call chain can have a surprisingly large future — in Rust, size_of_val on a future of a deeply nested call is a routine surprise, and boxing an inner future is the routine fix.
The cost that surprises people is different: async makes nothing faster on its own. It removes the *stack* per concurrent task, not the work. A program that was CPU-bound is exactly as CPU-bound afterwards, with additional state machinery. The gain is entirely in how many concurrent waiting operations a process can hold, which is why async is transformative for an I/O-bound server and pointless for a compute loop.
And there is a cost that is not the compiler's fault but is created by the lowering: any blocking call inside an async function blocks the thread the executor was using for everything else. The state machine only gives control back at an await; a synchronous read in the middle of one holds the thread through the entire read. This is the single most common way async systems go wrong in production, and the symptom — unrelated tasks becoming slow — has no visible connection to the offending line.
- One state object per in-flight async call, and nested calls nest their states inside the caller's.
- An indirect call per resumption, plus whatever the executor charges for scheduling.
- No reduction in CPU work; the saving is a stack per task, not the work per task.
- A blocking call inside an async function holds the executor thread until it returns.
- Locals live across an await leave the register allocator, exactly as in a generator.
How it works
The steps, in the order the compiler takes them.
- Assign a state number to each
awaitplus a start and a completed state, exactly as for a generator. - Run liveness over the CFG with awaits as suspension edges, and hoist every variable live across one into the state object.
- Rewrite the function's return type to a future or promise, and make the entry point allocate the state object and return the handle.
- At each await, store the next state tag, register a continuation that calls resume on this state, and return control to whoever is running the machine.
- On resume, switch on the tag, read the completed operation's result out of the awaited handle, and jump to the instruction after that await.
- On completion, resolve the function's own output handle, which is what schedules anything awaiting this call.
- Emit drop or cleanup information for each state so a state machine destroyed while suspended runs the destructors for whatever was live, and emit async-stack metadata so traces can be reconstructed.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A request hangs with no error and no timeout fires: the continuation was never registered, or the completion raced ahead of the registration, so nothing ever resumes the state machine.
- A downstream call is made twice for one request, because a completion path invoked the continuation twice and the state machine re-entered a block that had already run.
- The whole service becomes slow and unrelated requests time out, because one async function called a synchronous blocking API and held an executor thread.
- Memory per in-flight request is far higher than the data it holds, because nested futures embed one another and one leaf holds a large buffer live across an await.
- A stack trace from a failed async operation shows only the executor and the resume function, with nothing about the request that started it.
- A deadlock appears only in one host environment: the runtime resumed on a captured context that was already blocked waiting for the same operation.
- A value is corrupted after resumption because a local held a pointer into another local across an await, and the state object was moved.
When it helps
- I/O-bound concurrency at high fan-out: many connections, most of them waiting, where a stack per connection is the limiting resource.
- Expressing a sequence of dependent asynchronous steps as straight-line code, where the callback form would nest to a depth nobody can read.
- Structured cancellation and timeouts, since a suspended state machine is an object that can be dropped rather than a thread that must be persuaded to stop.
- Environments with exactly one thread available, where the alternative is not threads but manual callbacks.
When it hurts
- CPU-bound work, where the transformation adds state and scheduling to a program that was never waiting for anything.
- Code paths that must call blocking APIs, which hold the executor thread and degrade everything sharing it.
- Deeply nested async call chains, where embedded futures produce a large per-request state and the fix — boxing — costs an allocation per level.
- Systems where debuggability matters more than concurrency density, since the physical stack no longer describes what is happening.
What it costs
Every one of these is paid by something.
- The state-machine lowering buys concurrency density — an object per task instead of a stack per task — and pays function colouring, since only a transformed function can suspend and the transformed return type propagates to every caller.
- Registering a continuation buys resumption driven by completion rather than by polling, and pays an indirect call per resumption plus the obligation to fire exactly once, which is a correctness burden on every completion path in the runtime.
- Embedding nested futures buys a single allocation for a whole call chain and pays in size: the state of every level is live for the duration of the outermost call, and one large leaf inflates every future above it.
- Handing scheduling to a runtime buys pluggable executors and pays predictability: identical generated code has different threading, fairness and deadlock behaviour depending on a component the compiler never sees.
What else you could do
What a different compiler or language does instead, and when that is better.
- Threads, with blocking calls and no transformation. Vastly simpler to write, read and debug, with a real stack and a real stack trace, and limited by memory per thread and context-switch cost at high concurrency.
- Stackful coroutines or virtual threads — Go's goroutines, Java's virtual threads. Blocking syntax, no colouring, no state-machine transformation, and memory proportional to (growable) stacks.
- Explicit callbacks or promise chains, which is the manual form of the same continuation-passing and produces exactly the nesting async syntax exists to flatten.
- An explicit event loop with a hand-written state machine per connection, which is what high-performance servers did before the syntax existed and which is still the most predictable option when the state is small.
See it for yourself
The flag, dump or tool that shows you this directly.
- Rust:
rustc -Z unpretty=mirshows the generated state machine and its discriminant;std::mem::size_of_val(&fut)gives the state size, andtokio-consoleshows live tasks and where they are suspended. - C#: decompile with ILSpy or
ildasm— an async method becomes a struct or class implementingIAsyncStateMachinewith anint <>1__statefield and hoisted locals as fields, plus a builder that owns the continuation. - JavaScript: transpile an async function to ES5 and read the regenerator output — an explicit switch over states driven by a promise-consuming loop. Chrome DevTools reconstructs async stack traces from the same information.
- Python:
dis.dison anasync defshowsSEND,GET_AWAITABLEandRESUME;asyncio.all_tasks()plustask.get_stack()gives the live suspended tasks and where each is parked. - Our async state machine viewer at
/compilers/loweringruns the transformation on a small async function and steps through the generated states with the live set shown at each suspension.
Plausible wrong readings
Stated the way a confident engineer states them.
- "async makes code faster." It makes waiting cheaper. CPU-bound code gains nothing and pays for the machinery.
- "await blocks until the result arrives." It returns to the caller and arranges to be resumed later. Nothing is blocked; that is the entire point.
- "async is sugar over promises." Promise chaining is a library pattern; async requires splitting a function and relocating its locals, which no library can do.
- "If it is async it will not block the thread." Only the awaits give control back. A synchronous call between two awaits holds the thread for its full duration.
- "Colouring is an arbitrary language restriction." It follows from lowering per function plus encoding pendingness in the return type. Languages that do neither have no colouring.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Marking a function async lets it pause at an await and continue when the thing it was waiting for finishes. The compiler cannot pause a stack frame, so it converts the function into a small object holding the variables that must survive the pause plus a number saying where it stopped, and a function that jumps back to that spot. When the awaited operation completes, it calls that function. The original call returns a handle rather than a value, which is why the caller has to await too.
practical
Three things to check in real async code. Is anything blocking? A synchronous call between awaits holds the executor thread and slows down everything else, and this is the most common production failure. How big are the futures? Nested async calls embed one another, so a deep chain holding one large buffer inflates every level — measure before assuming. And can you get a usable stack trace when something fails at three in the morning? Async traces need runtime support; find out what yours offers before you need it.
advanced
The interesting consequences are the ones that come from combining this transformation with the rest of the language. Destructors: a suspended state machine that is dropped must run cleanup for whatever locals were live at that state, which is a per-state cleanup table with the same structure as an unwind table. Self-reference: once a local borrows another local across an await, the state object contains a pointer into itself and can no longer be moved, which is exactly the problem Pin exists to express in a language where moves are otherwise free. Cancellation: because a suspended task is an object rather than a thread, cancelling it is dropping it — which is far cleaner than cancelling a thread, and which puts the burden on every await point being a valid place to stop, since it might be the last one. And optimization: an await on an already-complete value can, in principle, be resumed without ever returning, so implementations chase the fast path where a completed future collapses back into straight-line code. Whether a given compiler achieves that is a measurable, version-dependent question rather than a property of the language.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
ConfigureAwait(false) is used; Rust resumes on whichever executor thread polls the future, which is why futures crossing threads must be Send. Kotlin resumes on the coroutine's dispatcher. The generated state machine is essentially the same in all four and none of the observable threading behaviour comes from it.async fn that is never awaited never executes any of its body — including its side effects, which surprises people coming from the other two.If you were asked this in an interview
- Walk me through what the compiler generates for a function with two awaits and a local used after both.
- Why does making one function async tend to make its callers async?
- A service using async gets slower under load and the CPU is not saturated. What is your first hypothesis?
Connections
- Programming Languages & Runtime Internals — The executor or event loop that decides when, where and in what order a suspended state machine is resumedThe compiler emits a resume function and a registration call and stops there. Fairness, thread assignment, work stealing, and whether one task can starve another are decided entirely by a runtime component, and every observable difference between async in two languages with identical lowerings comes from it.
- Testing & Reliability Engineering — Testing concurrent code whose interleavings are decided by a schedulerThe lowering makes suspension points explicit, which is what makes deterministic replay and interleaving exploration possible for async code at all — but designing those tests, and deciding what coverage of interleavings means, belongs to reliability engineering rather than to the compiler.