Futures in Rust Explained as State Machines You Can Read
An async fn compiles to a state machine that implements Future. See the generated shape, what poll does, and why a future does nothing until it is polled.

An async fn does not run when you call it. It builds a value, and that value is a state machine that implements the Future trait. Nothing happens until something calls poll on it, and each call runs the code up to the next point where it has to wait. Once you can see that machine, the odd rules of async Rust (futures do nothing on their own, a task can only be switched at an .await, a future's size depends on what it holds across an await) stop being rules to memorise and become consequences. This post builds a future by hand, drives it with a hand-made executor loop, prints the size of a compiler-generated one, and shows where the state lives. Everything compiled on Rust 1.99, edition 2024.
The trait, in full
A future has one method:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T),
Pending,
}poll asks "are you done yet?". The answer is Ready(value) or Pending. On Pending, the future promises to arrange for the Waker inside the Context to be called when it might make progress; the executor then polls it again. A future that returns Pending without having stored the waker anywhere is never polled again, which is the root cause of the "future that never finishes" bug in tasks that never finish. The Pin in the signature means the future cannot be moved between polls, which matters once it holds references into itself.
A future written by hand
The simplest useful future counts down. Each poll decrements a counter and asks to be polled again until it reaches zero. It is a state machine with the state stored in a field:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, Waker};
struct Countdown {
remaining: u32,
polls: u32,
}
impl Future for Countdown {
type Output = String;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<String> {
self.polls += 1;
if self.remaining == 0 {
Poll::Ready(format!("liftoff after {} polls", self.polls))
} else {
self.remaining -= 1;
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
fn block_on<F: Future>(fut: F) -> F::Output {
let mut fut = Box::pin(fut);
let mut cx = Context::from_waker(Waker::noop());
loop {
if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
return v;
}
}
}
fn main() {
let f = Countdown { remaining: 3, polls: 0 };
println!("created, nothing has run yet");
println!("{}", block_on(f));
}created, nothing has run yet
liftoff after 4 pollsblock_on is a complete, if useless, executor: it polls in a loop with a no-op waker (Waker::noop is stable since Rust 1.85). A real executor parks the thread until the waker fires, instead of spinning. The point is that the whole mechanism fits on a page: a future, a context holding a waker, a loop. Building an executor that actually waits is a good afternoon project, laid out in building a tiny executor.
What the compiler writes for async fn
Take a function with two await points:
async fn double_after(n: u32) -> u32 {
let first = ready(n).await;
let second = ready(first * 2).await;
first + second
}
async fn ready(n: u32) -> u32 {
n
}The compiler turns double_after into a type that is roughly an enum with one variant per suspension point. This is a hand-written equivalent, which has the same shape as the generated code but spells out the states:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, Waker};
struct Yield(bool);
impl Future for Yield {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if self.0 {
Poll::Ready(())
} else {
self.0 = true;
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
enum DoubleAfter {
Start { n: u32 },
WaitFirst { n: u32, y: Yield },
WaitSecond { first: u32, y: Yield },
Done,
}
impl Future for DoubleAfter {
type Output = u32;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
loop {
match &mut *self {
DoubleAfter::Start { n } => {
let n = *n;
println!(" start: n = {n}");
*self = DoubleAfter::WaitFirst { n, y: Yield(false) };
}
DoubleAfter::WaitFirst { n, y } => {
if Pin::new(y).poll(cx).is_pending() {
println!(" first await: pending");
return Poll::Pending;
}
let first = *n;
*self = DoubleAfter::WaitSecond { first, y: Yield(false) };
}
DoubleAfter::WaitSecond { first, y } => {
if Pin::new(y).poll(cx).is_pending() {
println!(" second await: pending");
return Poll::Pending;
}
let first = *first;
*self = DoubleAfter::Done;
return Poll::Ready(first + first * 2);
}
DoubleAfter::Done => panic!("polled after completion"),
}
}
}
}
fn main() {
let mut fut = Box::pin(DoubleAfter::Start { n: 21 });
let mut cx = Context::from_waker(Waker::noop());
let mut polls = 0;
loop {
polls += 1;
println!("poll {polls}");
if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
println!("ready: {v}");
break;
}
}
}poll 1
start: n = 21
first await: pending
poll 2
second await: pending
poll 3
ready: 63Each .await became a state. The variables that are alive across an await (n in the first wait, first in the second) are stored in the variant, because the function's stack frame will not survive a return to the executor. Variables that are not alive across an await never enter the enum. That is what makes a future's size equal to its largest state, not the sum of everything, and it is why holding a large buffer across an .await bloats every future that contains it. The panic arm shows the contract: polling a finished future is a bug, and the real generated code panics with "`async fn` resumed after completion".
Seeing the size of a real one
The generated future's size can be printed directly with size_of_val, and the experiment shows the rule about what lives across an await:
use std::mem::size_of_val;
async fn nothing_held() -> u8 {
let _scratch = [0u8; 4096];
tick().await;
1
}
async fn buffer_held() -> usize {
let scratch = [0u8; 4096];
tick().await;
scratch.len()
}
async fn tick() {}
fn main() {
let a = nothing_held();
let b = buffer_held();
let c = tick();
println!("tick(): {} bytes", size_of_val(&c));
println!("nothing_held(): {} bytes", size_of_val(&a));
println!("buffer_held(): {} bytes", size_of_val(&b));
}tick(): 1 bytes
nothing_held(): 2 bytes
buffer_held(): 4098 bytesThe first function declares a 4 KiB array and never uses it after the await, so the compiler does not store it, and the future stays tiny. The second uses the array after the await, so it must be saved in the state: the future is over 4 KiB, and so is every future that awaits it, and every task that spawns one. Recursive and deeply nested async code multiplies this. The practical advice is to keep big buffers on the heap (Vec or Box) when they cross an await. A future that is too large to move cheaply also shows up as a large stack frame in debug builds, which is one cause of stack overflows covered in debugging stack overflows.
Laziness: the consequence you will meet first
Because a future is just a value until polled, creating one has no side effects. This surprises people from JavaScript, where calling an async function starts it:
use std::future::Future;
use std::task::{Context, Poll, Waker};
use std::pin::pin;
async fn announce(label: &str) -> usize {
println!("running {label}");
label.len()
}
fn main() {
let fut = announce("first");
println!("future created");
let _forgotten = announce("never polled");
let mut fut = pin!(fut);
let mut cx = Context::from_waker(Waker::noop());
match fut.as_mut().poll(&mut cx) {
Poll::Ready(n) => println!("done: {n}"),
Poll::Pending => println!("pending"),
}
}future created
running first
done: 5"future created" prints before "running first", and "never polled" never prints. An unawaited future is dropped without running, which the compiler flags with a warning, covered in unused future must be used. Dropping a half-polled future is how cancellation works too: nothing unwinds the state machine; its fields are simply dropped. That detail is why select! and timeouts need care, as set out in cancellation safety.
What the waker is for
The hand-written futures above asked to be polled again by calling wake_by_ref immediately, which turns the executor into a busy loop. A real leaf future does it differently. A socket read that finds no data registers interest with the operating system's readiness queue (epoll on Linux, kqueue on macOS, IOCP on Windows), stores a clone of the waker next to that registration, and returns Pending. When the kernel reports the socket readable, the runtime's I/O driver finds the stored waker and calls it, and the waker's only job is to put the owning task back on the run queue. The task is then polled again from its saved state, and the read succeeds. Between those two moments the thread was free to run other tasks, which is the whole point of async: thousands of tasks waiting on I/O cost thousands of small state machines, not thousands of threads.
Two rules follow that you can check in any hand-written future. Return Pending only after the waker is stored somewhere that will call it, or the task sleeps forever. And store the latest waker you were given on each poll, since a task can move to a different executor thread between polls and the old waker may point to the wrong place; Waker::will_wake exists to avoid re-cloning when it has not changed.
Where the machine analogy stops
The generated code is not literally an enum you can write yourself in all cases. A future may borrow from its own fields (a reference to a local that lives across an await), which is why it must be pinned, and it is the reason hand-writing state machines for such code needs unsafe. The compiler's version also handles drops of partially-initialised state on cancellation and panics for you. For most work you never write a poll; you write async fn and compose futures with join!, select! and combinators. The cases where you do implement Future by hand are leaf operations that wrap an I/O source or a timer, and adapters that must be zero-cost; for everything else async fn is easier to get right. The syntax side of this story is in async and await explained.


