Skip to content
rusthacks.dev
Concurrency

Writing a Future by Hand in Rust With Poll and Wakers

An async fn is a state machine behind the Future trait. Writing a future yourself, with poll and a waker, removes the mystery from await.

18 min read
Summarize in AI
Flat illustration for the post “Writing a Future by Hand in Rust With Poll and Wakers”, composed around async.

Reading about wakers helps less than writing one. A future is a small contract between a value and an executor, and once you have implemented it yourself, async and await stop looking like magic and start looking like generated code. This post builds up from a future that is ready after a few polls, through the two ways it can hang, a timer woken by another thread, a hand-written executor and a hand-written join, and finishes by measuring what the compiler's version costs. Everything ran on Rust 1.99 (edition 2024). Most programs use Tokio 1.54 only to drive the future; the executor section removes even that. For the mental model first, read futures as state machines, or come back to it after.

The trait

rust
pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

That is the whole of it. poll asks "are you finished?" and the answer is Poll::Ready(output) or Poll::Pending. The Context argument gives the future access to a Waker. When the answer is Pending, the future makes a promise: something will call that waker when polling again could produce a different answer. The executor trusts the promise completely. It does not poll in a loop to check. It parks the task and sleeps until the waker fires. That trust is the source of nearly every bug in this post, and it is why a future that breaks the promise does not fail loudly. It just never finishes.

The self: Pin<&mut Self> receiver is the other odd part. It means the future will not move in memory once polled, which the compiler relies on when an async body holds a reference to one of its own locals across an await. We will meet Pin properly in a moment, because the compiler's own message about it is a good teacher.

A future that is ready after a few polls

The simplest useful future counts down and reports how many times it was polled:

src/main.rsrust
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct Countdown {
    remaining: u32,
    polls: u32,
}

impl Future for Countdown {
    type Output = u32;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        self.polls += 1;
        if self.remaining == 0 {
            Poll::Ready(self.polls)
        } else {
            self.remaining -= 1;
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

#[tokio::main]
async fn main() {
    let polls = Countdown { remaining: 3, polls: 0 }.await;
    println!("ready after {polls} polls");
}
Text
ready after 4 polls

Follow the control flow. The first three polls find remaining above zero, decrement it, and return Pending. The fourth finds zero and returns Ready with the poll count. The interesting line is cx.waker().wake_by_ref(), executed before each Pending. It tells the executor "I am not done, but poll me again straight away". The executor re-queues the task, so the future gets another turn without any external event. This is how a cooperative future yields while staying runnable, and it is what tokio::task::yield_now does under the hood.

The struct has two plain integer fields, so it is Unpin, and self.polls += 1 works through the Pin because a pinned reference to an Unpin type dereferences mutably like any other. The mut in mut self: Pin<&mut Self> makes the binding itself mutable; the DerefMut that lets you write through it exists only for Unpin targets.

The mistake: Pending without a wake-up

Delete the one interesting line and keep everything else. To make the failure observable instead of a frozen terminal, the program wraps the future in a half-second timeout:

src/main.rsrust
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct Countdown {
    remaining: u32,
    polls: u32,
}

impl Future for Countdown {
    type Output = u32;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        self.polls += 1;
        if self.remaining == 0 {
            Poll::Ready(self.polls)
        } else {
            self.remaining -= 1;
            Poll::Pending
        }
    }
}

#[tokio::main]
async fn main() {
    let job = Countdown { remaining: 3, polls: 0 };
    match tokio::time::timeout(std::time::Duration::from_millis(500), job).await {
        Ok(polls) => println!("ready after {polls} polls"),
        Err(e) => println!("gave up: {e}"),
    }
}
Text
gave up: deadline has elapsed

The timeout fired because the future was polled once, returned Pending, and was never polled again. Nothing woke it. The task is parked, and no event exists that would unpark it. Without the timeout, this program would run forever at 0 percent CPU, which makes it hard to find: there is no spin, no panic, no log line.

The compiler does give one hint, and it is a hint only because this particular future ignores its context. Building the program prints warning: unused variable: cx. A poll that returns Pending and never touches cx is almost always this bug, so treat that warning as an error in code that implements Future. The fix is to register the waker, either with wake_by_ref() as above when the future wants an immediate retry, or by storing cx.waker().clone() for something else to call, which is the subject of the timer below. The same hang appears in async code that uses a hand-written future with a bug, and debugging hung async Rust shows how to find it in a running process.

The mistake: mutating through a Pin

The countdown above worked because it is Unpin. Mark a future as one that must not move, with PhantomPinned, and the same body stops compiling:

src/main.rsrust
use std::future::Future;
use std::marker::PhantomPinned;
use std::pin::Pin;
use std::task::{Context, Poll};

struct Countdown {
    remaining: u32,
    _pin: PhantomPinned,
}

impl Future for Countdown {
    type Output = ();

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        if self.remaining == 0 {
            return Poll::Ready(());
        }
        self.remaining -= 1;
        cx.waker().wake_by_ref();
        Poll::Pending
    }
}

