The Rust Engineering Handbook / Chapter 58
Future, Poll, Waker, and the Generated State Machine
Reason from the Future polling contract to async state machines, wakeups, pinning, and executor responsibilities.
Start with the complete interface between an asynchronous computation and its scheduler:
pub trait Future {
type Output;
fn poll(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output>;
}
No async fn syntax is necessary to state the contract. A call to poll must make bounded progress without blocking the executor thread. It returns Ready(output) when the output is available. If it returns Pending, the future must arrange for the task represented by the current context’s waker to be notified when another poll may make progress. The executor owns scheduling and calls poll again. A wake is permission to reconsider the task, not proof that its output is ready.
This small protocol begins Part X by changing the unit of liveness analysis from Chapter 57’s worker thread to a cooperatively polled task. Every later async concern—timers, sockets, cancellation, select loops, bounded concurrency, task spawning, and shutdown—depends on it. Async Rust is not “threads with .await.” It is cooperative state-machine execution driven by readiness notifications.
Hand-trace a future before using async syntax
The lab exposes a one-shot signal as two handles. Signal<T> is completed by a producer. SignalFuture<T> is polled by an executor:
struct Shared<T> {
value: Option<T>,
waker: Option<Waker>,
completed: bool,
}
pub struct Signal<T> {
shared: Arc<Mutex<Shared<T>>>,
}
pub struct SignalFuture<T> {
shared: Arc<Mutex<Shared<T>>>,
}
On the first poll, the value is absent. The future clones the current task’s waker into shared state and returns Pending. The executor can run other work or park its thread. Later the producer stores the value, removes the waker while holding the mutex, drops the guard, and calls wake. The executor schedules another poll. That poll takes the value and returns Ready.

Read the right panel from top to bottom. wake travels from the event source to the executor’s task handle. It does not push output into the executor and it does not call the future directly. The executor may coalesce multiple wakeups, poll later, or poll only to receive Pending again because readiness was consumed by another participant. The correctness contract tolerates those schedules.
The left panel makes completion a terminal state. The Future trait does not require a useful behavior after a future has returned Ready; repolling may panic, block, or behave otherwise, but safe code must not cause undefined behavior. Executors stop polling completed tasks. Combinators that need stable post-completion behavior can wrap a future with an explicit fused state, but code must not infer fusion from the trait alone.
Poll reports state; it does not wait
Poll<T> has two variants:
pub enum Poll<T> {
Ready(T),
Pending,
}
Pending is often misread as “sleep until this operation finishes.” It means only “no output from this poll; use the registered notification path before polling for the same reason again.” The call itself returns. A future that blocks on a mutex, condition variable, socket read, or expensive CPU loop inside poll prevents the executor worker from polling other tasks assigned to it.
The method should perform a bounded amount of work. “Bounded” is operational, not merely finite in theory. Parsing a ten-gigabyte buffer before returning can starve neighboring tasks even though the function eventually completes. Break CPU work into explicit chunks with a wake/yield policy, or move it to a pool intended for blocking or CPU-bound work. Chapter 57’s granularity and pool-isolation questions still apply.
Ready transfers the output. An output can be success, error, or a domain state; Future does not prescribe error handling. A future that returns Ready(Err(_)) is complete just as one returning Ready(Ok(_)) is. Panicking from poll follows the executor’s panic-isolation behavior, which must be part of the runtime and service contract.
Context carries the current task’s wake capability
Context<'_> currently matters chiefly because it provides &Waker. A Waker is an executor-defined, cloneable task handle. Calling wake or wake_by_ref tells the executor that the associated task should be polled again. It can be sent and invoked from another thread.
The future registers a task, not an event callback. One task may be polling a tree of child futures. A child stores the task’s waker; when its event becomes ready, it wakes the parent task; the parent polls its tree again to find which child can progress. That is why wakeups may be coalesced and why a wake does not identify a completed output.
On repeated polls, a future must arrange to wake the task represented by the most recent context. The future may have moved between executor workers, or a wrapper may now be driving it through a different task. Keeping only the first waker can strand the current task. The standard Waker::clone_from operation is useful because it can avoid a redundant clone when the stored and current wakers wake the same task:
match &mut shared.waker {
Some(stored) => stored.clone_from(cx.waker()),
slot @ None => *slot = Some(cx.waker().clone()),
}
will_wake is a best-effort optimization query: true guarantees the same task, while false does not prove different tasks. It must not decide correctness.
Waker storage has ownership cost. A cloned waker may retain executor task state. Replace stale wakers and release them on completion or cancellation. Avoid cloning on every poll when clone_from suffices, but do not sacrifice the latest-waker rule for an unmeasured allocation concern.
Registration and readiness must close the lost-wakeup window
The most dangerous manual-future defect is a check/register race:
- future checks
ready == false; - producer sets
ready == trueand sees no registered waker; - future stores the waker and returns
Pending; - nobody wakes the task again.
The future is ready but stranded. The lab closes this window by protecting the value, completion bit, and waker with one mutex. Poll checks readiness and registers while holding that guard. Completion stores readiness and takes the registered waker under the same guard. The producer wakes after releasing the mutex:
pub fn complete(self, value: T) -> Result<(), T> {
let waker = {
let mut shared = self.shared.lock().unwrap();
if shared.completed {
return Err(value);
}
shared.completed = true;
shared.value = Some(value);
shared.waker.take()
};
if let Some(waker) = waker {
waker.wake();
}
Ok(())
}
Waking outside the guard avoids running executor-specific wake behavior while the future’s mutex is held. This is the callback rule from Chapter 55 applied to scheduler notification.
An atomic implementation needs the same proof in another form. It must show how registration and readiness publication interlock, including the race where readiness changes just before or after registration. Ordering alone does not create the missing retry. Established event-registration primitives typically use a register-then-recheck pattern or a coordinated state machine. Do not replace the teaching mutex with atomics merely because the subject is async.
Extra wakeups are allowed. A source can wake just before the future returns Pending; the executor’s ready bit or queue entry must preserve enough notification to cause another poll. The tiny executor uses Thread::unpark, whose token-like behavior closes the wake-before-park window for one parked thread. Production executors generalize this with task state and ready queues.
Async functions are stored control flow
An async fn call returns a future immediately; its body begins executing when the future is polled. Conceptually, the compiler transforms the body into a state machine. Each .await is a possible suspension point. To resume later, the generated future stores:
- which state should execute next;
- arguments and locals that remain live across the suspension;
- child futures currently being awaited;
- control-flow information needed for branches, loops, and cleanup.
Consider:
async fn load_and_decode(key: String) -> Result<Record, Error> {
let bytes = read_record(&key).await?;
let record = decode(&bytes)?;
audit(&key, &record).await?;
Ok(record)
}
Before the first await, the generated state contains key and the child returned by read_record. After that child is ready, it contains key, bytes or record as required by later code, and the audit child. Exact layout and variant representation are compiler implementation details; the durable model is that locals live across .await become fields of the returned future.
This model explains observable costs and type properties. A large buffer live across an await enlarges the future even if it is used only later. A non-Send value live across an await can make the future non-Send, while the same value created and dropped between suspension points may not. A borrow across an await becomes a relationship stored in the state machine. Drop or cancellation must destroy whichever fields are initialized in the active state.
Use scopes to control what crosses suspension:
let parsed = {
let scratch = build_large_scratch();
parse_with(&scratch, input)?
}; // scratch is gone before suspension
send(parsed).await?;
Do not contort code solely to shrink a future without measuring. But when task count is large, future size multiplied by in-flight tasks can be material. Inspect sizes in a pinned toolchain, profile allocation and residency, and treat layout changes across compiler versions as observations rather than stable ABI.
Pinning preserves addresses when state contains address-sensitive relationships
The receiver Pin<&mut Self> is not decoration. A generated future may contain relationships that become address-sensitive after polling—for example, a child future or internal reference logically tied to storage within the parent state machine. Moving the future after such initialization could invalidate those relationships.
Pin wraps a pointer and restricts moves of the pointee when the type is not Unpin. It does not make all fields immutable, allocate by itself, or prevent dropping. Projection from a pinned parent to a pinned field is subtle because moving one structurally pinned field would violate the parent’s guarantee. Hand-written address-sensitive futures should use established projection patterns or libraries rather than casual unsafe pointer code.
The lab’s SignalFuture<T> contains only an Arc and is Unpin, so Pin::new(&mut future) is enough. The manual implementation can access the shared state without projecting a pinned field. This is deliberate: it exposes the polling protocol without pretending to teach unsafe pin projection.
At an executor boundary, std::pin::pin! can pin a future in its current stack storage for a lexical scope:
let mut future = std::pin::pin!(future);
match future.as_mut().poll(&mut context) {
Poll::Ready(output) => return output,
Poll::Pending => { /* wait for a wake */ }
}
Heap pinning with Box::pin is useful when ownership or dynamic storage requires it, not an automatic requirement of async syntax. Pinning answers address stability; Send answers thread transfer; 'static answers borrowed lifetime constraints. They are independent dimensions.
A tiny executor shows the division of responsibility
The lab’s executor runs one future on the current thread:
pub fn block_on<F: Future>(future: F) -> F::Output {
let waker = Waker::from(Arc::new(ThreadNotify(std::thread::current())));
let mut context = Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(output) => return output,
Poll::Pending => std::thread::park(),
}
}
}
ThreadNotify implements the safe Wake trait and unparks the executor thread. This is sufficient for one task and makes busy polling visibly absent. It is not a production runtime.
A general executor must additionally own:
- task allocation, identity, and lifetime;
- a ready queue and deduplication/coalescing policy;
Wakerconstruction tied to the correct task;- worker scheduling, fairness, and prevention of one task monopolizing a worker;
- integration with I/O readiness, timers, signals, and blocking pools;
- panic isolation and task-result retention;
- cancellation, task ownership, and structured shutdown;
- telemetry for queue delay, poll duration, wake counts, stalled tasks, and worker saturation.
The executor is not responsible for inventing wakeups for a broken future. The future is not responsible for choosing its worker. The event source does not poll the future. Keeping those roles separate makes incident diagnosis possible.
Production block_on also needs a nesting policy. Blocking an executor worker while waiting for work scheduled onto the same constrained executor can deadlock or starve. Runtime-specific APIs may detect or handle some contexts, but there is no universal permission to call an arbitrary blocking executor from async code.
Busy polling is a contract violation even when it returns the right value
This loop is wrong:
loop {
if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
break output;
}
}
It turns Pending into a spin instruction, consumes a core, ignores the notification design, and may repeatedly contend on the event source. Under a single-threaded executor it can prevent the producer future from ever running. In tests it may appear fast when another OS thread produces the event; that does not make it a valid executor.
Self-waking on every Pending is only marginally different. It can create a ready-queue storm and starve peers. Self-wake is justified when the future performed bounded useful work and knows more work is immediately possible—for example, cooperative chunking. Even then, apply a budget and let the executor schedule competitors.
Likewise, returning Pending without storing a waker is valid only when some other guaranteed mechanism will wake the task or the future is intentionally never-ready. std::future::pending() is intentionally never-ready. An I/O future that simply “expects the executor to try later” is broken.
Completion, fusion, cancellation, and drop are distinct
After Ready, a future is complete. Fused means it has a stable, queryable behavior after completion, commonly continuing to report a terminal state. The base trait does not require fusion. A select-like loop that might poll an already completed branch needs to remove, disable, or explicitly fuse it according to the combinator contract.
Dropping a pending future is the base cancellation mechanism: the executor will no longer poll that value. Drop does not guarantee that an external operation stopped. A kernel I/O request, blocking worker, remote request, or spawned child may continue unless its owner implements cancellation. The future’s destructor must release its registration and owned state safely; the event source must tolerate waking a task whose interest was removed according to their coordinated protocol.
Cancellation can occur at any suspension point because that is where control returns to the caller. Therefore, every .await divides the function into completed effects and not-yet-executed effects. If a debit is persisted before an awaited audit append, dropping the future can leave exactly that intermediate state. Later chapters develop cancellation safety, but the generated-state model already reveals the obligation: name which active state is safe to destroy and how external side effects are reconciled.
Panic is another exit. If polling panics while a mutex is held, poisoning or recovery policy applies. If it panics after registering a waker but before committing state, the event source may retain a task handle. Executor panic isolation does not repair the future’s domain invariant.
Polling review failures
Several repairs compile while weakening the protocol:
- Clone the first waker forever: can wake a stale task after migration or wrapping.
- Check readiness, unlock, then register: creates a lost-wakeup window.
- Wake while holding internal locks: invokes executor behavior inside a protected transition and risks contention or lock cycles.
- Hold a blocking mutex across
.await: can strand a worker and creates a guard lifetime spanning arbitrary suspension; use an ownership redesign or an async-aware primitive with a deliberate scope. - Add
moveor'staticblindly: may transfer too much ownership or conceal missing structured task lifetime. - Box every future: can simplify type storage but adds allocation and indirection without addressing wake correctness.
- Use a no-op waker in a real executor: discards notifications; it is appropriate only when progress cannot require a wake.
- Assume one wake equals one poll: wakeups may be coalesced, and an executor controls scheduling.
- Assume one wake means ready: readiness can be consumed, spurious at the abstraction layer, or only sufficient to advance one step.
- Poll after
Readyto “confirm”: the trait offers no such protocol.
Code review should follow the shared state, not just the Future implementation. Find who publishes readiness, where the latest waker is stored, how their race is closed, who owns cancellation, and what prevents unbounded work in one poll.
Exercise: implement and audit one future
Implement a DeadlineSignal<T> manually. A producer may complete it with T; a timer may mark it expired; exactly one outcome wins. Do not use an async runtime in the core exercise.
Deliver:
- a state enum for waiting, completed, expired, and consumed;
- a
Futureimplementation that stores the most recent waker and returns promptly; - producer and timer methods that wake outside the state lock;
- a proof that readiness cannot change between the final check and waker registration without a later poll being scheduled;
- explicit behavior for duplicate completion and poll-after-ready;
- cancellation behavior when the future is dropped while the producer still exists;
- deterministic tests with two different counting wakers showing that only the most recently registered task is notified;
- a wake-before-park test for the tiny executor with no sleeps;
- a state-size observation for an equivalent
async fn, labeled compiler-version-specific; - a production-gap list covering I/O registration, timer ownership, task queues, fairness, panic, telemetry, and shutdown.
Then audit these claims as guaranteed, implementation-specific, or false:
- An async function starts executing when it is called.
Pendingtells the executor to poll continuously.- A wake carries the future’s output.
- Multiple wakes can be coalesced.
- Only the latest registered waker should receive the notification.
Pinmeans the future lives on the heap.- A future may be moved freely after it is pinned if no source-level references are visible.
- Polling after
Readymust return the same value. - Dropping a future guarantees its external operation has stopped.
- A future that never blocks an OS thread is necessarily fair to other tasks.
The best submission uses a mutex first because the registration/readiness proof remains visible. An atomic version is an optional second design and must include a state-transition and ordering proof, not only a microbenchmark.
The async contract in one page
- Calling an
async fncreates a future; polling drives its body. pollreceivesPin<&mut Self>and a taskContext.Ready(output)completes the future; the base trait does not promise safe-to-use repeated results.Pendingreturns control and requires a valid path to wake the current task when progress may be possible.- Store or update the most recent waker;
will_wakeis an optimization, not a correctness discriminator. - Close the race between readiness checks and waker registration.
- Wake outside internal locks when possible.
- A wake schedules reconsideration; it neither supplies the output nor proves readiness.
- Locals live across
.awaitbecome stored state and affect future size,Send, borrowing, and drop. - Pinning preserves address stability; it does not imply heap allocation, thread safety, or immortality.
- Executors own task queues and polling; event sources own readiness; futures connect the two.
- Blocking, busy polling, unbounded per-poll work, and self-wake storms defeat cooperative scheduling.
- Drop cancels interest in polling, not necessarily the external operation.
Once poll and wake are treated as a precise protocol, async syntax stops being magic. The next design questions become concrete: which values cross task boundaries, how tasks are spawned and owned, which operations block, and how cancellation propagates through a task tree.
Sources and version note
The polling, latest-waker, nonblocking, no-busy-polling, and post-completion rules follow the current official Future documentation. Wake semantics, coalescing, cross-thread use, clone_from, and will_wake follow Waker; the lab uses the safe Wake construction path. The conceptual compiler-generated state machine is also described in The Rust Programming Language’s async chapter. The fixture is dependency-free safe Rust 2024, verified against Rust 1.97.0 with Rust 1.85.0 as MSRV. Generated future layout, size, executor scheduling, and runtime integration are not stable language ABI promises.
Continue reading
Full table of contents