You have written futures that return Pending, and a driver that re-polls unconditionally, so nothing has gone wrong yet. That combination is a training-wheels arrangement. Real drivers do not busy-spin: they poll a task, and then they go to sleep until somebody tells them that task can make progress.
The thing that tells them is a Waker, and the rules for using it are a genuine contract. Break it and you get the worst-behaved bug in async programming: a program that hangs, at zero percent CPU, with no error, no panic, no log line, and a stack that looks completely healthy.
Context is a &Waker and (for now) nothing else
pub struct Context<'a> { /* .. */ }
impl<'a> Context<'a> {
pub fn waker(&self) -> &'a Waker;
}
Context is deliberately a struct rather than passing the &Waker directly, so that future Rust versions can add things to it without breaking every poll signature in the ecosystem. Today the only part that matters is cx.waker().
A Waker is a handle with one job: signal that this task should be polled again. Two ways to use it:
waker.wake(); // consumes the Waker
waker.wake_by_ref(); // keeps it
Both do the same thing to the runtime. wake_by_ref exists so a future that stored a waker can signal repeatedly without cloning, and so a future that borrowed one from the Context can signal without taking ownership it does not have.
The contract, in three clauses
Clause 1 — if you return Pending, arrange to be woken
When
pollreturnsPending, the future must ensure the waker is signalled once it can make progress.
This is the whole deal. Pending is a promise, not a shrug. It means “not now, and I have made arrangements for someone to tell you when.”
Where those arrangements live depends on what you are waiting for. A socket future registers the waker with the reactor’s epoll set. A channel receiver stores it in the channel so the sender can wake it. A yield_now() — the simplest possible case — just calls wake_by_ref() immediately, saying “nothing to wait for, put me at the back of the queue.”
Return Pending without doing any of that and your task is simply lost. Nobody will ever poll it again. The runtime is not broken; it is doing exactly what it was told.
The corresponding rule for the driver is that a wakeup may be spurious. A future can be polled when nothing has changed — an extra poll is always allowed, so poll must be safe to call at any time and must re-check its condition rather than assuming a wakeup means readiness.
Clause 2 — only the most recent waker counts
On repeated polls, only the waker from the most recent
Contextshould be scheduled. Wakers are not cumulative.
This is the clause people get wrong, and it is worth reading twice.
A future can be polled many times before it finishes, and it is not guaranteed to receive the same Waker each time. It may not even be polled by the same task, or on the same thread. Every call to poll supplies the currently correct way to wake whoever is waiting right now, and any waker from a previous poll may by then be stale.
So the rule for a future that stores a waker is: overwrite it, every time. Not if self.waker.is_none(). Not “keep them all in a Vec“. Store the newest one and discard the old.
The bug this prevents looks like this. A future is polled by task A, caches A’s waker, and returns Pending. Later the future is moved into task B — awaited from a different task, migrated by a work-stealing scheduler, or picked up by a different select! arm. B polls it; it already has a waker so it keeps the old one. The resource becomes ready, the future wakes A, and A has nothing to do with this any more. B waits forever.
Item 16.18 makes exactly this happen and asserts on the result, so it stops being a story.
Waker::will_wake(&other) exists as an optimisation: it answers “would these two wakers wake the same task?”, so you can skip a clone when the answer is yes. It is explicitly best-effort and is allowed to return false for two wakers that would in fact wake the same task. Use it to avoid work, never to decide correctness.
Clause 3 — poll must not block, and must not be spun
pollshould complete quickly, must not block, and should not be called in a tight loop.
Two audiences here. Futures must not block inside poll, because poll runs on the runtime’s worker thread and there is nothing else to run while it does. And drivers should not busy-poll, because the whole point of the waker is that the driver can go to sleep.
Your block_on from item 16.4 violates the second half, cheerfully and on purpose. It is fine for a driver whose entire job is to run one future to completion on a thread that has nothing else to do — that is genuinely what block_on is for — but it means bugs in clause 1 are invisible to it.
Why a lost wakeup is such a bad bug
Compare it with the failure modes you already know.
A deadlock between two mutexes shows up in a stack dump: two threads, each parked in lock, and the cycle is visible. A panic prints a message and a backtrace. An infinite loop pins a core to 100% and perf shows you where.
A lost wakeup produces none of that. The task is not in any list. Nothing is holding a lock. No thread is running. The runtime is idle and correct — it has nothing to do because nothing told it there was anything to do. From the outside your program has simply stopped, and every diagnostic tool agrees that everything is fine.
The only way to find it is to know that the failure mode exists and to go looking at which future last returned Pending and what it did with cx.waker() before it did.
💡You are writing a future that reads from a shared buffer. You store cx.waker().clone() in the buffer's Option<Waker> slot so the writer can wake you. What must the writer do, and in what order, when it puts data in?
click to reveal
Write the data first, then take the waker out, then release the lock, then wake. In that order.
Data before wake: waking first is a race. The woken task can be polled before the data lands, sees nothing, and parks again — having consumed the only wakeup it was going to get. The wake must be the last thing that happens after the state is already correct.
Take rather than borrow: the woken future will store a fresh waker on its next Pending, so the old one is spent. Leaving it in place means the next writer wakes a stale handle, which is clause 2 all over again.
Release the lock before waking: on a single-threaded executor, wake() can synchronously push onto a ready queue that the woken task will immediately touch, and on any executor it may run arbitrary code. Waking while holding the lock invites either a re-entrant RefCell panic or a genuine deadlock. Compute under the lock, act outside it.
You will write exactly this handshake in item 16.18, and the order is the whole of what makes it work.
What to take away
Three sentences.
Returning Pending obliges you to arrange a wakeup. The waker you must arrange it with is the one from the most recent poll. And nobody — not the compiler, not clippy, not any test that happens to use a busy-spinning driver — will tell you when you get either of them wrong.
Item 16.7 is where you build the machinery, and where a future that forgets produces a null instead of a hang.