Future trait & poll
Ladder:
src/bin/future_poll.rs· Run:cargo run --bin future_poll· Phase 5 · 9 rungs
TL;DR
A Future is a struct with exactly one method:
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
where Poll is Ready(T) | Pending. That’s the whole interface. Two facts fall out of it, and the entire async ecosystem is built on them:
.awaitis a poll loop. “Run this future” means: callpollrepeatedly until it returnsReady. When a sub-future returnsPending, you propagate thatPendingup to your caller and try again later.async fncompiles to an enum state machine. Each.awaitis a suspension point, and the compiler emits one enum variant per suspension point.pollis aloop { match self.state { ... } }driver that resumes at the current state.
Everything else — wakers, executors, Pin, join! — is machinery around those two facts.
Why this exists (from first principles)
Start with the problem async is solving: you have thousands of tasks that spend most of their time waiting (on sockets, timers, locks). Threads don’t scale here — a thread that blocks on a socket costs a full OS stack and a context switch to wake. You want thousands of concurrent waits on a handful of threads.
To do that, a task has to be suspendable and resumable as a plain value — something you can poll, park, and come back to, without a dedicated stack. That value is a Future, and its poll method is “make as much progress as you can right now, then tell me whether you finished (Ready) or you’re parked waiting (Pending)”.
Two constraints immediately appear, and the whole design answers them:
- How does a parked task get resumed? The executor can’t busy-poll ten thousand idle tasks. So
pollreceives aContextcarrying aWaker— a callback meaning “when I can make progress, call this and you’ll re-poll me”. That’s how a task sleeps at zero CPU cost until its socket is actually readable. - How is per-suspension state preserved? A synchronous function keeps locals on the stack across a blocking call. A future has no stack. So the state that must survive across an
.awaitgets stored in the future’s own struct — which is exactly why the compiler turnsasync fninto a state-machine enum, and why those state machines can become self-referential (the topic of the next ladder,Pin).
The ladder at a glance
| # | Tier | Rung | The lesson |
|---|---|---|---|
| 1 | foundations | Ready<T> | The Poll enum and the poll(Pin<&mut Self>, cx) shape |
| 2 | foundations | block_on | .await / “run a future” IS a poll loop |
| 3 | mechanics | YieldOnce | Pending means “poll me again”; state persists across polls |
| 4 | mechanics | Map<Fut, F> | Delegation: poll a child, propagate Pending, transform Ready |
| 5 | footgun | TwoStep | The generated state machine, written by hand |
| 6 | footgun | contract | The waker contract + poll-after-Ready, both made to fail |
| 7 | real-world | async fn | An async block is your hand-rolled TwoStep |
| 8 | real-world | Join | Poll two futures per turn — concurrency, not parallelism |
| 9 | capstone | BranchMachine | Desugar async fn (2 awaits + a branch) + a real waker executor |
The ideas, built up
1. The shape: Poll and one method
The simplest possible future is one that is already done:
struct Ready<T> { value: Option<T> }
impl<T> Future for Ready<T> {
type Output = T;
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
let value = this.value.take().unwrap(); // move T out of &mut self
Poll::Ready(value)
}
}
Three details that recur everywhere:
self: Pin<&mut Self>is a pinned mutable borrow (a promise the value won’t move again — seePinladder).Ready<T>isn’t self-referential, soself.get_mut()unwraps it back to&mut Self.get_mutis only offered whenSelf: Unpin, because handing out&mut Tlets safe codemem::swapthe value elsewhere — fine only when moving is harmless.value: Option<T>exists because you can’t move aTout of a&mutborrow, but you can.take()it out of anOption, leavingNonebehind. Same trick reappears forFnOnceclosures (rung 4)._cxis ignored — a future that is already done has no reason to arrange a wake-up. That “why doesn’t this one need the waker?” question is answered in rung 3.
2. .await is a poll loop
There is no magic under “run this future”. It’s a loop:
fn block_on<F: Future + Unpin>(fut: F) -> F::Output {
let mut fut = fut;
let mut fut = Pin::new(&mut fut);
let mut cx = noop_cx(); // a do-nothing Waker::noop()
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(v) => return v,
Poll::Pending => {} // busy-spin: just poll again
}
}
}
This is a busy-spin executor: on Pending it immediately re-polls, burning CPU. That’s fine for a toy and terrible for real life — but it’s the honest skeleton. A real executor differs in exactly one place: instead of Poll::Pending => {}, it parks the thread and only re-polls when the waker fires (rungs 6 and 9).
The pinning choice matters. Pin::new(&mut fut) is alloc-free but requires F: Unpin. The alternative, Box::pin(fut), heap-allocates and yields Pin<Box<F>>, which is always Unpin regardless of F — so it accepts even !Unpin async state machines with no unsafe. The Unpin bound here is what forces the E0277 in rung 7.
3. Pending means “poll me again”
Every future so far finished on the first poll. The interesting ones suspend:
struct YieldOnce { yielded: bool }
impl Future for YieldOnce {
type Output = ();
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
if this.yielded {
Poll::Ready(())
} else {
this.yielded = true;
Poll::Pending // "not done — call me again later"
}
}
}
Poll it by hand and you see the two-step: first poll → Pending, second poll → Ready. This is what a single .await on something-not-yet-ready looks like from the inside.
The important insight hides in yielded: bool. That flag is state persisted across poll calls. Scale it up from “one bool” to “which .await am I parked at, and what locals are still alive?” and you have invented the async state machine (rung 5).
4. Delegation: the combinator shape
A Map future owns another future and transforms its output:
impl<Fut, F, B> Future for Map<Fut, F>
where Fut: Future + Unpin, F: FnOnce(Fut::Output) -> B {
type Output = B;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
if let Poll::Ready(v) = Pin::new(&mut this.inner).poll(cx) {
let f = this.f.take().unwrap(); // FnOnce: move it out to call it
Poll::Ready(f(v))
} else {
Poll::Pending // propagate the child's suspension
}
}
}
This “poll the child, propagate Pending, map the Ready” pattern is the entire job of every adapter (.map, .and_then, the ?-in-async plumbing). Two things worth burning in:
cxis forwarded, unchanged, to the child. A leaf future five layers deep registers the executor’s waker; every combinator in between is just a pipe. Poll flows down; the waker (registered from that samecx) flows back up.fis anOption<F>for the same reasonReady’s value was — calling anFnOncemoves it, so you.take()it out of&mut self. It runs exactly once, on the singleReady.
5. The state machine, by hand
This is the center of the ladder. Consider:
async {
let a = fut_a().await; // suspension point #1
let b = fut_b().await; // suspension point #2
a + b
}
The compiler turns this into an enum with one variant per suspension point:
enum TwoStep {
Running1 { a_fut: FutA }, // awaiting #1
Running2 { b_fut: FutB, a: i32 }, // #1 done; awaiting #2, carrying `a`
Done, // terminal
}
And poll is a loop { match state } driver:
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
loop {
match this {
TwoStep::Running1 { a_fut } => {
if let Poll::Ready(a) = Pin::new(a_fut).poll(cx) {
*this = TwoStep::Running2 { b_fut: FutB { polled: false }, a };
continue; // fall through to poll #2 immediately
} else {
return Poll::Pending;
}
}
TwoStep::Running2 { b_fut, a } => {
if let Poll::Ready(b) = Pin::new(b_fut).poll(cx) {
let a = *a; // copy out BEFORE overwriting *this
*this = TwoStep::Done;
return Poll::Ready(a + b);
} else {
return Poll::Pending;
}
}
TwoStep::Done => panic!("TwoStep polled after completion"),
}
}
}
Three ideas made concrete:
- Each
.awaitis a variant. Two awaits →Running1,Running2. Theloop { match state }is the resume mechanism: a freshpollcall re-enters at whichever variant it left off in. - The
continueafter a transition is “keep making progress”. Anasync fndoes not return to the executor merely because one sub-future finished — it drives forward until something is genuinelyPending. Iffut_awere instantly ready, the hand-poll would see one fewerPending. Running2carriesa. A local alive across an await must be stored in the future’s own struct. That is why async state machines get large — and, in the real compiler output, why they become self-referential and!Unpin.
Borrow-checker subtlety:
let a = *a;must come before*this = TwoStep::Done;.ais borrowed out ofthis; overwriting*thisinvalidates that borrow. Copying it out first is exactly what the compiler does when it moves a live-across-await local out of the old state. For a non-Copylocal you’d reach forstd::mem::replace(this, Done)to take the old variant by value and then move fields out of it — the heavyweight generalization of theOption::taketrick.
6. async fn IS a Future
Rung 7 closes the loop by writing the same logic as an async block and proving it is indistinguishable from the hand-rolled TwoStep:
fn two_step_async() -> impl Future<Output = i32> {
async {
let a = fut_a().await;
let b = fut_b().await;
a + b
}
}
The assertions confirm both the same result (42) and the same Pending, Pending, Ready rhythm when polled by hand — because the compiler placed a suspension point at each .await, right where Running1/Running2 were. The generated enum is a type you can’t name or inspect (the “one hidden type” of -> impl Future), but it behaves exactly like the one you wrote.
7. join — concurrency without parallelism
TwoStep was sequential: it never touched fut_b until fut_a finished. Join drives both every poll and finishes when both are done:
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
if let Some(fut) = &mut this.a {
if let Poll::Ready(v) = Pin::new(fut).poll(cx) {
this.a_out = Some(v);
this.a = None; // done — never poll it again
}
}
if let Some(fut) = &mut this.b { /* ...same for b... */ }
if this.a_out.is_some() && this.b_out.is_some() {
Poll::Ready((this.a_out.take().unwrap(), this.b_out.take().unwrap()))
} else {
Poll::Pending
}
}
This is futures::join! / tokio::join! in miniature. The design teaches three things:
- You poll both per call. That interleaving on a single task is async concurrency — no extra threads.
join(fut_a, fut_b)finishes in exactly two polls because both sides advance together. this.a = NoneafterReadyis a structural fuse. Setting theOptiontoNoneguarantees you never poll a completed future again (the rung-6 rule). Theis_some()guard is a hand-rolledFusedFuture.- You must stash results. The two sides finish at different times; taking
a_outearly would discard A’s value permanently (its future is alreadyNone), soJoinwould never complete. The mixed-readiness case (join(ready(99), fut_b())) exercises exactly this.
Footguns
The waker contract (the #1 real-world async bug)
Pending is a two-part promise: “I’m not done — AND I have arranged (via cx.waker()) to be woken when I can progress.” A Pending returned without registering the waker is a lost task: under a parking executor it is polled once and never again — a permanent hang.
The busy-spin block_on hides this because it re-polls unconditionally. A realistic executor exposes it. The ladder builds one from a flag waker:
struct FlagWaker { woken: AtomicBool }
impl Wake for FlagWaker {
fn wake(self: Arc<Self>) { self.woken.store(true, Ordering::SeqCst); }
fn wake_by_ref(self: &Arc<Self>) { self.woken.store(true, Ordering::SeqCst); }
}
// re-poll ONLY if the waker fired since the last poll
if !flag.woken.swap(false, Ordering::SeqCst) {
return Err("stuck: future returned Pending without registering a wake");
}
Now the split is visible:
// OK: arms the waker before parking -> executor re-polls -> Ok(7)
this.yielded = true;
cx.waker().wake_by_ref();
Poll::Pending
// WRONG (rung 3's YieldOnce): returns Pending, never touches cx
// -> parking executor returns Err("stuck") -> in production, a real hang
this.yielded = true;
Poll::Pending
YieldOnce “worked” under busy-spin and hangs under a parking executor. A production runtime replaces “check a bool, else give up” with “block the thread until woken”, and wake() replaces “set a bool” with “push this task back on the ready queue”.
Poll-after-Ready
Once poll returns Ready, the future is spent and must not be polled again — it may panic (like TwoStep::Done) or return garbage. The ladder drives TwoStep to completion and pokes it once more inside catch_unwind to confirm the panic. This is why combinators that might over-poll (select!, racing) require FusedFuture / .fuse(), which makes an already-finished future return Pending instead of exploding.
Real-world patterns
Wakerconstruction. In real runtimes aWakeris a hand-built vtable (RawWaker+RawWakerVTable) so it needs no allocator and can point at anything. Std’s friendly door is theWaketrait on anArc<T>plusWaker::from(arc)— exactly whatFlagWakeruses.Waker::noop()(stable since 1.85) is the do-nothing waker for hand-polling.Contextis aWakercarrier. Today it is essentially&Wakerwith room to grow. Every combinator takescxand forwards the samecxdown — which is how a deep leaf registers the executor’s waker.block_ontakes pinned futures.futures::executor::block_onand friends pin the future (often viaBox::pinorpin!) because compiler-generated async futures are!Unpin. That single requirement is theUnpinwall you hit in rung 7.join!interleaves on one task. Concurrency (interleaved progress) is distinct from parallelism (multiple threads).Joingives the former with zero threads.
Capstone insight
Rung 9 desugars an async fn with a runtime branch and drives it with a proper waker-parking executor:
async fn compute(n: i32) -> i32 {
let a = wake_yield(n).await; // await #1 — ALWAYS
if a % 2 == 0 {
let b = wake_yield(a * 2).await; // await #2 — ONLY on the even branch
a + b
} else {
a // odd branch: no second await
}
}
The hand-written machine encodes the branch as a transition that only sometimes reaches the second suspension point:
enum BranchMachine {
Await1 { fut1: WakeYield },
Await2 { fut2: WakeYield, a: i32 }, // even branch only
Done,
}
The Await1 -> Ready(a) arm chooses: if a is even, transition to Await2 and continue; if odd, go straight to Done and return. The branch means the number of suspension points depends on a runtime value — the odd path never constructs Await2, exactly as the else arm never hits a second .await.
The executor is the synthesis of rungs 2 and 6:
fn run<F: Future>(fut: F) -> F::Output {
let flag = Arc::new(FlagWaker { woken: AtomicBool::new(true) });
let waker = Waker::from(flag.clone());
let mut cx = Context::from_waker(&waker);
let mut fut = Box::pin(fut); // accept !Unpin async futures
loop {
if !flag.woken.swap(false, Ordering::SeqCst) { panic!("stuck"); }
match fut.as_mut().poll(&mut cx) {
Poll::Ready(v) => return v,
Poll::Pending => {} // parked until the next wake
}
}
}
The payoff assertion sweeps run(compute_machine(n)) == run(compute(n)) for every n in 0..20, covering both branches. The aha: async/.await is not a runtime feature — it is a source transformation into a Future state machine, and once you can write that machine by hand, .await becomes readable as the enum it compiles to.
Explain it back
- What are the two variants of
Poll, and what does returning each one obligate you to do? - Why does
polltakePin<&mut Self>and not&mut self? Why doesget_mutrequireUnpin? - What does
.awaitdesugar to? How many enum variants does anasync fnwith three.awaits generate? - A future returns
Pendingbut never touchescx. Why does it work under a busy-spinblock_onand hang under a real executor? - Why must a local used after an
.awaitbe stored in the future’s struct rather than on the stack? What does that imply about the size andUnpin-ness of async state machines? - How does
joindiffer from two sequential.awaits, and why must it set each side’sOptiontoNoneafterReady? - Why does
block_on(some_async_fn())fail to compile with anUnpinbound, and how doesBox::pinfix it?
See also
Send&Syncdeeply — an async future isSendiff every value held across an.awaitisSend.- Shared state vs message passing — the sync concurrency models the async ones mirror.
impl Trait& RPIT —async fnis sugar for-> impl Future, the “one hidden type” this ladder relies on.- Next in Phase 5:
Pin&Unpin(why the state machines above are!Unpin), writing a future by hand (wakers &Contextin full), and building a tiny executor (the parking loop, for real).