The Rust Async Model
Async/await in Rust looks similar to async/await in JavaScript or C#, but the mechanism underneath is fundamentally different, and that difference explains most of the surprising behavior newcomers hit: futures that "do nothing" until awaited, a compile error mentioning Pin, or confusion about why async fn main needs an attribute macro at all. Rust's async model is built from one trait, Future, and a deliberate choice to ship no runtime in the standard library.
This page is the conceptual anchor for the rest of the async-futures section. The other pages here cover specific mechanics: the Future trait's poll method, executors, pinning, streams, and cancellation. This one ties them together into a single mental model: futures as lazy state machines, executors as the thing that drives them, and pinning as a structural consequence of how the compiler builds those state machines.
Summary
async fnandasyncblocks compile to anonymous types implementing theFuturetrait, a state machine that makes progress only when something callspollon it.- Insight: This design lets Rust run huge numbers of concurrent operations on a small thread pool with zero per-task heap allocation by default, at the cost of needing an external runtime to actually execute anything.
- Key Concepts:
Future,poll, executor, waker, state machine,Pin. - When to Use: Understanding this model matters whenever you debug a stuck task, choose a runtime, write a custom
Future, or hit aSend/Pincompiler error you don't immediately recognize. - Limitations/Trade-offs: The lazy, no-runtime design pushes real complexity, scheduling, I/O readiness, timers, out to third-party crates, so "async Rust" always really means "async Rust plus a runtime."
- Related Topics: the
Futuretrait'spollcontract, executors and runtimes, pinning andUnpin, cooperative task scheduling.
Foundations
The single most important fact about Rust futures is that they are lazy. Creating a value with an async block or calling an async fn does no work at all; it just builds a value. Work only happens when that value is polled, and polling is not something application code typically does directly, an executor does it.
This is a deliberate departure from JavaScript's promises, which begin executing the moment they are created. A Rust future is closer to an unstarted iterator: let fut = fetch_data(); allocates a state machine and does nothing else, exactly as let iter = vec.iter(); produces an iterator that has not yet touched an element.
Every async fn and async block is syntax sugar. The compiler transforms the function body into an anonymous struct that implements the Future trait, with the body's local variables becoming the struct's fields and each .await point becoming a possible pause state. Conceptually:
// async fn fetch() -> u32 { let x = step_one().await; step_two(x).await }
// compiles to roughly an enum like:
enum FetchState {
Start,
WaitingOnStepOne { /* captured locals */ },
WaitingOnStepTwo { x: u32 },
Done,
}This state machine has one method that matters: poll, which either returns Poll::Ready(value) when the work is complete, or Poll::Pending when it needs to wait on something, an incoming network byte, a timer, another future. async/await is just a convenient way to write that state machine by hand without actually writing an enum and a match statement yourself.
Mechanics & Interactions
A Future cannot make itself run faster by wishing; it needs something outside itself calling poll repeatedly until it returns Poll::Ready. That something is an executor, and the Rust standard library deliberately does not provide one.
This split is intentional and mirrors a broader pattern in the language: the standard library defines the trait (the contract), and the ecosystem provides implementations tuned for different environments. Tokio, async-std, smol, and embedded runtimes like Embassy all implement the same Future contract differently, one optimized for servers with thread pools, another for microcontrollers with no heap at all. A library that only uses async fn and never spawns a runtime itself can run under any of them.
Polling on its own would be wasteful if the executor just called poll in a tight loop, so a second mechanism completes the picture: the waker. When a future returns Poll::Pending, it is handed a Waker as part of the polling context, and it is responsible for calling that waker later, typically from an I/O callback or a timer, to tell the executor "poll me again, I might be ready now." This waker handoff is what lets an executor park a task efficiently instead of spinning, and it is the same mechanism whether the future is waiting on a TCP socket or a five-second timer.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct Ready(bool);
impl Future for Ready {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
if self.0 { Poll::Ready(()) } else {
cx.waker().wake_by_ref(); // ask to be polled again
Poll::Pending
}
}
}The poll signature above hides a detail that trips up nearly everyone the first time they see it: self: Pin<&mut Self> instead of the ordinary &mut self. This is where pinning enters the picture, and it is a direct structural consequence of the state machine transformation described in Foundations.
Consider an async block that borrows one of its own local variables across an .await, something like let buf = [0u8; 64]; let n = reader.read(&mut buf).await; followed by later code that reads buf again. The compiler's generated state machine has to store both buf and a reference to buf as fields of the same struct, which makes the struct self-referential: one field points into another field of the same value. Ordinary Rust values can be moved freely (a memcpy under the hood), but moving a self-referential struct invalidates the internal pointer, since it would now point at the old memory location instead of the new one. Pin<P> is a pointer wrapper that promises the compiler "the value behind this pointer will never move again," which is exactly the guarantee a self-referential state machine needs to be sound.
Advanced Considerations & Applications
Most application code never writes Pin explicitly, because .await pins futures on the stack automatically, and this is by design: pinning is a mechanism the language needs, not an API most users are meant to interact with directly. It surfaces in three recurring situations: implementing a Future by hand, storing a future inside a struct field (which requires Pin<Box<dyn Future<...>>> since a struct field cannot be pinned to the stack the way a local variable can), and reading a !Unpin compiler error, which usually means a future got moved after something already started polling it.
The Unpin auto trait is the escape hatch that keeps pinning from being painful for the common case. Almost all ordinary Rust types are Unpin, meaning moving them after pinning is fine, since they hold no self-references. Only generated async state machines, and hand-written self-referential types, are typically !Unpin, and only those actually need the discipline Pin enforces.
Runtimes build meaningfully different scheduling and I/O models on top of the same Future contract, and the choice has real operational consequences, not just API differences.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Multi-threaded work-stealing executor (Tokio default) | Scales across cores; good default for servers | Requires spawned futures to be Send; more moving parts to reason about | Production network services |
Single-threaded executor (current_thread, LocalSet) | Simpler model; supports !Send futures | No parallelism; one slow task blocks everything else on that thread | Tests, embedded, !Send-heavy code |
Box::pin heap-allocated futures | Enables storing/returning futures uniformly, including trait objects | One allocation per boxed future; slight indirection cost | Dynamic dispatch, futures stored in collections |
Stack pinning (pin! macro) | Zero extra allocation | Only works for locals that don't need to be returned or stored | Local .await-only usage inside one function |
The lazy, poll-based design has direct consequences for cancellation, which is worth naming explicitly because it differs from thread-based cancellation. Dropping a future at any await point simply stops polling it, there is no signal delivered to "interrupt" work in progress the way a thread might receive a signal. This makes cancellation cheap and structural (drop the future, its Drop impl runs, resources are freed), but it also means cancellation safety, whether a partially completed operation leaves data in a consistent state, is something the future's author has to design for explicitly rather than something the runtime guarantees automatically.
Common Misconceptions
- "Calling an async function starts the work immediately." It builds a lazy
Futurevalue and does nothing else; work begins only once something polls it, typically by awaiting it or spawning it on an executor. - "Rust's standard library includes an async runtime." It defines the
Futuretrait and the polling contract, but ships no executor at all; every real async program depends on a runtime crate like Tokio. - "
Pinmeans the same thing as immutable."Pinonly prevents a value's memory address from changing; the value can still be mutated in place throughPin::as_mutor safe methods that don't move it. - "You always need to think about Pin when writing async code."
.awaitpins values on the stack automatically for you;Pinbecomes visible mainly when implementingFuturemanually or storing a future inside another struct. - "async/await is a special runtime feature, not 'real' code." It is ordinary syntax sugar around a state machine implementing an ordinary trait; there is no hidden runtime magic beyond the
poll/Wakerprotocol itself. - "Dropping a future cancels it the same way killing a thread does." Dropping simply stops polling and runs
Drop; there is no interrupt signal, so any partial side effects already performed before the drop point are not automatically undone.
FAQs
What does it mean that Rust futures are "lazy"?
Creating a future, whether by calling an async fn or writing an async block, does no work by itself. It only produces a value implementing Future; execution happens later, when an executor calls poll on that value.
Why does an async program need an external runtime like Tokio?
The standard library defines the Future trait, the contract for "a unit of work that can be polled," but deliberately does not include a scheduler, an I/O reactor, or timers. Those are provided by runtime crates so different environments (servers, embedded devices, tests) can choose an implementation that fits.
What actually happens when I write `.await`?
It desugars to a loop that polls the future, and if it returns Poll::Pending, suspends the enclosing state machine at that point and yields control back to whatever is driving the outer future, usually the executor.
What is a waker and why does polling need one?
A Waker is a handle a pending future stores and later invokes (often from an I/O completion callback) to tell the executor "poll me again." Without it, an executor would have to poll every pending future repeatedly in a busy loop instead of sleeping until there is real progress to make.
Why does poll take `Pin<&mut Self>` instead of `&mut self`?
Because the compiler-generated state machine behind an async block can be self-referential, holding a reference into one of its own fields across an await point. Pin guarantees the value will not move in memory after being pinned, which is what keeps that internal reference valid.
Do I need to use Pin in everyday async application code?
Rarely. .await pins the future on the stack for you automatically. Pin typically only appears when implementing Future by hand or storing a future as a struct field.
What is the difference between Unpin and !Unpin?
Unpin types can be safely moved even after being pinned, because they hold no self-references; nearly all ordinary Rust types are Unpin. !Unpin types, mostly compiler-generated async state machines, must stay at a fixed memory address once pinned.
Why would I choose a single-threaded runtime over a multi-threaded one?
A single-threaded executor supports futures that are !Send, and it removes the overhead and complexity of cross-thread scheduling. It has no parallelism, so it fits tests, embedded targets, and code that specifically depends on thread-local or non-thread-safe state.
What happens if I drop a future in the middle of an await?
Polling simply stops, and the future's Drop implementation runs like any other value going out of scope. There is no interrupt delivered mid-operation; any side effects already committed before that point are not automatically rolled back.
Is async/await just sugar, or does it change how the code runs?
It is sugar over the same Future/poll/Waker model you would use writing a manual implementation. The generated state machine behaves identically to a hand-written one, just without the boilerplate.
Why do some crates use `Box::pin` instead of the `pin!` macro?
Box::pin heap-allocates the future, which lets it be returned from a function, stored in a struct field, or used as a dyn Future trait object. The pin! macro pins on the stack with no allocation, but the result cannot outlive the current function scope in the same way.
How is Rust's async model different from JavaScript promises?
A JavaScript promise begins executing as soon as it is created; a Rust future does nothing until polled. This makes Rust futures closer to unstarted iterators than to promises, and it is why an unawaited, unspawned Rust future silently does nothing at all.
Related
- The Future Trait - the
pollcontract in full detail, with a hand-writtenFutureexample. - Executors & Runtimes - what drives polling, and how Tokio's flavors differ.
- Pinning & Unpin - the
PinAPI and self-referential futures in depth. - Async Basics - hands-on
async/awaitexamples building on this model. - Streams - the async equivalent of an iterator, built on the same polling model.
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.