3. How Poll Works 🟡
What you'll learn:
- The executor's poll loop: poll → pending → wake → poll again
- How to build a minimal executor from scratch
- Spurious wake rules and why they matter
- Utility functions:
poll_fn()andyield_now()
The Polling State Machine
The executor runs a loop: poll a future, if it's Pending, park it until its waker fires, then poll again. This is fundamentally different from OS threads where the kernel handles scheduling.
stateDiagram-v2
[*] --> Idle : Future created
Idle --> Polling : executor calls poll()
Polling --> Complete : Ready(value)
Polling --> Waiting : Pending
Waiting --> Polling : waker.wake() called
Complete --> [*] : Value returned
Important: While in the Waiting state the future must have registered the waker with an I/O source. No registration = hang forever.
A Minimal Executor
To demystify executors, let's build the simplest possible one:
use std::future::Future;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
use std::pin::Pin;
/// The simplest possible executor: busy-loop poll until Ready
fn block_on<F: Future>(mut future: F) -> F::Output {
// Pin the future on the stack
// SAFETY: `future` is never moved after this point — we only
// access it through the pinned reference until it completes.
let mut future = unsafe { Pin::new_unchecked(&mut future) };
// Create a no-op waker (just keeps polling — inefficient but simple)
fn noop_raw_waker() -> RawWaker {
fn no_op(_: *const ()) {}
fn clone(_: *const ()) -> RawWaker { noop_raw_waker() }
let vtable = &RawWakerVTable::new(clone, no_op, no_op, no_op);
RawWaker::new(std::ptr::null(), vtable)
}
let waker = unsafe { Waker::from_raw(noop_raw_waker()) };
let mut cx = Context::from_waker(&waker);
// Busy-loop until the future completes
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(value) => return value,
Poll::Pending => {
// A real executor would park the thread here
// and wait for waker.wake() — we just spin
std::thread::yield_now();
}
}
}
}
// Usage:
fn main() {
let result = block_on(async {
println!("Hello from our mini executor!");
42
});
println!("Got: {result}");
}
Don't use this in production! It busy-loops, wasting CPU. Real executors (tokio, smol) use
epoll/kqueue/io_uringto sleep until I/O is ready. But this shows the core idea: an executor is just a loop that callspoll().
Wake-Up Notifications
A real executor is event-driven. When all futures are Pending, the executor sleeps. The waker is an interrupt mechanism:
// Conceptual model of a real executor's main loop:
fn executor_loop(tasks: &mut TaskQueue) {
loop {
// 1. Poll all tasks that have been woken
while let Some(task) = tasks.get_woken_task() {
match task.poll() {
Poll::Ready(result) => task.complete(result),
Poll::Pending => { /* task stays in queue, waiting for wake */ }
}
}
// 2. Sleep until something wakes us up (epoll_wait, kevent, etc.)
// This is where mio/polling does the heavy lifting
tasks.wait_for_events(); // blocks until an I/O event or waker fires
}
}
Spurious Wakes
A future may be polled even when its I/O isn't ready. This is called a spurious wake. Futures must handle this correctly:
impl Future for MyFuture {
type Output = Data;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Data> {
// ✅ CORRECT: Always re-check the actual condition
if let Some(data) = self.try_read_data() {
Poll::Ready(data)
} else {
// Re-register the waker (it might have changed!)
self.register_waker(cx.waker());
Poll::Pending
}
// ❌ WRONG: Assuming poll means data is ready
// let data = self.read_data(); // might block or panic
// Poll::Ready(data)
}
}
Rules for implementing poll():
- Never block — return
Pendingimmediately if not ready - Always re-register the waker — it may have changed between polls
- Handle spurious wakes — check the actual condition, don't assume readiness
- Don't poll after
Ready— behavior is unspecified (may panic, returnPending, or repeatReady). OnlyFusedFutureguarantees safe post-completion polling
Challenge: Implement a CountdownFuture that counts down from N to 0, printing the current count as a side-effect each time it's polled. When it reaches 0, it completes with Ready("Liftoff!"). (Note: a Future produces only one final value — the printing is a side-effect, not a yielded value. For multiple async values, see Stream in Ch. 11.)
Hint: This doesn't need a real I/O source — it can wake itself immediately with cx.waker().wake_by_ref() after each decrement.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct CountdownFuture {
count: u32,
}
impl CountdownFuture {
fn new(start: u32) -> Self {
CountdownFuture { count: start }
}
}
impl Future for CountdownFuture {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.count == 0 {
Poll::Ready("Liftoff!")
} else {
println!("{}...", self.count);
self.count -= 1;
// Wake immediately — we're always ready to make progress
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
// Usage with our mini executor or tokio:
// let msg = block_on(CountdownFuture::new(5));
// prints: 5... 4... 3... 2... 1...
// msg == "Liftoff!"
Key takeaway: Even though this future is always ready to progress, it returns Pending to yield control between steps. It calls wake_by_ref() immediately so the executor re-polls it right away. This is the basis of cooperative multitasking — each future voluntarily yields.
Handy Utilities: poll_fn and yield_now
Two utilities from the standard library and tokio that avoid writing full Future impls:
use std::future::poll_fn;
use std::task::Poll;
// poll_fn: create a one-off future from a closure
let value = poll_fn(|cx| {
// Do something with cx.waker(), return Ready or Pending
Poll::Ready(42)
}).await;
// Real-world use: bridge a callback-based API into async
async fn read_when_ready(source: &MySource) -> Data {
poll_fn(|cx| source.poll_read(cx)).await
}
// yield_now: voluntarily yield control to the executor
// Useful in CPU-heavy async loops to avoid starving other tasks
async fn cpu_heavy_work(items: &[Item]) {
for (i, item) in items.iter().enumerate() {
process(item); // CPU work
// Every 100 items, yield to let other tasks run
if i % 100 == 0 {
tokio::task::yield_now().await;
}
}
}
When to use
yield_now(): If your async function does CPU work in a loop without any.awaitpoints, it monopolizes the executor thread. Insertyield_now().awaitperiodically to enable cooperative multitasking.
Key Takeaways — How Poll Works
- An executor repeatedly calls
poll()on futures that have been woken- Futures must handle spurious wakes — always re-check the actual condition
poll_fn()lets you create ad-hoc futures from closuresyield_now()is a cooperative scheduling escape hatch for CPU-heavy async code
See also: Ch 2 — The Future Trait for the trait definition, Ch 5 — The State Machine Reveal for what the compiler generates