fn main() {}
Text
error[E0594]: cannot assign to data in dereference of `Pin<&mut Countdown>`
  --> src/main.rs:18:9
   |
18 |         self.remaining -= 1;
   |         ^^^^^^^^^^^^^^^^^^^ cannot assign
   |
   = help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Pin<&mut Countdown>`

For more information about this error, try `rustc --explain E0594`.
error: could not compile `lab` (bin "lab") due to 1 previous error

The message is the best short explanation of pinning there is. A Pin<&mut T> hands out a mutable reference only through DerefMut, and DerefMut is implemented only when T: Unpin. A mutable reference lets you call mem::swap or mem::replace, which move the value to a new address, and that would invalidate any pointer into the old location. For a type that opts out of Unpin, the pin promises that the value will stay put until it is dropped, so safe code is not allowed a general &mut.

You get the access back in one of two ways. The safe way is the pin-project crate, or its lighter cousin pin-project-lite, which generates a type-safe projection from Pin<&mut Outer> to Pin<&mut Field> for the fields that need pinning and to plain &mut for the ones that do not. The raw way is unsafe { self.get_unchecked_mut() }, with a proof obligation: you must not move anything out of the struct through that reference.

src/main.rsrust
use std::future::Future;
use std::marker::PhantomPinned;
use std::pin::Pin;
use std::task::{Context, Poll};

struct Countdown {
    remaining: u32,
    _pin: PhantomPinned,
}

impl Future for Countdown {
    type Output = ();

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        // SAFETY: only a plain integer field is written; the struct is never moved out.
        let this = unsafe { self.get_unchecked_mut() };
        if this.remaining == 0 {
            return Poll::Ready(());
        }
        this.remaining -= 1;
        cx.waker().wake_by_ref();
        Poll::Pending
    }
}

#[tokio::main]
async fn main() {
    Countdown { remaining: 2, _pin: PhantomPinned }.await;
    println!("done");
}
Text
done

Here the unsafe block writes one integer field and moves nothing, so the safety comment is simple. If a field were itself a future that you polled, you would project the pin to it and not take it by value. Most hand-written futures do not need this, because their fields are Unpin (integers, Arc, Option<Waker>) and the whole struct is too. You meet PhantomPinned mostly when something is self-referential, and the usual case is the state machine the compiler generates for an async block.

A future woken by another thread

Waking itself is silly. The realistic shape is a future waiting for something outside, and the classic teaching example is a timer: a background thread sleeps, sets a flag and calls the stored waker. State shared between the future and the thread lives behind an Arc<Mutex<_>>:

src/main.rsrust
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use std::thread;
use std::time::{Duration, Instant};

struct Shared {
    done: bool,
    waker: Option<Waker>,
    polls: u32,
}

struct Timer {
    shared: Arc<Mutex<Shared>>,
}

impl Timer {
    fn new(dur: Duration) -> Self {
        let shared = Arc::new(Mutex::new(Shared { done: false, waker: None, polls: 0 }));
        let for_thread = Arc::clone(&shared);
        thread::spawn(move || {
            thread::sleep(dur);
            let mut s = for_thread.lock().unwrap();
            s.done = true;
            if let Some(w) = s.waker.take() {
                w.wake();
            }
        });
        Timer { shared }
    }
}

impl Future for Timer {
    type Output = u32;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
        let mut s = self.shared.lock().unwrap();
        s.polls += 1;
        if s.done {
            Poll::Ready(s.polls)
        } else {
            s.waker = Some(cx.waker().clone());
            Poll::Pending
        }
    }
}

#[tokio::main]
async fn main() {
    let start = Instant::now();
    let polls = Timer::new(Duration::from_millis(50)).await;
    println!("timer fired after {} ms, polled {polls} times", start.elapsed().as_millis());
}
Text
timer fired after 53 ms, polled 2 times

The 50 ms request fired at 53 ms, and the future was polled exactly twice. The first poll found done false, stored a clone of the waker and returned Pending. The task then slept, with no CPU use and no polling, until the thread called wake(). The second poll found done true and returned Ready. Two polls for the whole wait is the entire efficiency argument for the model: compare a loop that checks a flag every millisecond, which would have polled about fifty times.

Three details here are easy to get wrong, and each is a class of bug on its own.

The waker is replaced on every Pending poll. A task can migrate between executor threads and between polls, and the waker passed to the latest poll is the only one guaranteed to refer to the right place. Storing the first one and never updating it is a classic source of a future that works on a single-threaded executor and hangs on a multi-threaded one. If cloning on every poll bothers you, call Waker::will_wake to check whether the stored waker would wake the same task and skip the clone if so.

The check and the registration happen under the same lock that the thread takes before it sets done. Otherwise there is a window: the future reads done == false, the thread sets done and finds no waker to call, and then the future stores its waker and returns Pending. The wake-up was lost, and the future waits for something that already happened. Holding one mutex across the read of the flag and the write of the waker makes the pair atomic with respect to the thread's pair. Lock-free versions use an atomic waker (futures::task::AtomicWaker) to close the same gap.

Finally, one thread per timer exists only because it is the shortest way to show the shape. A runtime keeps a single timer structure for all sleepers and one thread to drive it, which is why thousands of Tokio sleeps cost almost nothing (Tokio timeouts has the consequences for abandoned work).

An executor in forty lines

So far Tokio has been the thing that calls poll. It is not magic either, and a minimal block_on fits on a screen using only the standard library. This version also counts how many times the future was polled and woken, so the output can confirm the promise from the first section:

src/main.rsrust
use std::future::Future;
use std::pin::{Pin, pin};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::task::{Context, Poll, Wake, Waker};
use std::thread::{self, Thread};
use std::time::{Duration, Instant};

struct ThreadWaker {
    thread: Thread,
    wakes: AtomicU32,
}

impl Wake for ThreadWaker {
    fn wake(self: Arc<Self>) {
        self.wake_by_ref();
    }
    fn wake_by_ref(self: &Arc<Self>) {
        self.wakes.fetch_add(1, Ordering::SeqCst);
        self.thread.unpark();
    }
}

fn block_on<F: Future>(fut: F) -> (F::Output, u32, u32) {
    let mut fut = pin!(fut);
    let inner = Arc::new(ThreadWaker { thread: thread::current(), wakes: AtomicU32::new(0) });
    let waker = Waker::from(Arc::clone(&inner));
    let mut cx = Context::from_waker(&waker);
    let mut polls = 0;
    loop {
        polls += 1;
        if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
            return (v, polls, inner.wakes.load(Ordering::SeqCst));
        }
        thread::park();
    }
}

struct Sleep {
    deadline: Instant,
    started: bool,
}

impl Future for Sleep {
    type Output = ();
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        if Instant::now() >= self.deadline {
            return Poll::Ready(());
        }
        if !self.started {
            self.started = true;
            let waker = cx.waker().clone();
            let deadline = self.deadline;
            thread::spawn(move || {
                thread::sleep(deadline.saturating_duration_since(Instant::now()));
                waker.wake();
            });
        }
        Poll::Pending
    }
}

async fn two_steps() -> &'static str {
    Sleep { deadline: Instant::now() + Duration::from_millis(30), started: false }.await;
    Sleep { deadline: Instant::now() + Duration::from_millis(30), started: false }.await;
    "finished"
}

fn main() {
    let start = Instant::now();
    let (out, polls, wakes) = block_on(two_steps());
    println!("{out} in {} ms: {polls} polls, {wakes} wakes", start.elapsed().as_millis());
}
Text
finished in 67 ms: 3 polls, 2 wakes

The program has three parts. ThreadWaker implements the standard library's Wake trait, which is the easy way to make a Waker. Waking does one thing: it unparks the thread that is running block_on, after bumping a counter. block_on pins the future on the stack with the pin! macro, builds a Context from the waker, and then loops: poll, return if Ready, otherwise thread::park(), which blocks until some thread unparks it. That is an executor. Everything a real runtime adds (many tasks, a run queue, work stealing, I/O and timer drivers) is built around that same poll, park, wake cycle.

The future under test is an async fn, not a hand-written one. two_steps awaits two Sleep futures of 30 ms each, and the first time each is polled, it spawns a thread that sleeps and then wakes the stored waker. The result: 67 ms total, three polls and two wakes. The count is the interesting bit. Poll one starts the first sleep and returns Pending from inside two_steps. The first wake arrives at about 30 ms. Poll two resumes the async body past the first await, starts the second sleep and is pending again. The second wake arrives at about 60 ms, and poll three completes. Three polls for two awaits is the shape of any state machine: one call to start, then one call per resumption. Compare that with a design in which the executor polled every millisecond, where the same program would have polled sixty times.

Notice what the executor does not need to know. It does not know about sleeps, threads or timers. It knows one function, poll, and one callback, the waker. This separation is why futures from different libraries compose under any executor, and why Tokio-specific futures (anything using its timers or sockets) panic when polled by an executor that does not provide a Tokio runtime context: they look for state that only Tokio sets up. Our Sleep uses a plain thread, so it runs anywhere.

To keep this short, the timer thread-per-sleep and the lack of a task queue are shortcuts. The follow-up, a tiny async executor, adds a queue of tasks and a spawn. For the runtime you would actually use, see the Tokio runtime explained.

A combinator: join by hand

tokio::join! and futures::future::join look like language features. They are futures too, and a two-way join is short enough to read at once:

src/main.rsrust
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};

enum State<A: Future, B: Future> {
    Running { a: Pin<Box<A>>, b: Pin<Box<B>>, a_out: Option<A::Output>, b_out: Option<B::Output> },
    Done,
}

struct Join2<A: Future, B: Future> {
    state: State<A, B>,
}

fn join2<A: Future, B: Future>(a: A, b: B) -> Join2<A, B> {
    Join2 { state: State::Running { a: Box::pin(a), b: Box::pin(b), a_out: None, b_out: None } }
}

impl<A: Future, B: Future> Unpin for Join2<A, B> {}

impl<A: Future, B: Future> Future for Join2<A, B> {
    type Output = (A::Output, B::Output);

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let State::Running { a, b, a_out, b_out } = &mut self.state else {
            panic!("Join2 polled after completion");
        };
        if a_out.is_none() {
            if let Poll::Ready(v) = a.as_mut().poll(cx) {
                *a_out = Some(v);
            }
        }
        if b_out.is_none() {
            if let Poll::Ready(v) = b.as_mut().poll(cx) {
                *b_out = Some(v);
            }
        }
        if a_out.is_some() && b_out.is_some() {
            let out = (a_out.take().unwrap(), b_out.take().unwrap());
            self.state = State::Done;
            Poll::Ready(out)
        } else {
            Poll::Pending
        }
    }
}

async fn wait(ms: u64, tag: &'static str) -> &'static str {
    tokio::time::sleep(Duration::from_millis(ms)).await;
    tag
}

#[tokio::main]
async fn main() {
    let start = Instant::now();
    let (a, b) = join2(wait(100, "a"), wait(100, "b")).await;
    println!("{a} {b} in {} ms", start.elapsed().as_millis());
}
Text
a b in 102 ms

Two sleeps of 100 ms finish in 102 ms, so both ran concurrently. The mechanism is in poll. Each call polls the first future if it has not produced a value, polls the second if it has not, and stores whichever outputs arrive in Option slots. Only when both slots are full does it return Ready with the pair. Both inner futures receive the same Context, so whichever completes first wakes the one task that holds the join, and the join polls both again. Nothing in this is parallel: the inner futures are polled one after the other on the same thread, and what overlaps is the waiting.

That last point deserves emphasis. Joined futures share a single task, so they run on one thread at a time even in a multi-threaded runtime. If both bodies do heavy computation, join! will not use two cores. Use tokio::spawn for that, which creates separate tasks the runtime can place on different workers. The tradeoff for joining in-task is that the futures can borrow from the surrounding scope, which spawned tasks cannot (the 'static bound), and that dropping the join drops both, which is clean cancellation.

Two implementation choices are visible. The inner futures are boxed and pinned with Box::pin, which costs an allocation each but means the join struct holds only Pin<Box<_>> and can be Unpin itself, avoiding a pin projection. The real macros pin the futures in place on the stack with no allocation, which is why they are written with pin-project or macros. The State::Done variant also makes polling after completion a panic, in line with the Future contract, which allows any behaviour after Ready. A deliberate panic is the most helpful behaviour, because it points at the caller's bug.

What async fn compiles to, measured

A hand-written future holds its state as fields. The compiler does the same for an async block: it builds an enum with a variant for the start, one for each await point, and the finished state, and the fields of each variant are the locals that are still alive at that point. The size of the resulting value is therefore the size of the largest variant, plus a tag. That has a direct, visible effect, which this program prints:

src/main.rsrust
use std::mem::size_of_val;
use std::time::Duration;
use tokio::time::sleep;

async fn small() {
    let n = 7u32;
    sleep(Duration::from_millis(1)).await;
    println!("{n}");
}

async fn holds_buffer() {
    let buf = [0u8; 4096];
    sleep(Duration::from_millis(1)).await;
    println!("{}", buf[0]);
}

async fn drops_before_await() {
    {
        let buf = [0u8; 4096];
        println!("{}", buf[0]);
    }
    sleep(Duration::from_millis(1)).await;
}

fn main() {
    println!("small:              {} bytes", size_of_val(&small()));
    println!("holds_buffer:       {} bytes", size_of_val(&holds_buffer()));
    println!("drops_before_await: {} bytes", size_of_val(&drops_before_await()));
}
Text
small:              120 bytes
holds_buffer:       4216 bytes
drops_before_await: 120 bytes

The first function keeps a u32 across its single await, and the future is 120 bytes (most of that is the inner sleep future with its timer entry). The second keeps a 4 KB array alive across the await, because it reads buf[0] after, and the future grows to 4216 bytes, the extra 4096 being exactly the buffer. The third creates the same array but uses it inside a block that ends before the await, so it is dropped first and the future is back to 120 bytes.

This is the practical consequence of "one variant per await point". A local that is not live at an await does not occupy the future. A future that spawns into ten thousand tasks and holds a big buffer across one await costs ten thousand times as much memory as one that scopes it. Moving large values into a Box before the await, or ending their scope with a block as above, are the fixes. This is also why the compiler's error for holding a MutexGuard across an await names the await and the variable: it works out liveness at each suspension point, and a value live at one has to be stored in the future (and therefore must be Send if the future is to be). The same analysis is why async recursion requires boxing: a recursive async fn would be a type that contains itself.

Rules to carry out of this

  • Return Pending only after arranging a wake-up. If you did not store the waker or call it, you wrote a hang.
  • Replace the stored waker on each Pending poll, or check will_wake.
  • Make the check of the condition and the registration of the waker one atomic step with respect to the code that sets the condition.
  • Never block inside poll. It runs on the executor's thread, and the blocking-call failure from blocking the Tokio runtime applies in full.
  • Do not poll after Ready unless the future documents what happens.
  • Dropping a future is cancellation. If the future owns a thread, a socket or a registration, implement Drop to release it.

When you should not write one

You rarely need to implement Future directly. If the condition is a message arriving, a tokio::sync::oneshot or Notify does the waker bookkeeping correctly, including the lost-wakeup race above; Tokio channels compared shows which to pick. If you want to combine futures, use join!, select! or a JoinSet. If you want a stream of values, use the types in async streams. Hand-written futures belong at the bottom of the stack, in a library that wraps a new kind of event source such as a hardware peripheral or a foreign callback, and in the cases where an async fn would need to hold a lock across an await it should not. For anything that borrows heavily, the pin projection rules above are error-prone enough that pin-project-lite is the sensible default over raw unsafe.

The programs here also skip several things a production future handles: panic safety, spurious wake-ups that arrive with the condition still false (so always recheck it), and waker cloning cost under heavy contention. The language side is specified in the documentation of the Future trait and the std::task module, which is where the contract about wakers is written down. To see how the pieces fit into a working service, continue to async Rust and Tokio, and for the awaited side of everything here, async and await explained.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.