Async, illustrated

Every Rust async fn compiles to a state machine. The runtime polls that state machine. Each .await is a state transition. Once you can see this picture, the async rules in the async concepts page stop being arbitrary and start being inevitable.

The code

async fn fetch_two(a: &str, b: &str)
    -> Result<(Bytes, Bytes), Error>
{
    let r1 = fetch(a).await?;
    let r2 = fetch(b).await?;
    Ok((r1, r2))
}

Three things happen, in order. Fetch a. Fetch b. Return both.

What the compiler builds underneath:

A future as a state machine

A diagram showing the async function compiled to a state machine with four states (Start, polling fetch a, polling fetch b, complete). Arrows between states are labeled with what triggers them. A runtime block at the top has an arrow into the state machine labeled poll and an arrow out labeled Pending or Ready.

Runtime

Tokio executor

.poll()

← Pending or Ready

0Startbegin fetch(a)1Polling await on fetch(a)2Polling bwait on fetch(b)Completereturn Ok(r1, r2)first poll→ Pendinga resolved→ Pendingb resolved→ Readypoll → Pendingpoll → Pending

Each .await is one state transition.

The compiler synthesizes the state struct, the resume logic, and the storage for every variable that has to outlive an await.

The runtime calls .poll() on the state machine. If the future returns Pending, the task is parked. If it returns Ready, the machine advances to the next state.

What the compiler synthesizes

For each async fn, rustc generates an anonymous struct that holds:

  • A state field (an enum: Start, AfterAwait0, AfterAwait1, Complete).
  • A field for every variable that lives across an .await (the variables that would have been on the stack in a synchronous function).
  • A poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Output> method.

Calling the function does not run the body. It returns the struct in its Start state. The body only runs when something calls .poll().

The rules fall out of the picture

Once you can see the state machine, every async rule becomes obvious.

Why you can't hold a std::sync::Mutex guard across .await. The guard is a field of the state machine struct between states. The state machine could be moved to a different thread by the runtime between polls. A std::sync::Mutex guard is not Send. The compiler refuses, or worse, deadlocks if you forced it. Use tokio::sync::Mutex, whose guard is Send.

Why .await requires an async context. The .poll() method is on a Future, called by a runtime. Outside an async block, there is no runtime calling poll. You need #[tokio::main], Runtime::block_on, or another async function to be the thing that calls poll.

Why tokio::spawn(async { ... }) requires Send. Spawn puts the future on a pool that can move it between threads. Every state of the state machine has to be movable to a new thread.

Why CPU-bound work blocks the executor. When .poll() runs CPU-bound code, the executor's thread is busy. Other tasks on the same thread cannot make progress. Use spawn_blocking to push CPU work to a different pool.

Why fire-and-forget loses errors. tokio::spawn returns a JoinHandle. If you don't .await it, errors disappear into the void. The state machine completed; nobody asked.

The substrate read

On the JVM, futures used to look like this too (CompletableFuture and friends), with callback chains. Project Loom changed the model: virtual threads pretend to be regular threads but yield to the runtime at blocking points. The JVM does the state-machine work invisibly. The advantage is that existing blocking code becomes async without changes. The cost is that the runtime is doing more work for you, which is mostly fine until you need to reason about it.

In C++, coroutines (co_await) compile to similar state machines, with the same self-referential challenges Rust solved with Pin. The mechanism is the same; the syntax is rougher.

Rust's choice is to put the state machine in plain sight. The Pin/Send/Sync gymnastics are not arbitrary; they're the price of letting you reason about exactly when execution pauses and resumes. The picture above is the picture every async Rust program is actually built from.

Further reading