Skip to content
← Back to explainers

Rust explainer

Waker and Context: the wakeup contract

About 11 min to read. Allow extra time to work through examples.

What a future must do with the waker when it returns Pending, why it must keep the waker from the most recent poll, and how a missed wakeup can leave a task idle with no error.

This article uses Rust. You can make it your practice mode to browse related content across the site.

An executor is the code that drives futures by calling poll on them. The simplest executor, such as the busy-polling block_on in item 16.4, calls poll again immediately whenever a future returns Pending. That works, but it keeps a CPU core busy while nothing is happening. Most executors instead poll a task, set it aside if it returns Pending, and poll it again only after something reports that it may be able to make progress.

That report is made through a Waker. This article describes the rules a future follows when it uses one. If a future breaks them, its task can stop being polled even though the work it was waiting for has finished. Nothing panics and no error is reported; the program appears to stop.

Context and Waker

poll receives a &mut Context<'_>. On stable Rust, the part of Context that futures use is its waker:

impl<'a> Context<'a> {
    pub fn from_waker(waker: &'a Waker) -> Context<'a>;
    pub fn waker(&self) -> &'a Waker;
}

Context is a struct rather than a bare &Waker so the standard library can add more information later without changing the signature of Future::poll. Some additions exist as unstable APIs, but they are not available on stable Rust.

A Waker is a handle that asks the executor to poll a particular task again. A task is the top-level future an executor is responsible for; the futures it awaits are polled as part of it. Waker is Clone, Send and Sync, so a future can store a copy and another thread can use it later. There are two ways to signal:

waker.wake_by_ref(); // signals and keeps the waker
waker.wake();        // signals and consumes the waker

Both ask for the same thing. Use wake_by_ref when you need to keep the waker or only have a reference, for example cx.waker().wake_by_ref(). Use wake when you own a waker you no longer need. Depending on how the waker is implemented, consuming it can avoid work such as an extra reference-count update.

A wakeup is a request, not a poll. Calling wake does not run the future; it tells the executor to schedule it, and the executor polls it later.

Rule 1: before returning Pending, arrange a wakeup

The documentation for Future::poll describes the expected behavior: when a future is not ready, it returns Poll::Pending and keeps a clone of the waker from the current Context, and that waker is woken once the future can make progress.

Returning Pending therefore means "not ready yet, and something will wake this task when it is worth polling again." What that something is depends on what the future waits for:

  • A future waiting on a socket registers its waker with the runtime's I/O component, often called a reactor, which wakes it when the operating system reports the socket is ready.
  • A channel receiver stores its waker in the channel, and the sender wakes it after sending a value.
  • A simple yield future calls cx.waker().wake_by_ref() before returning Pending. It has nothing to wait for; it only wants other tasks to get a turn first.

A future that returns Pending only because a future it awaits returned Pending does not need its own arrangement: the inner future has already made one with the same Context.

If a future returns Pending without any arrangement, and nothing else wakes the task, an executor that waits for wakeups never polls the task again. The executor is behaving as documented; it was never told the task was ready.

The rule also runs the other way. A future can be polled when nothing it waits for has changed: another future in the same task may have caused the wakeup, or the executor may poll more often than needed. These are called spurious wakeups. So poll must check whether it can actually make progress, and if it cannot, return Pending again with a fresh arrangement. The one call a future does not have to tolerate is a poll after it has already returned Ready; the documentation says such a poll may panic, block forever or misbehave in other ways.

Rule 2: keep the waker from the most recent poll

The documentation adds: when poll is called more than once, only the waker from the Context passed to the most recent call should be scheduled to receive a wakeup. Wakers do not accumulate.

A future can be polled many times before it finishes, and it may receive a different waker on different polls. Two common ways this happens:

  • A future is polled while it belongs to one task and is later moved into another task, which polls it with its own waker.
  • A combinator that drives many futures, such as futures::stream::FuturesUnordered, can give each inner future its own waker so it knows which one to poll. A future polled directly and later placed inside such a combinator sees a new waker.

In both cases the old waker wakes something that is no longer waiting for this future. If the future kept the old waker, the new owner is never woken.

The simplest correct policy is to replace the stored waker on every poll that returns Pending. Do not store it only if self.waker.is_none(), and do not collect wakers in a Vec. Here is a receiver that follows the policy:

use std::cell::RefCell;
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Context, Poll, Waker};

struct Slot {
    value: Option<u32>,
    waker: Option<Waker>,
}

struct Recv {
    slot: Rc<RefCell<Slot>>,
}

impl Future for Recv {
    type Output = u32;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
        let mut slot = self.slot.borrow_mut();
        if let Some(value) = slot.value.take() {
            return Poll::Ready(value);
        }
        // Replace whatever an earlier poll stored.
        slot.waker = Some(cx.waker().clone());
        Poll::Pending
    }
}

