warming up your workspace

async/await, explained by building the executor underneath it

By 2026, async Rust has crossed from "interesting" to "expected". Production adoption of Rust sits near half of systems teams, compile times for medium projects dropped from around 35 seconds to 8, and async/.await reads about as plainly as it does in Go or Python. Government memory-safety guidance keeps pushing it into new infrastructure. So it is a good moment to answer the question most people skip: when you write .await, what actually runs it?

The answer surprises people coming from other languages. Rust ships the async and .await keywords and the Future trait, and then it ships no runtime to drive them. You bring your own, or you pull in tokio. That sounds like a gap. It is actually the whole design, and you can build the missing piece yourself in a single file.

The one idea

A future is not a running task. It is a poll-able state machine. You poll it, and it answers one of two things: Ready(value), it finished, here is the result, or Pending, not yet, and it will arrange to be woken when there is progress. Nothing happens until something polls it. That is why Rust futures are called "lazy": an async block that no one polls does exactly nothing.

An executor is the something that polls. That is its entire job.

A future you write by hand

To see the state machine clearly, skip async for a moment and implement Future directly. This one returns Pending a few times, then Ready, remembering its progress in a field.

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct Yield { left: u32 }

impl Future for Yield {
    type Output = &'static str;
    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        if self.left == 0 {
            Poll::Ready("done")
        } else {
            self.left -= 1;
            cx.waker().wake_by_ref();   // "there is more, poll me again"
            Poll::Pending
        }
    }
}

Three details that matter:

  • The state lives in the struct (left). Each poll advances it. When you write an async fn, the compiler generates a struct exactly like this, with a field for every value that has to survive across an .await.
  • Pending is a promise, not a dead end. Before returning it, a well-behaved future calls the waker so the executor knows to come back. Forget that and your task hangs forever.
  • Pin is the guarantee that the future will not be moved in memory while it is suspended, which matters because a generated state machine can hold references into itself.

The executor that drives it

The executor owns the waker and the poll loop. A production executor parks the thread and sleeps until a waker fires. Ours keeps a no-op waker and polls in a tight loop, which is enough to watch the machine turn.

use std::task::{Waker, RawWaker, RawWakerVTable};

fn block_on<F: Future>(mut fut: F) -> F::Output {
    let waker = noop_waker();
    let mut cx = Context::from_waker(&waker);
    let mut fut = unsafe { Pin::new_unchecked(&mut fut) };
    let mut polls = 0;
    loop {
        polls += 1;
        match fut.as_mut().poll(&mut cx) {
            Poll::Ready(v) => { println!("finished after {} polls", polls); return v; }
            Poll::Pending => continue,   // real runtimes sleep here until woken
        }
    }
}

fn noop_waker() -> Waker {
    fn no_op(_: *const ()) {}
    fn clone(_: *const ()) -> RawWaker { RawWaker::new(std::ptr::null(), &VTABLE) }
    static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, no_op, no_op, no_op);
    unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }
}

The waker plumbing looks heavy, but it is doing one honest thing: giving a future a handle to say "wake me". The difference between our busy loop and tokio is only what happens on Pending, we continue, they sleep the thread and wait for a waker to signal real work, so an idle server uses no CPU.

.await is just polling, threaded together

Now bring back async. An async fn is a factory that returns one of these state machines, and .await polls an inner future, forwarding Pending upward until the inner one is Ready.

async fn task() -> u32 {
    let a = (Yield { left: 3 }).await;   // suspends 3 times
    let b = (Yield { left: 2 }).await;   // then 2 more
    println!("a={a}, b={b}");
    42
}

fn main() {
    println!("task returned {}", block_on(task()));
}

Run it and you get:

a=done, b=done
finished after 6 polls
task returned 42

Six polls: three to drain the first Yield, two for the second, and one more that reaches the end and returns Ready(42). The compiler stitched two little state machines into one, and our executor turned the crank until it stopped. There was never a hidden thread pool or a background scheduler. There was a struct and a loop.

Where this shows up

Every async runtime you will actually use, tokio on servers, embassy on microcontrollers with no operating system at all, is this same contract with a smarter executor: a real waker that parks and unparks threads, a task queue, a reactor that watches sockets and timers and fires wakers when they are ready. The Future trait and the poll model are identical from a bare-metal blinking LED to a web service handling a hundred thousand connections.

That portability is the payoff of Rust putting the runtime in your hands instead of the language. If you want to build the real thing next, a multi-task executor with a proper waker and a task queue, that is where the rust track on IWTLP goes from here.

Sources