Skip to content

← Async From First Principles step 4 of 25

Easy Primitives

`block_on`: the smallest thing that can run a future

Rust’s standard library ships async/.await syntax and the Future trait — and no executor. Nothing in std will run a future for you. That is a deliberate decision: a kernel, a microcontroller and a web server want completely different schedulers, so the language stays out of it.

The consequence is that async Rust does not work until somebody writes a driver. Today that somebody is you.

pub fn block_on<F: Future>(fut: F) -> F::Output
pub fn run_to_completion(steps: u32) -> u32

You are given a Countdown future that returns Pending steps times and then resolves to the number of times it was polled. Write block_on, and make run_to_completion(steps) drive a Countdown and return that count. The answer is always steps + 1: steps pending polls plus the one that finishes.

The whole driver

Four ingredients, and nothing else:

  1. Pin the future. poll demands Pin<&mut Self>, so the future has to sit at a fixed address. std::pin::pin!(fut) (stable 1.68) pins it on the stack and gives you back a Pin<&mut F>. Box::pin(fut) does the same on the heap.
  2. Build a Context. Context::from_waker(Waker::noop()) — remember Waker::noop() already hands you a &'static Waker, so no extra &.
  3. Loop. Poll. On Ready(v), return v. On Pending, go round again.
  4. That is it.

The stumble that is guaranteed to happen

The starter contains it, so you will meet it immediately:

loop {
    match fut.poll(&mut cx) { ... }
}

E0382: use of moved value: fut. pin! gives you a Pin<&mut F>, and Future::poll takes self: Pin<&mut Self> by value. The first iteration consumes the pin; there is nothing left for the second. Pin is not Copy, and it deliberately is not — handing out two Pin<&mut T> to the same place would be handing out two &mut.

The fix is Pin::as_mut, which reborrows:

fut.as_mut().poll(&mut cx)

as_mut takes &mut self and produces a shorter-lived Pin<&mut F>, leaving the original intact. This is the pin equivalent of reborrowing a &mut, and you will use it in every single future you write from here on.

Name the flaw before it bites you

The driver you just wrote busy-spins. It re-polls unconditionally, as fast as the CPU allows, whether or not the future could possibly have made progress. Two consequences:

  • It burns a core for nothing. Real drivers park the thread and let the waker unpark it.
  • More interestingly, it hides bugs. A future that returns Pending and forgets to register the waker is broken — but this driver polls it again anyway, so it works. Swap in a correct driver and the same future hangs forever with zero CPU usage.

That failure has a name, lost wakeup, and it is the subject of items 16.6 and 16.7. For now, notice that the Countdown you were given calls cx.waker().wake_by_ref() before returning Pending, even though this driver does not need it to. That is politeness that will turn out to be a requirement.

Watch the loop

If you take the poll out of the loop body by accident you get loop {}, which clippy rejects as clippy::empty_loop — a genuinely useful accident, because a bare loop {} in a driver is an infinite hang rather than a compile error in most languages.

Loading visualization…