Async/Await & Futures
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Rust 1.99.0, the 2024 edition, tokio 1.53 and futures 0.3, checked on 2026-10-06. Async
Rust lets one thread make progress on many tasks that spend most of their time waiting — for a network reply, a
database row, a timer. Instead of blocking a thread per task, an async function returns a future that an executor
polls when there is something to do. The language provides async/.await and the Future trait; the executor comes
from a library, usually tokio (see Async Runtimes &
Tokio). The axum section builds entirely on this model.
Why async
Threads are a fine way to do several things at once, but each OS thread costs memory (a stack of typically 2-8 MiB reserved) and a context switch to schedule. A server holding ten thousand idle connections cannot afford ten thousand threads. Async tasks are small state machines — often a few hundred bytes — and switching between them is a function call. Use async for I/O-bound concurrency; for CPU-bound parallelism, threads or Rayon remain the right tool (see Data Parallelism with Rayon).
Source: Async Book: Async and await
The Future trait
A future is a value that will eventually produce a result. Its trait is small:
use std::pin::Pin;
use std::task::{Context, Poll};
pub trait FutureLike {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
// enum Poll<T> { Ready(T), Pending }
Source: std::future::Future
-
pollasks the future to make progress. It returnsPoll::Ready(value)when done, orPoll::Pendingif it must wait. -
Before returning
Pending, the future arranges for theWakerincxto be called when it can make progress (a socket became readable, a timer fired). The executor then polls it again. -
selfisPin<&mut Self>because futures created byasyncmay hold references into themselves; see Pin & Unpin.
Source: std::future::Future
A hand-written future makes the protocol concrete. This one completes after being polled three times, waking itself each time (real futures wake when an external event happens):
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct CountDown(u8);
impl Future for CountDown {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.0 == 0 {
Poll::Ready("lift-off")
} else {
println!("polled, {} to go", self.0);
self.0 -= 1;
cx.waker().wake_by_ref(); // ask to be polled again
Poll::Pending
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
println!("{}", CountDown(3).await);
}
Source: std::future::Future
Futures are lazy
Calling an async fn does not run its body. It returns a future that does nothing until it is .awaited (or spawned
on an executor). Forgetting the .await is the classic beginner bug, and the compiler warns: "unused implementer of
Future that must be used — futures do nothing unless you .await or poll them".
async fn notify(member: &str) -> bool {
println!("notifying {member}");
true
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let pending = notify("alice"); // nothing printed yet: just a future
println!("before await");
let sent = pending.await; // now the body runs
println!("sent = {sent}");
}
async fn, async blocks and .await
-
async fn f(..) -> Tis sugar for a function returningimpl Future<Output = T>; the future captures the arguments (including references, so it borrows them). -
An
async { ... }block is an expression whose value is a future;async move { ... }takes ownership of the variables it uses, which is needed when the future must be'static(fortokio::spawn). -
.awaitcan only be used insideasynccode. It polls the future and, if it is pending, yields control to the executor; the enclosing future resumes at that point later.
The compiler lowers each async body into an enum-like state machine with one state per .await point, holding the
local variables that live across that point. Its size is the size of the largest state, which is why huge local arrays
held across an .await make futures large.
use std::time::Duration;
async fn fetch_title(isbn: &str) -> Option<String> {
tokio::time::sleep(Duration::from_millis(20)).await; // simulated network call
(isbn == "978").then(|| "Dune".to_string())
}
async fn fetch_rating(isbn: &str) -> u8 {
tokio::time::sleep(Duration::from_millis(20)).await;
if isbn == "978" { 5 } else { 0 }
}
#[tokio::main]
async fn main() {
let isbn = String::from("978");
let summary = async { // async block borrowing `isbn`
let title = fetch_title(&isbn).await?; // ? works inside async blocks returning Option
Some(format!("{title}: {} stars", fetch_rating(&isbn).await))
};
println!("{:?}", summary.await);
let handle = tokio::spawn(async move { // spawned tasks need 'static: move
fetch_rating(&isbn).await
});
println!("{}", handle.await.unwrap());
}
Source: The Rust Reference: Async blocks
Concurrency: join! and select!
Awaiting futures one after another runs them sequentially. To run them concurrently on the same task, combine them:
-
tokio::join!(a, b, ...)(orfutures::join!) polls all futures until every one completes and returns all results;try_join!stops at the firstErr. -
tokio::select!waits for the first of several futures to complete, then drops the others. -
For a dynamic number of futures, use
futures::future::join_all,FuturesUnordered, or tokio’sJoinSet(which spawns them as separate tasks).
use std::time::{Duration, Instant};
use tokio::time::sleep;
async fn lookup(source: &'static str, ms: u64) -> &'static str {
sleep(Duration::from_millis(ms)).await;
source
}
#[tokio::main]
async fn main() {
let start = Instant::now();
let a = lookup("catalog", 100).await;
let b = lookup("reviews", 100).await;
println!("sequential: {a}, {b} in ~{} ms", start.elapsed().as_millis() / 100 * 100); // ~200 ms
let start = Instant::now();
let (a, b) = tokio::join!(lookup("catalog", 100), lookup("reviews", 100));
println!("joined: {a}, {b} in ~{} ms", start.elapsed().as_millis() / 100 * 100); // ~100 ms
let winner = tokio::select! {
r = lookup("primary", 300) => r,
r = lookup("replica", 50) => r,
_ = sleep(Duration::from_millis(200)) => "timeout",
};
println!("first to answer: {winner}"); // replica
}
join! and select! give concurrency within one task: the futures share a thread. To use several CPU cores, spawn
separate tasks with tokio::spawn on a multi-threaded runtime.
Cancellation
A future that is dropped before completion is cancelled: it is never polled again, and its locals are dropped at
whatever .await it was suspended on. Cancellation is how select! discards the losers, how tokio::time::timeout
works, and what happens when an HTTP client disconnects while axum is running a handler.
The consequence is cancel safety: code between two .await points always runs to completion, but code across an
.await may stop halfway. A function that reads part of a message into a local buffer and then awaits more data loses
that partial message if cancelled. Design for it: keep partial state in a struct that outlives the future, use
cancel-safe APIs in select! loops (tokio documents which methods are), and use RAII guards for cleanup that must happen.
use std::time::Duration;
use tokio::time::{sleep, timeout};
struct Cleanup(&'static str);
impl Drop for Cleanup {
fn drop(&mut self) {
println!("cleanup: {}", self.0); // runs even when the future is cancelled
}
}
async fn slow_import() -> u32 {
let _guard = Cleanup("release import lock");
sleep(Duration::from_secs(10)).await; // cancelled while suspended here
println!("never printed");
42
}
#[tokio::main]
async fn main() {
match timeout(Duration::from_millis(50), slow_import()).await {
Ok(n) => println!("imported {n}"),
Err(_) => println!("timed out: the import future was dropped"),
}
}
Source: Async Book: Cancellation
Streams
A stream is the async counterpart of an iterator: it yields many values over time (poll_next returns
Poll<Option<Item>>). The trait is not yet in the standard library; the ecosystem uses futures::Stream with
StreamExt adapters (map, filter, buffer_unordered, next), and tokio-stream for tokio types. SSE and WebSocket
handlers in axum consume and produce streams.
use futures::stream::{self, StreamExt};
async fn rating_for(isbn: u32) -> u32 {
isbn % 5 + 1
}
#[tokio::main]
async fn main() {
let ratings: Vec<u32> = stream::iter([978u32, 979, 980, 981])
.map(rating_for) // a stream of futures
.buffer_unordered(2) // run up to two concurrently
.collect()
.await;
println!("{} ratings", ratings.len());
let mut evens = stream::iter(1..=6).filter(|n| futures::future::ready(n % 2 == 0));
while let Some(n) = evens.next().await {
print!("{n} ");
}
println!();
}
async fn in traits, async closures and recursion
-
async fn in traits (1.75): trait methods can be
async fn. Callers cannot require the returned future to beSend, which matters for generic code that spawns it; declare the method asfn get(&self) -> impl Future<Output = T> + Sendwhen that is needed. Such traits are not dyn-compatible; see Trait Objects & dyn Compatibility. -
async closures (1.85):
async |x| { ... }with theAsyncFn,AsyncFnMutandAsyncFnOncetraits; see Closures & Fn Traits. -
recursion: an
async fncannot call itself directly, because its state machine would contain itself (error E0733, "recursion in an async fn requires boxing"). Box the recursive call:Box::pin(walk(child)).await.
#[derive(Debug)]
struct Category {
name: String,
children: Vec<Category>,
}
async fn count(cat: &Category) -> usize {
let mut n = 1;
for child in &cat.children {
n += Box::pin(count(child)).await; // boxing breaks the infinitely sized future
}
n
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let tree = Category {
name: "Fiction".into(),
children: vec![Category { name: "Sci-fi".into(), children: vec![] }, Category { name: "Classics".into(), children: vec![] }],
};
println!("{} has {} categories", tree.name, count(&tree).await);
}
Source: Async Book: Recursion
Future and IntoFuture in the 2024 prelude
The 2024 edition adds Future and IntoFuture to the prelude, so implementing or bounding on them no longer needs an
import. IntoFuture (1.64) is what .await actually calls, which lets builder types be awaited directly — reqwest’s
client.get(url).send() and sqlx’s query builders use it.
The same idea in other languages
C#, JavaScript, Python and Swift have async/await with eager tasks scheduled by a built-in runtime; Rust’s futures
are lazy, the runtime is a library, and futures are zero-cost state machines rather than heap-allocated objects. Java
reached for the opposite design with virtual threads. Compare
C#: Async & Await,
JavaScript: Async JavaScript,
Python: Concurrency & Async,
Swift: Async/Await & Tasks,
Java: Virtual Threads,
Java: High-Level Concurrency,
C++: Coroutines,
C++: Async, Futures, and Atomics and
PHP: Fibers & Asynchronous PHP.
Common errors
| Error | Cause and fix |
|---|---|
E0728 |
make the caller |
"future cannot be sent between threads safely" (E0277) |
a non- |
"unused implementer of |
a missing |
E0733 recursion in an async fn requires boxing |
|
E0373 async block may outlive the current function |
add |
Where the books are outdated
Gangappa & Rangaraj (Mastering Distributed Observability in Rust, chapter 2) and Yoon (Beginning Axum, chapter 1)
introduce async with tokio; both predate async closures (1.85) and the 2024 prelude additions. Code that uses the
async-trait macro for static dispatch can switch to native async fn in traits (1.75).
References
-
The Rust Programming Language: Fundamentals of Asynchronous Programming (futures, concurrency, streams, traits)