Each Pending return overwrites slot.waker with a clone of the current waker, so the sender always wakes whoever polled most recently. If a value is already present, poll returns it without touching the waker.

Item 16.18 builds a join handle whose starter keeps only the first waker. Its driver polls each handle once with a waker that does nothing before the collecting task sees it, so keeping the first waker means the collecting task is never woken and the run ends with an empty result.

Waker::will_wake(&other) can avoid a clone when the stored waker is already correct. If it returns true, the two wakers are guaranteed to wake the same task. It works on a best-effort basis and may return false even for two wakers that would wake the same task. Treat it as an optimisation: when it returns false, replace the stored waker.

match &mut slot.waker {
    Some(old) if old.will_wake(cx.waker()) => {}
    stored => *stored = Some(cx.waker().clone()),
}

Rule 3: poll should return quickly and should not be called in a loop

The documentation also says that poll implementations should try to return quickly and should not block, and that a future should be polled again only after its waker has been called. This rule has two sides.

A future should not block inside poll, for example by waiting on a std::sync::Mutex held for a long time or by calling a blocking read. While poll blocks, the executor thread running it cannot poll any other task. Blocking is not undefined behavior, but it can make other tasks slow or, if they are the ones that would unblock it, stop progress entirely.

An executor should not poll a future in a tight loop, because the waker exists so the executor can sleep until there is work. The block_on from item 16.4 polls in a loop on purpose. That is acceptable for a teaching driver with one future and nothing else to do, but it hides mistakes with rule 1: a future that forgets to arrange a wakeup still gets polled, so the program works under that driver and stops under an executor that waits for wakeups.

Why a missed wakeup is hard to diagnose

Many concurrency bugs leave visible evidence. A panic prints a message. An infinite loop keeps a CPU core busy, and a profiler shows where. A deadlock between two threads usually shows up in a thread dump as two threads each blocked on a lock.

A missed wakeup often leaves none of those. The waiting task is not on the executor's ready queue, so nothing polls it. No thread needs to be blocked on a lock and no thread needs to be running its code. The executor sees no work and stays idle. A thread dump shows idle worker threads, which is also what a healthy program waiting for input looks like. Runtime-specific tools can help. For Tokio, tokio-console lists tasks and how long each has been idle, and can warn when a task no longer has any wakers. It still cannot tell you which code should have woken the task.

To find it, identify which future last returned Pending for the stuck task, and check what it did with cx.waker() before returning: whether it stored the current waker, where it stored it, and whether the code that should wake it actually runs.

Check your understanding A future reads from a shared buffer protected by a lock. Before returning Pending, it stores cx.waker().clone() in the buffer's Option<Waker> field so a writer can wake it. In what order should a writer store data and wake the reader? Show answer

Store the data first, then take the waker out of the field, then release the lock, then call wake.

Data before wake. If the writer wakes first, the reader can be polled before the data is stored. It finds nothing, stores a waker and returns Pending again. If no other wakeup follows, it waits indefinitely. Wake only after the shared state shows what the reader is waiting for.

Take rather than leave in place. take() moves the waker out of the field so the writer owns it after releasing the lock. It also leaves None behind, so a later writer does not wake a waker the reader has finished with. A reader that follows rule 2 stores a fresh waker the next time it returns Pending.

Release the lock before waking. wake runs code chosen by whoever implemented the waker. If that code tries to use the same lock, it fails while the writer still holds it: a RefCell borrow panics because the cell is already mutably borrowed, and locking a std::sync::Mutex again on the thread that holds it may deadlock or panic. Update state while holding the lock, and wake after releasing it.

The send function below follows that order for the Slot type from rule 2:

fn send(slot: &Rc<RefCell<Slot>>, value: u32) {
    let waker = {
        let mut slot = slot.borrow_mut();
        slot.value = Some(value);
        slot.waker.take()
    }; // the mutable borrow ends here
    if let Some(waker) = waker {
        waker.wake();
    }
}

Item 16.18 uses the same handshake when a spawned task finishes and wakes the task awaiting its handle.

Summary

  • Returning Pending obliges a future to make sure something will wake its task when it can make progress.
  • The waker to store is the one from the most recent poll. Replace the stored waker each time.
  • poll must tolerate spurious wakeups, should not block, and should not be called in a tight loop.

The compiler does not check any of these rules, and a busy-polling driver hides violations of the first two. Item 16.7 builds a waker-driven executor loop in which a future that returns Pending without requesting a wakeup produces None (serialized as null) instead of a result.

step 6 of 25

Async From First Principles

Up next Queue another poll with Wake