Async/Await
TL;DR async fn desugars to a function that returns a Future. The future is a compiler-generated state machine. Calling .await suspends the current task if the future isn't ready, yielding control back to the runtime.
The Desugaring
async fn fetch(url: &str) -> String {
// ...
}
// Equivalent to:
fn fetch(url: &str) -> impl Future<Output = String> + '_ {
// compiler generates a state machine struct here
}
The state machine captures all local variables that survive across .await points. This is why async functions have larger stack frames than equivalent synchronous code — state is stored on the heap (in a Box<dyn Future>) when tasks are spawned.
The Future Trait
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T),
Pending,
}
The runtime calls poll. If it returns Pending, the future is suspended. When it's ready to make progress (e.g., I/O is available), the Waker stored in Context is called, which tells the runtime to poll the future again.
You rarely implement Future manually — async/await generates the implementation.
Tokio: The Runtime
async functions don't run themselves. They need an executor — a runtime that drives the futures to completion. Tokio is the standard choice for Rust servers.
#[tokio::main]
async fn main() {
let result = fetch("https://example.com").await;
println!("{result}");
}
#[tokio::main] desugars to:
fn main() {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async {
// your async main body
})
}
Spawning Tasks
tokio::spawn creates a lightweight task that runs concurrently. Tasks are multiplexed across OS threads by Tokio's work-stealing scheduler.
let handle = tokio::spawn(async {
// runs concurrently with the caller
compute_something().await
});
let result = handle.await.unwrap(); // JoinHandle<T>
Spawned tasks must be Send + 'static — they may move between threads.
Concurrency vs Parallelism
.await is cooperative: a task yields only at .await points. CPU-bound work blocks the thread and starves other tasks.
// BAD: blocks the Tokio thread
tokio::spawn(async {
std::thread::sleep(Duration::from_secs(1)); // never yield
});
// GOOD: yield-friendly sleep
tokio::spawn(async {
tokio::time::sleep(Duration::from_secs(1)).await;
});
// GOOD: CPU-bound work on a dedicated thread pool
tokio::task::spawn_blocking(|| {
heavy_computation() // runs on a blocking thread, doesn't starve async tasks
});
Join Multiple Futures
Run futures concurrently and wait for all:
let (a, b) = tokio::join!(fetch_a(), fetch_b());
join! polls both futures, interleaving progress. This is concurrent but still single-threaded (unless spawned).
// Race — first one wins, others are cancelled
let result = tokio::select! {
r = fetch_a() => r,
r = fetch_b() => r,
};
Gotchas
Holding a MutexGuard across .await:
// will deadlock or fail to compile
async fn bad(lock: &Mutex<Vec<i32>>) {
let guard = lock.lock().unwrap();
something_async().await; // guard held across yield point
// ...
}
Use tokio::sync::Mutex for async-safe locking, or drop the guard before the .await.
async closures are not stable yet (as of 2025, tracked in RFC #2394). Use async move {} blocks instead.