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;}
// your async main, as a value ↓fn main() { Runtime::new().unwrap() // async main, as a value ↓ .block_on(MainMachine::Start) }
// 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,}
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
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.
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.
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.
#[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.
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.
Each .await compiles to:
- poll the child
- on
Pending, returnPendingyourself, handing the thread back
The next poll lands in the same match arm. A return statement with a bookmark — not a queue operation.
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.
The runtime takes ownership of the main machine and will poll it. Everything so far was construction — zero lines of your code have executed.
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 besidebuf - it polls it:
ready…prints and theSleepis created
All ordinary synchronous code.
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.
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.
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.
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.
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.
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.
poll returns Ready(()); block_on returns;
main exits.
The whole async story of this program: two polls, one wake.