The Async Runtime Model
The Concurrency module’s intro to async/.await covered how to write async code. This lesson covers what actually exists once you compile it: a state machine, a trait with one method, and a runtime you have to supply yourself — Rust ships the language feature, not the executor.
async fn Compiles to a State Machine
Section titled “async fn Compiles to a State Machine”Calling an async fn does not run its body. It immediately returns a value — a state machine, generated by the compiler, that implements the Future trait — and none of the function’s code runs until something actually drives that state machine forward.
stateDiagram-v2 [*] --> Start Start --> AfterFirstAwait: poll -- runs body up to first .await AfterFirstAwait --> AfterSecondAwait: poll again -- inner future ready, runs to next .await AfterSecondAwait --> Done: poll again -- inner future ready, runs to the end Done --> [*]
Each .await in the function body becomes a boundary between states: the compiler generates one variant of an anonymous enum per suspend point, holding exactly whatever local variables are still needed after that point. This is why an async fn with three .awaits compiles to something conceptually similar to a hand-written state machine with three-plus states, and why the size of that generated future can grow with how much has to be kept alive across each suspension.
The Future Trait and poll
Section titled “The Future Trait and poll”Everything above is driven by one trait with one method:
trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;}poll does exactly one of two things: return Poll::Ready(value) if the future has finished, or return Poll::Pending if it hasn’t — in which case it’s also responsible for arranging to be polled again later (via the Waker inside Context, covered below). Nothing runs on its own; a future that is never polled never makes progress, no matter how much time passes.
Pin: Why It Exists
Section titled “Pin: Why It Exists”The state machine async fn generates can be self-referential: a local variable created before an .await and a reference to that same local variable, both need to live inside the same generated struct, across the suspension. An ordinary Rust value can be freely moved (its bytes copied elsewhere) — but moving a self-referential struct would leave its internal reference pointing at the old memory location, now potentially reused for something else. Pin<P> is a wrapper around a pointer that adds exactly one guarantee: the pointee will not be moved for as long as it’s pinned (for a type that doesn’t implement Unpin, which most self-referential generated futures don’t). This is precisely why poll takes self: Pin<&mut Self> instead of a plain &mut self — it’s the type system’s way of saying “you may only call this once you’ve promised not to move the value out from under it.”
Waker: How a Future Gets Polled Again
Section titled “Waker: How a Future Gets Polled Again”When poll returns Poll::Pending, it has to somehow cause itself to be polled again once the thing it’s waiting on is actually ready — otherwise the executor would have to guess when to try again. Context (the second argument to poll) carries a Waker: a handle the future can clone, stash somewhere (a channel, an I/O reactor’s registration table), and call .wake() on once the underlying event happens. Calling .wake() doesn’t run any of the future’s code directly — it tells the executor “this task is ready to be polled again,” and the executor is the one that actually calls poll next.
A Runnable Executor: block_on in ~40 Lines of std
Section titled “A Runnable Executor: block_on in ~40 Lines of std”Rust’s standard library defines Future, Poll, Context, and Waker — but deliberately ships no executor at all. Here is a minimal one, entirely std, that can drive exactly one future to completion:
use std::future::Future;use std::pin::Pin;use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
// A minimal, std-only executor: no crates, just enough to drive one Future to completion.
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone_raw, no_op, no_op, no_op);
fn clone_raw(_: *const ()) -> RawWaker { RawWaker::new(std::ptr::null(), &VTABLE)}fn no_op(_: *const ()) {}
fn noop_waker() -> Waker { let raw = RawWaker::new(std::ptr::null(), &VTABLE); unsafe { Waker::from_raw(raw) }}
fn block_on<F: Future>(fut: F) -> F::Output { let waker = noop_waker(); let mut cx = Context::from_waker(&waker); let mut fut = Box::pin(fut); loop { match fut.as_mut().poll(&mut cx) { Poll::Ready(value) => return value, Poll::Pending => continue, // busy-poll: fine for a teaching example, never for real code } }}
struct YieldOnce { yielded: bool,}
impl Future for YieldOnce { type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { if self.yielded { Poll::Ready("done") } else { println!("poll #1: not ready yet, returning Pending"); self.yielded = true; cx.waker().wake_by_ref(); // tell the executor to poll again Poll::Pending } }}
async fn demo() -> &'static str { println!("demo started"); let value = YieldOnce { yielded: false }.await; println!("demo resumed after .await"); value}
fn main() { let result = block_on(demo()); println!("result = {}", result);}demo startedpoll #1: not ready yet, returning Pendingdemo resumed after .awaitresult = doneRawWaker/RawWakerVTable are the unsafe, manual building blocks Waker is built from — a real executor (Tokio, async-std) uses them to build a Waker that actually re-schedules the task on a work queue; this one’s waker does nothing at all, which is why block_on has to busy-loop, calling poll over and over until the future finally returns Ready. YieldOnce is a hand-written future that returns Pending exactly once (printing to prove it), then Ready on the next poll — everything an async fn’s generated state machine does automatically, done by hand here to make the mechanism visible. This is a teaching executor, not a real one: a real executor sleeps the thread until a Waker actually fires, rather than spinning.
What the Compiler Says: A Future That Cannot Be Sent
Section titled “What the Compiler Says: A Future That Cannot Be Sent”Multi-threaded executors (Tokio’s default runtime included) need to move a task’s future between worker threads freely, which means the future itself has to implement Send. A future that captures a non-Send type across an .await point fails that requirement — and this is a real, verified diagnostic with no error code at all, produced by plain rustc with no async runtime crate involved:
// @expect-error nocodeuse std::rc::Rc;
async fn uses_rc() { let val = Rc::new(5); some_async_fn().await; println!("{}", val);}
async fn some_async_fn() {}
fn assert_send<T: Send>(_t: T) {}
fn main() { assert_send(uses_rc());}error: future cannot be sent between threads safely --> src/main.rs:14:17 |14 | assert_send(uses_rc()); | ^^^^^^^^^ future returned by `uses_rc` is not `Send` | = help: within `impl Future<Output = ()>`, the trait `Send` is not implemented for `Rc<i32>`note: future is not `Send` as this value is used across an await --> src/main.rs:5:21 | 4 | let val = Rc::new(5); | --- has type `Rc<i32>` which is not `Send` 5 | some_async_fn().await; | ^^^^^ await occurs here, with `val` maybe used laternote: required by a bound in `assert_send` --> src/main.rs:11:19 |11 | fn assert_send<T: Send>(_t: T) {} | ^^^^ required by this bound in `assert_send`
error: aborting due to 1 previous errorThe compiler’s own explanation names the exact mechanism from earlier in this lesson: val is a local kept alive across an .await, which means it’s part of the generated state machine’s stored fields, which means the whole generated future’s Send-ness depends on every such stored field being Send — one non-Send local held across a suspension point is enough to make the entire future non-Send, even though the rest of the function never touches another thread directly. This is the async-specific twin of the Concurrency module’s Rc E0277 example — same underlying rule (Rc<T> is not Send), surfacing through the generated state machine instead of through an explicit thread::spawn.
Holding a std::sync::MutexGuard Across .await Is Wrong at the Model Level
Section titled “Holding a std::sync::MutexGuard Across .await Is Wrong at the Model Level”The same mechanism produces a second, extremely common real-world diagnostic, and it’s worth seeing as more than “another Send error” — it’s the async model directly explaining why this specific pattern is a mistake, not just a type-checker technicality:
// @expect-error nocodeuse std::sync::Mutex;
async fn holds_lock_across_await(m: &Mutex<i32>) { let mut guard = m.lock().unwrap(); *guard += 1; some_async_fn().await; // the lock is still held here, across a suspension point}
async fn some_async_fn() {}
fn assert_send<T: Send>(_t: T) {}
fn main() { let m = Mutex::new(0); assert_send(holds_lock_across_await(&m));}error: future cannot be sent between threads safely --> src/main.rs:15:17 |15 | assert_send(holds_lock_across_await(&m)); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ future returned by `holds_lock_across_await` is not `Send` | = help: within `impl Future<Output = ()>`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, i32>`note: future is not `Send` as this value is used across an await --> src/main.rs:6:21 | 4 | let mut guard = m.lock().unwrap(); | --------- has type `std::sync::MutexGuard<'_, i32>` which is not `Send` 5 | *guard += 1; 6 | some_async_fn().await; // the lock is still held here, across a suspension point | ^^^^^ await occurs here, with `mut guard` maybe used laternote: required by a bound in `assert_send` --> src/main.rs:11:19 |11 | fn assert_send<T: Send>(_t: T) {} | ^^^^ required by this bound in `assert_send`
error: aborting due to 1 previous errorstd::sync::MutexGuard is deliberately not Send (unlocking must happen on the thread that locked it on some platforms), so this is caught here as a compile error — but the deeper problem exists even in code that somehow avoids the Send bound entirely: .awaiting while holding a lock means the lock stays held for however long the awaited future takes to become ready, on an executor that is almost always sharing that thread with other unrelated tasks. If the pending work is slow, every other task waiting on that same Mutex — and on a single-threaded executor, potentially every other task scheduled on that thread at all — sits blocked for the duration. The fix is always the same shape: do the locked, synchronous work, then explicitly drop the guard, then .await; if the async operation itself needs the protected data, restructure so the lock is only held for the brief synchronous read/write, never across the suspension.
Cancellation Is Just Drop
Section titled “Cancellation Is Just Drop”An async runtime has no special “cancel this task” operation distinct from ordinary Rust semantics — cancelling a future is exactly dropping it, and everything the Drop & RAII lesson covered applies unchanged: a dropped future’s fields are dropped in order, right where the drop happens, and any code after the point it was suspended at simply never runs.
use std::future::Future;use std::pin::Pin;use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone_raw, no_op, no_op, no_op);fn clone_raw(_: *const ()) -> RawWaker { RawWaker::new(std::ptr::null(), &VTABLE)}fn no_op(_: *const ()) {}fn noop_waker() -> Waker { unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) }}
struct NoisyGuard(&'static str);impl Drop for NoisyGuard { fn drop(&mut self) { println!("dropping {}", self.0); }}
struct PendingForever;impl Future for PendingForever { type Output = (); fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<()> { Poll::Pending // never completes }}
async fn task() { let _guard = NoisyGuard("task"); println!("task started, about to await forever"); PendingForever.await; println!("this line never runs");}
fn main() { let waker = noop_waker(); let mut cx = Context::from_waker(&waker); let mut fut = Box::pin(task());
match fut.as_mut().poll(&mut cx) { Poll::Pending => println!("polled once: still pending (as expected)"), Poll::Ready(()) => println!("unexpectedly ready"), }
println!("dropping the future now -- this is what cancellation looks like"); drop(fut); // cancellation: drop the future without ever polling it to completion println!("end of main");}task started, about to await foreverpolled once: still pending (as expected)dropping the future now -- this is what cancellation looks likedropping taskend of main"this line never runs" never prints — once task()’s future is dropped while suspended inside PendingForever.await, execution of that function body simply stops at exactly that point, permanently. _guard’s Drop still runs (RAII cleanup is unaffected by how a scope ends, exactly as the Drop & RAII lesson covered), but nothing past the suspension point ever executes. This is why cancellation-safety is a real design concern for async code: a future dropped midway through, say, a multi-step database transaction leaves whatever partial state existed at the last .await — there is no automatic rollback beyond whatever each individual Drop impl happens to do.
thread::sleep in Async Code, and spawn_blocking
Section titled “thread::sleep in Async Code, and spawn_blocking”std::thread::sleep blocks the current OS thread completely — calling it inside an async fn blocks whichever executor thread happens to be running that task, freezing every other task scheduled on that same thread for the sleep’s full duration, exactly like the MutexGuard problem above but for CPU/blocking work instead of a lock. tokio::time::sleep(...).await, by contrast, is a real future: it returns Pending and lets the executor run other work while waiting, resuming only once the timer actually fires. The general version of this problem — any long-running, synchronous, non-yielding work inside an async task — is what spawn_blocking exists for: it hands the blocking work to a separate thread pool dedicated to exactly that, so the async executor’s own threads stay free to keep servicing other tasks. The Concurrency module’s coverage of thread::scope and thread-based concurrency is the right place to revisit the difference between “blocking the current thread” and “yielding control back to a scheduler” in full.
use std::future::Future;use std::pin::Pin;use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone_raw, no_op, no_op, no_op);
fn clone_raw(_: *const ()) -> RawWaker { RawWaker::new(std::ptr::null(), &VTABLE)}fn no_op(_: *const ()) {}
fn noop_waker() -> Waker { let raw = RawWaker::new(std::ptr::null(), &VTABLE); unsafe { Waker::from_raw(raw) }}
fn block_on<F: Future>(fut: F) -> F::Output { let waker = noop_waker(); let mut cx = Context::from_waker(&waker); let mut fut = Box::pin(fut); loop { match fut.as_mut().poll(&mut cx) { Poll::Ready(value) => return value, Poll::Pending => continue, // busy-poll: fine for a teaching example, never for real code } }}
struct YieldOnce { yielded: bool,}
impl Future for YieldOnce { type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { if self.yielded { Poll::Ready("done") } else { println!("poll #1: not ready yet, returning Pending"); self.yielded = true; cx.waker().wake_by_ref(); // tell the executor to poll again Poll::Pending } }}
async fn demo() -> &'static str { println!("demo started"); let value = YieldOnce { yielded: false }.await; println!("demo resumed after .await"); value}
fn main() { let result = block_on(demo()); println!("result = {}", result);}Compiling…
Mental Model
Section titled “Mental Model”Picture an async fn as a recipe card cut into numbered steps at every .await, with a sticky note listing exactly which ingredients need to still be on the counter to resume at each step — that card-plus-notes is the compiler-generated Future. The kitchen only ever does one thing at a time: pick up a card, read from the current step’s sticky note, cook until the next .await or the end, then either stop (Ready) or put the card down and stick a reminder (Waker) on the timer that’s blocking it (Pending). Nothing on the counter cooks itself while the card is down — an executor is simply whatever process decides which card to pick up next, and Rust’s standard library deliberately doesn’t supply one.
Trade-offs
Section titled “Trade-offs”Compiling async down to a plain trait plus a state machine (rather than baking a specific runtime into the language) is what lets Tokio, async-std, and a from-scratch 40-line executor like this lesson’s all exist as ordinary libraries with no special compiler support — real flexibility, genuinely used across very different platforms (embedded, WASM, servers). The cost is that async code is never portable across executors for free the way synchronous code is portable across operating systems: spawn_blocking, timers, and I/O reactors are runtime-specific, and mixing runtimes in one binary is a real, common source of “this future panics/hangs for no visible reason” bugs.
Common Misconceptions
Section titled “Common Misconceptions”- “Calling an async function starts running it immediately, like a regular function call.” It returns a
Futurevalue — inert until something callspollon it. Code inside anasync fngenuinely does not run until an executor drives it, which is why a baresome_async_fn();with no.awaitand no.spawn(a classic mistake) silently does nothing at all. - “
.awaitblocks the thread until the value is ready, like a synchronous call.” It suspends this future, returning control to the executor so it can run other work — the thread itself keeps making progress on other tasks. This is precisely the opposite of whatstd::thread::sleepdoes inside async code, which is why mixing the two is a bug. - “Async tasks run in parallel with each other automatically.” A single-threaded executor interleaves tasks on one thread (concurrency, not parallelism); only a multi-threaded executor with
Sendfutures can run tasks on genuinely different threads at the same time, and that’s exactly the requirement behind theSend-future diagnostics earlier in this lesson. - “Dropping a future part-way through is unsafe or leaves it in some special broken state.” It’s ordinary
Drop, running exactly the same way it would for any other value going out of scope — the only surprising part is that code textually after the current suspension point in theasync fn’s body never executes, which matters for cancellation-safety, not for memory safety.
Reading AI-generated Code
Section titled “Reading AI-generated Code”Generated async code sometimes holds a std::sync::MutexGuard (or any other synchronous lock guard) across an .await, especially inside a helper function where the lock and the await are several lines apart and the connection isn’t visually obvious — exactly the shape this lesson’s MutexGuard example demonstrated directly.
🐛 Spot the bug
use std::sync::Mutex;use std::time::Duration;
struct Cache { data: Mutex<Vec<i32>>,}
impl Cache { async fn refresh(&self, fetch: impl std::future::Future<Output = i32>) { let mut data = self.data.lock().unwrap(); // "hold the lock while we fetch so no one reads stale data" -- AI-added comment let value = fetch.await; data.push(value); }}Show the answer
The comment’s intent (don’t let a reader see a half-updated cache) is reasonable, but holding the actual lock guard across fetch.await is the wrong way to get it: whatever fetch is awaiting — a network call, a database query, anything that takes real time — now runs with the lock held the entire time. On a multi-threaded runtime this future would also fail to compile with the same “future cannot be sent between threads safely” diagnostic shown earlier in this lesson, since std::sync::MutexGuard is not Send. Even setting the compile error aside, every other task that needs self.data — reads included — blocks for the full duration of fetch, on the executor thread that’s supposed to be free to run other work. The fix separates the synchronous critical section from the await entirely: run fetch.await first, with no lock held at all, and only take the lock afterward, briefly, just to push the already-fetched value in (let value = fetch.await; self.data.lock().unwrap().push(value);) — readers can still see a “stale” value while the fetch is in flight, which was true either way, but now nothing else is blocked while it happens.