Async/await in Rust with Tokio: the basics
Getting started with async Rust: futures, async fn and .await, #[tokio::main], tokio::spawn, join!, async sleep, and when to use async vs threads.
Async Rust lets one thread juggle many tasks that spend most of their time waiting: on the network, a database, a timer. While one task waits, the thread runs another. That's why web servers written with async Rust handle tens of thousands of connections with a handful of threads.
Futures are lazy
An async fn doesn't run when you call it. It returns a future, a value representing work that hasn't happened yet. .await runs the future to completion (pausing the current task while it waits) and gives you its output:
async fn fetch_price(item: &str) -> u32 { ... }
let price = fetch_price("potion").await;
Calling an async function without awaiting it does nothing, and rustc warns: "unused implementer of Future that must be used ... futures do nothing unless you .await or poll them".
.await is only allowed inside async code:
async fn fetch() -> u32 {
5
}
fn main() {
let n = fetch().await;
println!("{n}");
}
error[E0728]: `await` is only allowed inside `async` functions and blocks
A runtime: Tokio
Rust's standard library defines futures but doesn't include a runtime to drive them. Tokio is the one most teams use. Add it to Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["full"] }
and mark main:
#[tokio::main]
async fn main() {
let price = fetch_price("potion").await;
println!("{price}");
}
#[tokio::main] starts a multi-threaded runtime and runs your async main on it.
Running things concurrently
Awaiting one future after another runs them in sequence. To run them at the same time:
tokio::join!(a, b, c)runs several futures concurrently in the current task and returns all their outputs, in argument order.try_join!stops at the firstErr.tokio::spawn(future)starts an independent task that the runtime may run on any worker thread. It returns aJoinHandle;handle.awaitgivesResult<T, JoinError>(Errif the task panicked). Spawned tasks must beSend + 'static, like threads, so useasync moveand owned data.tokio::select!waits on several futures and continues with whichever finishes first (timeouts, cancellation).
Don't block the runtime
Inside async code, never call blocking functions such as std::thread::sleep, heavy CPU loops, or blocking file and network I/O: they freeze a whole worker thread and every task on it. Use the async versions (tokio::time::sleep, tokio::fs, tokio::net), and move CPU-heavy or blocking work to tokio::task::spawn_blocking.
Async vs threads
| Use async when | Use threads when |
|---|---|
| many tasks mostly waiting on I/O (servers, crawlers, chat) | a few CPU-heavy jobs (image processing, compression) |
| you need timeouts and cancellation everywhere | the code calls blocking libraries |
| you use async libraries (axum, reqwest, sqlx) | simplicity matters more than scale |
For CPU-bound data parallelism, rayon is usually simpler than both.
Common mistakes
- Forgetting
.await(the work never happens). std::sync::Mutexguards held across.await(the future isn'tSend; usetokio::sync::Mutexor drop the guard first).- Expecting spawned tasks to print in a fixed order. Collect their results through
JoinHandles orjoin!, then print.
Example
use std::time::Duration;
use tokio::time::sleep;
async fn fetch_price(item: &str, delay_ms: u64) -> u32 {
sleep(Duration::from_millis(delay_ms)).await; // pretend this is a network call
match item {
"potion" => 30,
"sword" => 150,
_ => 0,
}
}
async fn checkout(items: Vec<&'static str>) -> Result<u32, String> {
let mut total = 0;
for item in items {
let price = fetch_price(item, 5).await;
if price == 0 {
return Err(format!("unknown item {item}"));
}
total += price;
}
Ok(total)
}
#[tokio::main]
async fn main() {
// Concurrently: both waits overlap. Results come back in argument order.
let (potion, sword) = tokio::join!(fetch_price("potion", 30), fetch_price("sword", 10));
println!("potion {potion}, sword {sword}");
// Independent tasks on the runtime; await the handles in order.
let mut handles = Vec::new();
for (i, cart) in [vec!["potion", "sword"], vec!["potion", "shield"], vec!["sword"]].into_iter().enumerate() {
handles.push(tokio::spawn(async move { (i, checkout(cart).await) }));
}
for h in handles {
let (i, result) = h.await.expect("task panicked");
println!("cart {i}: {result:?}");
}
// CPU-heavy work goes to a blocking thread, not the async workers.
let sum = tokio::task::spawn_blocking(|| (1..=1_000_000u64).sum::<u64>()).await.unwrap();
println!("sum {sum}");
}
Output:
potion 30, sword 150
cart 0: Ok(180)
cart 1: Err("unknown item shield")
cart 2: Ok(150)
sum 500000500000