Skip to content

← Async From First Principles step 12 of 25

Hard Framework

Hand-compile an `async fn` into a state machine

You have been told that an async fn compiles to a state machine. This is where you write one by hand and check that it behaves identically.

Given, and unchangeable:

pub async fn step(n: u32) -> u32 { /* pends once, then n * 2 */ }

pub async fn pipeline(n: u32) -> u32 {
    let a = step(n).await;
    let b = step(a + 1).await;
    a + b
}

Write a Pipeline struct with an explicit enum State and an impl Future<Output = u32> that does the same thing, then:

pub fn compare(inputs: Vec<u32>) -> Vec<(u32, u32)>

drives both versions for every input and returns the pairs. Every pair must be equal.

The transformation

An async fn body is cut at its suspension points. Everything between two .awaits becomes one arm of a state machine, and every local that is still alive across an .await has to be stored in the future itself, because the stack frame is gone the moment poll returns.

pipeline has two suspension points, so it needs four states:

  • Start — nothing has run yet.
  • AwaitingAstep(n) is in flight. Nothing else is live.
  • AwaitingBstep(a + 1) is in flight, and a is live, because the final line needs it. So this variant carries a.
  • Done — completed; polling again is a contract violation.

Look at AwaitingB carrying a. That is the whole reason a future has a size, why a big async fn is expensive to move, and why an async fn that holds a MutexGuard across an .await keeps that lock held — the guard is literally a field of the state machine. One picture, several mysteries retired.

The compiler does not literally build this enum; rustc lowers async through MIR coroutines. But it is conceptually equivalent, and the size rule carries over exactly: a future is as large as its largest state.

Two things the tests will catch

poll must loop. When the child in AwaitingA returns Ready, you move to AwaitingB — and you must then poll the new child in the same call, not return Pending and wait to be polled again. A single match with no surrounding loop produces a future that needs extra polls to finish. Since step wakes itself, the driver will re-poll and the answer eventually comes out right, but you will have written a future that does strictly more work than the compiler’s. Loop.

Do not poll a child after it returned Ready. Each state owns exactly one child and leaves it behind when it transitions.

The error you are meant to meet

The starter matches on self.state directly:

match this.state {
    State::AwaitingA(mut fut) => ...
}

E0507: cannot move out of this.state as enum variant AwaitingA which is behind a mutable reference. You have a &mut Pipeline, not a Pipeline. Moving the boxed future out of the enum would leave this.state holding garbage, and if poll panicked in the middle you would have a half-destroyed value. The borrow checker will not have it.

Matching on &mut this.state gets you references, but then you cannot rebuild a different variant out of the pieces you are borrowing. The idiomatic answer is the one every real state machine uses:

match std::mem::replace(&mut this.state, State::Done) {

mem::replace swaps a placeholder in and hands you the old value by value, atomically as far as the type system is concerned. There is never a moment when this.state is invalid. You now own the variant, can take the boxed child out of it, and must remember to write the new state back — including on the Pending path, where you put the same variant back unchanged.

That “leave a valid placeholder behind” move is worth internalising on its own. It is how you take ownership out of a struct you only borrow, and mem::take is the same trick when the placeholder can be Default::default().

Keep the children boxed

Store them as Pin<Box<dyn Future<Output = u32>>> behind a type alias — that keeps Pipeline Unpin, so &mut *self works, and it keeps clippy::type_complexity quiet. Storing an async fn future inline here would demand pin projection, and that is item 16.16.

Do not try to make the two versions poll the same number of times. rustc’s state machine is free to differ. Assert on outputs and ordering, which is what compare does.