Articles
you write
async fn hi(who: &str) {    println!("ready…");    sleep(1s).await;    println!("hi {who}!");} #[tokio::main]async fn main() {    let mut buf = [0u8; 32];    // type your name + ⏎    let n = stdin().read(&mut buf);    let me = str::from_utf8(&buf[..n])
        .trim_end();    hi(me).await;}
terminal
henrik ready… hi henrik!
what runs executor machines Sleep timer poll #1 poll waker @ t+1s Pending Pending ≈ 1 s — thread parked, future inert wake() → thread unparks poll #2 Ready(()) Ready(()) — done executor machines Sleep timer poll #1 poll waker @ t+1s Pending Pending ≈ 1 s — thread parked, future inert wake() → thread unparks poll #2 Ready(()) Ready(()) — done
main thread —  running parked · 0% CPU
the compiler produces
#[tokio::main] — just a macro
//               your async main, as a value ↓fn main() { 
    Runtime::new().unwrap()
        // async main, as a value ↓
        .block_on(MainMachine::Start) 
}
async fn — a plain fn returning a machine
// the arg becomes a fieldfn hi(who: &str) -> Hi<'_> { 
    Hi::Start { who } 
}enum Hi<'a> {    Start { who: &'a str },    AwaitingSleep { 
        sleep: Sleep, 
        who: &'a str,
     },    Done,}// async main becomes one tooenum MainMachine {    Start,    // ↓ hi.who points into buf    AwaitingHi { 
        buf: [u8; 32], 
        hi: Hi<'…>,
     },    Done,}
its poll() — and what .await desugars to
impl Future for Hi<'_> {  fn poll(self: Pin<&mut Self>, 
          cx: &mut Context<'_>)      -> Poll<()> {    loop {      match *self {        Start { who } => {          println!("ready…");          *self = AwaitingSleep { … };        }        // the .await: poll the child…        AwaitingSleep { sleep, who } 
          => match sleep.poll(cx) {          // …on Pending, return Pending yourself
          // return Pending yourself          Pending  => return Pending,          // loop iteration 1
          Ready(_) => {  // loop iteration 1            println!("hi {who}!");            *self = Done;          }        },        // loop iteration 2
        Done => return Ready(()),  // loop iteration 2      }    }  }}

↑ poll #2 resumes at AwaitingHi → AwaitingSleep — not from the top

1/16 · why this page

A visual guide to Rust async

I have always felt that I missed some parts of the async story. I could never visualize it.

Collaborating with an LLM and inspecting the compiler’s MIR output, I was able to fill in the gaps. I was happy with what I learned, so I decided to create a visual presentation and share it.

The code is simplified for demonstration purposes. Some snippets are pseudocode and won’t compile as shown.

Use the left and right arrow keys, click the arrow buttons, or swipe left and right to move through the guide.

2/16 · the program

One tiny program: read a name from the keyboard, print ready…, sleep one second, then greet — type henrik⏎ and it answers hi henrik!.

The rest of this page traces how those few lines actually run.

3/16 · the sugar

Three pieces of sugar hide everything that follows:

  • #[tokio::main]
  • the two async fns
  • the .awaits

None is runtime magic — each is a mechanical rewrite the compiler performs. The next four steps show what each one becomes.

4/16 · the macro

#[tokio::main] is an ordinary macro. It wraps your async main in a plain fn main that builds a Runtime and hands block_on one value: your whole async main body, compiled into a machine (next step).

The runtime enters as a library call — the language ships no executor.

5/16 · async fn — a value

Every async fn becomes a plain fn that just builds an enum — one variant per pause point, holding whatever must survive the pause.

Arguments are fields from birth: hi(me) runs no body, it constructs Hi::Start { who: me }. And async main is no exception — its machine owns buf and stores the nested hi machine inline.

6/16 · .await — poll + early return

Each .await compiles to:

  • poll the child
  • on Pending, return Pending yourself, handing the thread back

The next poll lands in the same match arm. A return statement with a bookmark — not a queue operation.

7/16 · why Pin<&mut Self>

Hi<'a> alone is fine — who points at data outside it. The trouble is the composition: main's machine owns buf and stores hi, whose who points into buf.

The composed machine points into itself — move it in memory and who dangles. That is what Pin<&mut Self> guarantees: once polling begins, the machine never moves again.

8/16 · block_on

The runtime takes ownership of the main machine and will poll it. Everything so far was construction — zero lines of your code have executed.

9/16 · poll #1

poll() enters main's machine at Start:

  • the plain blocking read waits for your name — poll #1 simply lasts that long
  • it builds Hi::Start { who } and stores it beside buf
  • it polls it: ready… prints and the Sleep is created

All ordinary synchronous code.

10/16 · nested polls

The hi machine polls Sleep, forwarding cx.

Awaiting nests: main's machine polled hi, hi polls Sleep — no new task, no queue entry, just function calls going down.

11/16 · the waker contract

Sleep isn't ready. Before saying so, it clones the waker out of cx and registers it with the timer.

The contract: you may only return Pending after arranging your own wake-up call.

12/16 · pending bubbles

Every .await up the chain sees Pending and early-returns it, machine by machine, until the executor holds the thread again. The stack unwinds completely.

13/16 · parked

Nothing is left to poll, so the thread parks in the OS.

The future isn't running slowly — it's an enum variant sitting in memory. Nobody polls; nobody checks the clock.

14/16 · wake()

The timer driver hits the deadline and fires the stored waker — which, for a block_on future, simply unparks the blocked thread.

This wake is the only scheduling event in the entire program — the .awaits never touched a queue.

15/16 · poll #2

The match reads the stored state and resumes where it left off — AwaitingHi, then AwaitingSleep inside it. ready… doesn't print twice.

Sleep answers Ready, and the last println! greets henrik through who — a pointer into the machine itself, still valid because the pinned machine never moved.

16/16 · done

poll returns Ready(()); block_on returns; main exits.

The whole async story of this program: two polls, one wake.