Tech Wiki

TOPICSSERIES

[Rust Zero to Production 23] Rust Future, Poll, and Pin: The Model Under async/await

Calling an async fn does not immediately execute its body. The call returns a Future, and the computation advances only when something polls it. Beneath the syntax hidden by await are Poll::Pending, Poll::Ready, a Waker that requests another poll, and Pin, which expresses movement constraints in the type system.

This article targets Rust 2024 with rustc and Cargo 1.98.1. A small standard-library-only example polls each future one step at a time to expose those boundaries. Its polling driver is strictly conceptual, not a production executor or asynchronous runtime. It uses no network access, sleeping, or timing measurements.

1. A Future stays inert until it is polled

A Future represents an asynchronous computation that may eventually finish. Creation and execution are separate: constructing or storing a future does not advance it. Futures are inert: they must be actively polled to make progress.

1.1. The input and output of Future::poll

The core method has this shape.

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

The caller supplies the future as Pin<&mut Self> along with the current task's Context. Poll::Pending means there is no result yet. Poll::Ready(value) means the computation has finished and value is its final output.

In the example, TraceFuture::new() creates a future with a counter that records polls. The counter is 0 immediately after construction and becomes 1 only after the first poll_once. The constructor does not secretly start the computation.

1.2. The contract after Pending and Ready

Pending does not mean failure or cancellation. It says the future cannot finish now and has arranged for the current task to be polled again when progress becomes possible. Ready marks this future's completion boundary.

Do not assume that polling a completed future returns the same value again. The Future contract leaves polling after Ready unspecified: the call may panic, block forever, or cause other problems. The example stops using each future after its first Ready.

2. A Waker requests another poll; it does not poll

When the first poll returns Pending, what causes another poll? A Waker is a handle for waking a task. Calling wake tells the executor that the task is ready to run again. The distinction matters: a Waker does not call Future::poll directly.

2.1. Keep readiness separate from the wake request

The example changes readiness explicitly through ReadySignal. One mutex protects the state.

#[derive(Debug, Default)]
struct SignalState {
    ready: bool,
    registered: Option<Waker>,
}

#[derive(Debug, Default)]
pub struct ReadySignal {
    state: Mutex<SignalState>,
}

ready is a plain bool, and the latest Waker lives beside it under the same Mutex<SignalState>. Atomic types are used only for the poll-count and wake-request counters. Readiness is not an atomic flag, so changing the state and taking the registered waker happen within one critical section.

mark_ready sets ready = true, takes the registered waker, releases the mutex guard, and then calls wake. In the test, the wake-request count rises to 1 while the poll count stays at 1. Only an explicit second poll by the polling driver produces Ready("signal observed").

2.2. Store the Waker from the latest Context

Context provides access to the current task's Waker. If the same future returns Pending more than once, the waker from the latest Context must receive the next wakeup. Keeping an older waker could wake a registration that has since been replaced by a different task representation.

fn poll_state(&self, cx: &Context<'_>) -> bool {
    let mut state = self.state.lock().expect("readiness mutex poisoned");
    if state.ready {
        true
    } else {
        state.registered = Some(cx.waker().clone());
        false
    }
}

The example polls once with a first waker and again with a second. The second poll replaces the first registration. After mark_ready, the first counter remains 0 and only the second reaches 1. The future's poll count still does not change. Waking requests a repoll; carrying out that poll is a separate responsibility.

3. What a minimal polling driver shows, and what it does not

The teaching helper poll_once builds a Context from a borrowed Waker and performs exactly one poll.

pub fn poll_once<F: Future>(future: Pin<&mut F>, waker: &Waker) -> Poll<F::Output> {
    let mut context = Context::from_waker(waker);
    future.poll(&mut context)
}

This makes the relationship among Future, Context, and Waker visible, but it is not a production executor. There is no task queue or loop that receives wake requests and schedules tasks automatically. It also omits fairness across tasks, concurrent execution, I/O readiness, cancellation, panic isolation, and shutdown.

The example calls a second poll_once explicitly after signal.mark_ready(). A real executor would feed the wake notification into its scheduling policy and poll the future again at an appropriate point. This article separates those roles only at the conceptual level. Runtime and Tokio task structure belong to the next article.

4. async fn and async blocks also create Futures

