Almost every confusing thing about async Rust is a consequence of one transformation. Once you can see the shape of the value an async fn returns, the mysteries stop being mysteries: why futures are !Unpin, why recursion needs boxing, why a MutexGuard held across an .await stays locked, why a deeply nested future is surprisingly large.
One picture, several problems retired.
The desugaring, in one line
async fn f(x: T) -> U { .. }
is sugar for
fn f(x: T) -> impl Future<Output = U> { .. }
Note what that says. f is an ordinary function. Calling it runs no part of the body; it constructs and returns a value. That value’s type is anonymous — you cannot name it, only describe it as “some type implementing Future<Output = U>“ — and it is a compiler-generated stackless coroutine.
“Stackless” is the important word. A green thread or a Go goroutine is stackful: it owns a real stack, and suspending it means keeping that stack around. A Rust future owns no stack. Its saved state is a flat struct, and it is exactly as big as it needs to be.
Conceptually: one state per suspension point
Take
async fn pipeline(n: u32) -> u32 {
let a = step(n).await;
let b = step(a + 1).await;
a + b
}
Cut the body at each .await. You get four positions the function can be parked at, and each one needs to remember exactly the locals that are still alive there:
enum PipelineState {
Start { n: u32 },
AwaitingA { fut: StepFuture },
AwaitingB { a: u32, fut: StepFuture }, // `a` is live: the last line needs it
Done,
}
poll is then a loop around a match on the current state: run forward until you hit an await, poll the child, and either advance to the next state or store the state back and return Pending.
Do not take that enum too literally. rustc does not emit a Rust enum with those variant names; async bodies are lowered to coroutines in MIR and the layout is chosen by the compiler, including a niche-packed discriminant and overlapping fields between states. The right phrasing is “conceptually equivalent to”. Everything below follows from the concept regardless of the encoding.
Item 16.12 has you write that enum by hand and check that it behaves identically. It is the single most clarifying exercise in this track.
The .await desugaring
The Reference spells out what expr.await becomes, and it is worth reading once:
match IntoFuture::into_future(expr) {
mut fut => loop {
match Pin::new_unchecked(&mut fut).poll(cx) {
Poll::Ready(v) => break v,
Poll::Pending => yield, // return Pending, resume here next time
}
}
}
Three things to notice. It goes through IntoFuture, not just Future, which is why you can .await things that are not futures yet. It loops, so a spurious wakeup re-polls rather than misbehaving. And yield is the coroutine primitive: return Pending now, and resume execution at exactly this point when polled again.
Four consequences
1. No heap allocation
The state machine is one flat value. Its size is the maximum over its states, since only one is live at a time. pipeline(3) returns a struct that lives wherever you put it — on the stack, in a Vec, inside another future. Nothing is allocated.
That is a real, structural advantage over runtimes that box every coroutine, and it is why joining five futures inline costs one struct rather than five allocations.
2. Futures are self-referential, and therefore !Unpin
async fn f(data: Vec<u8>) {
let first = &data[0]; // a reference INTO the future's own storage
something().await; // both `data` and `first` are live here
println!("{first}");
}
The state that survives that .await contains data and a pointer into data. The future is a struct with a pointer to one of its own fields.
Move that struct — copy the bytes to a new address — and the pointer still points at the old one. That is a dangling pointer produced by a plain memcpy, in safe code, with no unsafe anywhere.
Which is precisely why Future::poll takes self: Pin<&mut Self> instead of &mut self. It is not decoration; it is the type system refusing to let you move a future once polling has begun. Article 16.10 is entirely about that.
Note the “once polling has begun” part: a freshly constructed future has not run any code and so holds no internal references. You can move it freely right up until you pin it. That is why block_on(f()) works — the future is moved into block_on, and only then pinned.
3. Recursion needs indirection
If async fn f awaits f, then one of the locals live across that .await is a future of type F — so size_of::<F>() would have to be at least size_of::<F>() plus everything else. No finite number satisfies that.
rustc says so directly:
error[E0733]: recursion in an async fn requires boxing
= note: a recursive `async fn` call must introduce indirection
such as `Box::pin` to avoid an infinitely sized future
Pin<Box<dyn Future<Output = T>>> has a fixed size whatever it points at, and the recursion terminates. The cost is one allocation per level, which is why deep async recursion is a design decision rather than a free abstraction. Item 16.23 makes you do it.
4. Whatever is live across an .await is stored
This is the one that costs people production incidents.
let guard = mutex.lock().unwrap();
do_something(&guard);
other_service().await; // guard is live here
use_it(&guard);
guard is a local that survives the suspension, so it is a field of the future. The lock is held for the entire duration of the await — which might be a network round trip, or forever if the task is never polled again.
Three follow-on effects: throughput collapses; you deadlock if the awaited work needs the same lock; and because MutexGuard is not Send, the whole future becomes !Send and a multi-threaded executor will refuse it, with an error message that points at the future and never mentions the guard.
clippy::await_holding_lock and clippy::await_holding_refcell_ref exist for exactly this, and item 16.22 is where you meet them.
💡size_of an async fn's future is the max over its states. So what happens to the size of outer if it awaits middle, which awaits inner, each holding a 1 KiB buffer across its await?
click to reveal
They nest, so the sizes add up: outer must contain middle‘s entire state machine as one of its fields, which contains inner‘s. Roughly 3 KiB, plus discriminants and padding.
That composes badly. A chain of ten small async functions each holding a modest local produces a future you would not want to move around casually, and moving it is exactly what Vec::push, spawn, and returning it all do. Real symptoms include surprising stack usage at task-spawn time and stack overflows in debug builds where the compiler’s state-machine layout optimisations are less aggressive.
The remedies are the obvious ones once you can see the cause: shrink what is live across awaits (do the buffer work in a block that ends before the await, or take it out with mem::take), or Box::pin an inner future to break the nesting at a cost of one allocation.
clippy::large_futures exists to warn about oversized futures. It is in the pedantic group, so it is off by default — including on this site’s gate — but it is worth switching on in a service that spawns a lot of tasks.
Why this is the right time to learn it
You could read this article first and it would be a list of assertions. Read after writing a block_on, a hand-rolled future and a waker, every claim is checkable against something you have already built.
And the next two items make it concrete: 16.10 explains Pin as the answer to consequence 2, and 16.12 has you build the enum from consequence 1 and prove it matches.