Rust async/await and Futures Explained
How Rust's async/await desugars into a state machine, what a Future actually is, and the runtime model that makes it efficient on real workloads.
What you'll learn
- ✓What a Future is at the type level
- ✓How async/await compiles to a state machine
- ✓The poll model and why a runtime is required
- ✓How wakers and reactors interact
- ✓Common errors around lifetimes in async code
Prerequisites
- •Comfort with Rust ownership and traits
What and Why
Rust’s async system gives you cooperative concurrency without garbage collection or runtime-managed green threads. You write code that looks sequential; the compiler turns it into a state machine that suspends on I/O and resumes when work is ready. A runtime (commonly Tokio) drives those state machines on a small pool of OS threads.
The result is high throughput for I/O-heavy workloads with predictable memory usage and zero hidden allocations from the language itself.
Mental Model
A Future is a value that, when polled, either returns a result (Poll::Ready) or signals that it needs to wait (Poll::Pending). The runtime polls futures; futures register a Waker so the runtime knows when to poll them again.
runtime --poll--> Future
|
Ready(T) <---+--- Pending (stores Waker)
^
|
I/O event arrives
wake() called
|
runtime --poll-- Future (resumes state machine) async fn foo() -> T is sugar for fn foo() -> impl Future<Output = T>. Inside, every .await becomes a suspension point in the generated state machine.
Hands-on Example
A small async function fetching two URLs concurrently with Tokio.
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await;
format!("{name} done after {ms}ms")
}
#[tokio::main]
async fn main() {
// Sequential: ~150ms total
let a = fetch("a", 100).await;
let b = fetch("b", 50).await;
println!("{a}\n{b}");
// Concurrent: ~100ms total
let (a, b) = tokio::join!(fetch("a", 100), fetch("b", 50));
println!("{a}\n{b}");
// Spawning lets futures run on background tasks
let handle = tokio::spawn(fetch("c", 75));
println!("{}", handle.await.unwrap());
}
tokio::join! polls multiple futures on the current task in parallel. tokio::spawn hands a future to the runtime as an independent task that can run on any worker thread.
Common Pitfalls
- Blocking the runtime: calling
std::thread::sleepor doing CPU-heavy work inside an async function blocks the worker thread and starves other tasks. Usetokio::time::sleepfor waiting, andspawn_blockingfor CPU-bound work. - Holding non-
Sendstate across.await: if any value held across an await point is notSend, the future itself is notSendand cannot be spawned. The compiler error points to the offending value. - Borrowing across await: borrows must be valid across all suspension points. Refactor to clone, restructure scopes, or use owned types.
- Forgetting to
.await: a future on its own does nothing. Callingfoo()without.awaitproduces a warning and the work never starts. - Mixing runtimes: do not call Tokio APIs from inside an
async-stdruntime, or vice versa. Stick to one.
Practical Tips
For concurrency, choose between join! (run all to completion), select! (return when the first finishes), and FuturesUnordered (a stream of futures with dynamic membership). Each has a clear use case.
Cancellation in Rust is just dropping the future. Anything held by a future runs its destructor when the future is dropped, so RAII-style cleanup still works. Plan for this: do not hold a mutex guard across an await if cancellation could leave the lock in a surprising state.
Use #[tracing::instrument] from the tracing crate to follow tasks. Stack traces are less helpful in async code; structured logs and spans are the right tool.
When debugging “future is not Send” errors, check for Rc, RefCell, or raw pointers across an await. Replace with Arc, Mutex, or restructure the borrow.
For libraries, return impl Future<Output = T> rather than wrapping in Box<dyn Future> unless you really need dynamic dispatch. Static dispatch keeps zero-cost guarantees.
Wrap-up
Async Rust is a small language feature plus a big ecosystem. The feature is async/await, which compiles your code into a state machine of Future values. The ecosystem is the runtime that polls those futures and the I/O drivers that wake them when bytes arrive. Once you internalize the polling model, error messages stop being mysterious and patterns like join, select, and spawn_blocking fall into clear roles. The payoff is concurrency that scales to thousands of connections with predictable, allocation-free code.
Related articles
- Rust Rust Tokio Runtime Explained
How Tokio schedules tasks, manages workers, and integrates with the OS to power asynchronous Rust programs efficiently across cores.
- Rust Rust Axum Web Framework Tutorial
A practical introduction to building HTTP services in Rust using the Axum web framework, with routing, extractors, state, and JSON handling.
- Rust Rust Actix vs Axum Comparison
A side-by-side comparison of Actix Web and Axum, covering architecture, ergonomics, performance, ecosystem, and how to pick the right one for your project.
- Rust Rust Builder Pattern Explained
Learn the builder pattern in Rust, why it fits the language so well, and how to use it for ergonomic, type-safe configuration of complex structs.