Calling an async fn returns a future whose output is the function's declared return value; it does not return that value immediately. Evaluating an async block likewise creates an anonymous future type. The same laziness rule applies to these compiler-generated futures and the manually implemented TraceFuture.

4.1. An async fn with no await still needs polling

The example's function body contains no await.

pub async fn async_answer() -> u8 {
    42
}

Even so, the result of async_answer() is not a u8; it is a Future<Output = u8>. The call alone does not extract the body's result. After Box::pin pins it, one poll of this simple body returns Ready(42).

Async code that contains await may encounter Pending in an unfinished child future. This example does not imitate .await orchestration or runtime scheduling. It checks only the type boundary: an async fn and an async block create futures.

5. Pin restricts movement through safe APIs

Future::poll receives Pin<&mut Self> rather than &mut Self because some futures may depend on their internal location remaining stable between polls. Pin<Ptr> is a pointer wrapper. Through safe APIs, it prevents the pointee from being moved out of or otherwise invalidated at its current memory location.

That guarantee should not be stretched too far. Pin does not forbid every kind of memory movement in all circumstances, nor can it repair unsafe code or an incorrect implementation that violates the pinning contract. Pin also does not make a value self-referential. A type's implementation and invariants determine whether it depends on a stable location.

5.1. Expose the !Unpin boundary with PhantomPinned

TraceFuture includes a PhantomPinned field so that it does not receive the automatic Unpin implementation.

#[derive(Debug)]
pub struct TraceFuture {
    polls: Arc<PollCount>,
    signal: Arc<ReadySignal>,
    _pin: PhantomPinned,
}

let future = Box::pin(TraceFuture {
    polls,
    signal,
    _pin: PhantomPinned,
});

This example creates no actual self-reference. PhantomPinned is only a marker that exposes how a !Unpin type must be handled through safe APIs. Box::pin puts the value in a pinned heap allocation and returns Pin<Box<TraceFuture>>. Polling then receives only the Pin<&mut TraceFuture> produced by future.as_mut().

5.2. Unpin types can still move safely through Pin

Unpin is an auto trait for types that do not need pinning guarantees. When the pointee is Unpin, treating Pin like an ordinary pointer does not violate a location invariant. That is why Pin::new can safely construct a pin from &mut F.

pub fn poll_unpinned_once<F: Future + Unpin>(
    future: &mut F,
    waker: &Waker,
) -> Poll<F::Output> {
    poll_once(Pin::new(future), waker)
}

The test passes std::future::ready("movable future") to this helper and observes Ready("movable future"). A TraceFuture containing PhantomPinned cannot use the helper; it must take the poll_once path that accepts an already pinned pointer.

6. Verify the boundaries with deterministic tests

Run these commands from the project directory.

cd examples/article-23-future-poll-pin
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

On Rust 1.98.1 and Cargo 1.98.1, all five commands should exit with code 0. The suite contains 6 integration tests. Program output is fixed as follows.

constructed: polls=0 wake_requests=0
first poll: Pending polls=1 wake_requests=0
signal: polls=1 wake_requests=1
second poll: Ready("signal observed") polls=2 wake_requests=1
async fn: Ready(42)
pin boundary: TraceFuture uses PhantomPinned; Ready<T> is Unpin

6.1. What the counters establish

The first line's polls=0 shows that construction alone did not advance the future. The first poll returns Pending and increments only the poll count to 1. Marking readiness raises wake requests to 1, but polls remains 1. The count reaches 2 and Ready appears only after the explicit second poll.

Separate tests cover replacement with the latest Context waker, the first poll of an async fn future, and the Unpin helper. Every check uses direct calls and counters, with no dependency on thread scheduling order or elapsed time. These results say nothing about executor throughput, fairness, or real I/O integration.

Three questions keep the model straight when reading a future: who polls it, who stores and wakes the latest waker after Pending, and whether the type requires location stability. Executors and runtimes supply the operational answers to the first two. This article has fixed the contracts beneath them; the next one builds tasks with Tokio.

Full source code

The complete runnable source for this article is available in the Chapter 23 project on GitHub.

Sources


3 responses

  1. […] Previous articleRust Future, Poll, and Pin: The Model Under async/await […]

  2. […] Next articleRust Future, Poll, and Pin: The Model Under async/await […]

Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.