Async & Await
Async programming in Rust lets you write concurrent code that handles thousands of I/O-bound tasks without spawning thousands of threads. The async/.await syntax looks sequential, but under the hood a state machine multiplexes tasks onto a small thread pool. The result is high concurrency with low overhead — but the model has sharp edges that are worth understanding upfront.
async fn & .await
An async fn returns a Future instead of executing immediately. The future does nothing until polled — typically by an async runtime:
async fn fetch_data(url: &str) -> String {
// Simulating an HTTP request
println!("Fetching {}", url);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
format!("Data from {}", url)
}
#[tokio::main]
async fn main() {
let result = fetch_data("https://api.example.com").await;
println!("{}", result);
}
Fetching https://api.example.com
Data from https://api.example.com
.await suspends the current task and yields control to the runtime, which can poll other futures while waiting. This is cooperative multitasking — tasks must yield voluntarily.
Futures Are Lazy
This is the most important thing to internalize: calling an async function does not execute it.
async fn greet() {
println!("Hello!");
}
#[tokio::main]
async fn main() {
let future = greet(); // Nothing happens yet
println!("Future created");
future.await; // NOW it executes
}
Future created
Hello!
If you forget .await, the code silently does nothing. The compiler warns about unused futures, but it is easy to miss.
The Difference Between Threads & Async
Threads give you preemptive parallelism. The OS scheduler switches between threads, and each thread has its own stack (typically 2-8 MB). Spawning 10,000 threads is expensive.
Async gives you cooperative concurrency. Tasks share a small thread pool, each task uses minimal memory (a few hundred bytes for the state machine), and the runtime switches between tasks at .await points. Spawning 10,000 async tasks is cheap.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let mut handles = vec![];
// Spawn 10,000 concurrent tasks — trivial with async
for i in 0..10_000 {
handles.push(tokio::spawn(async move {
sleep(Duration::from_millis(100)).await;
i
}));
}
let mut sum = 0u64;
for handle in handles {
sum += handle.await.unwrap() as u64;
}
println!("Sum: {}", sum);
}
Sum: 49995000
When to use threads: CPU-bound work, blocking I/O, work that cannot be made async.
When to use async: Network I/O, file I/O (with async libraries), handling many concurrent connections, anything where tasks spend most of their time waiting.
You Need a Runtime
Rust's standard library provides the Future trait but no runtime. You must choose one:
- tokio — the dominant async runtime. Full-featured: timers, I/O, channels, synchronization primitives. Used by most production Rust projects.
- async-std — mirrors the standard library API with async equivalents. Smaller community but simpler learning curve.
- smol — minimal runtime, good for understanding how runtimes work.
// With tokio
#[tokio::main]
async fn main() {
println!("Running on tokio");
}
// Equivalent manual setup
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
println!("Running on tokio");
});
}
The runtime provides the executor (polls futures), the reactor (watches for I/O events), and utilities like timers and channels. Without a runtime, futures have nothing to drive them.
Concurrent Execution
.await is sequential. To run futures concurrently, use join! or spawn:
use tokio::time::{sleep, Duration};
async fn fetch_user() -> String {
sleep(Duration::from_millis(200)).await;
"Alice".into()
}
async fn fetch_orders() -> Vec<String> {
sleep(Duration::from_millis(300)).await;
vec!["order-1".into(), "order-2".into()]
}
#[tokio::main]
async fn main() {
// Sequential: 200ms + 300ms = 500ms
let user = fetch_user().await;
let orders = fetch_orders().await;
println!("{}: {:?}", user, orders);
// Concurrent: max(200ms, 300ms) = 300ms
let (user, orders) = tokio::join!(fetch_user(), fetch_orders());
println!("{}: {:?}", user, orders);
}
Alice: ["order-1", "order-2"]
Alice: ["order-1", "order-2"]
tokio::join! runs both futures on the same task. tokio::spawn creates separate tasks that can run on different threads.
Pinning
Futures in Rust are state machines that may contain self-referential data. Pin ensures a future is not moved in memory after it has been polled, which would invalidate internal pointers:
use std::pin::Pin;
use std::future::Future;
fn make_future() -> Pin<Box<dyn Future<Output = String>>> {
Box::pin(async {
"hello from pinned future".to_string()
})
}
#[tokio::main]
async fn main() {
let result = make_future().await;
println!("{}", result);
}
hello from pinned future
You encounter Pin most often when:
- Storing futures in structs
- Implementing
Futuremanually - Using
Box::pin()to put a future on the heap
In day-to-day async code, you rarely deal with Pin directly. The async/.await syntax handles it for you. When the compiler complains about pinning, Box::pin() is usually the answer.
Async Is Not Always Faster
Async has overhead: the state machine, the runtime, the cooperative scheduling. For CPU-bound work, async adds complexity with no benefit:
// BAD: CPU-bound work in async — blocks the runtime
async fn compute_hash(data: &[u8]) -> Vec<u8> {
// This blocks the executor thread
expensive_hash(data)
}
// GOOD: Offload CPU-bound work to a blocking thread
async fn compute_hash_proper(data: Vec<u8>) -> Vec<u8> {
tokio::task::spawn_blocking(move || expensive_hash(&data))
.await
.unwrap()
}
Async shines when tasks spend most of their time waiting on I/O. If your tasks are compute-heavy, use threads or Rayon.
A Practical Example: Fetching Multiple URLs
use tokio::time::{sleep, Duration};
async fn fetch(url: &str) -> Result<String, String> {
// Simulate network latency
sleep(Duration::from_millis(150)).await;
if url.contains("error") {
Err(format!("Failed to fetch {}", url))
} else {
Ok(format!("Response from {}", url))
}
}
#[tokio::main]
async fn main() {
let urls = vec![
"https://api.example.com/users",
"https://api.example.com/orders",
"https://api.example.com/products",
];
let mut handles = vec![];
for url in urls {
handles.push(tokio::spawn(async move {
(url, fetch(url).await)
}));
}
for handle in handles {
let (url, result) = handle.await.unwrap();
match result {
Ok(body) => println!("{}: {}", url, body),
Err(e) => eprintln!("{}: {}", url, e),
}
}
}
https://api.example.com/users: Response from https://api.example.com/users
https://api.example.com/orders: Response from https://api.example.com/orders
https://api.example.com/products: Response from https://api.example.com/products
Common Pitfalls
- Forgetting
.await— the most common async bug. The future is created but never executed. The compiler warns, but in complex code the warning can be missed. - Blocking the runtime — calling
std::thread::sleep, synchronous file I/O, or CPU-heavy computation inside an async context starves other tasks. Usespawn_blockingfor blocking work. - Async in traits — prior to Rust 1.75,
async fnin traits required theasync-traitcrate, which boxes the future. As of Rust 1.75, native async trait methods work but with some limitations ondyn Trait. Sendbound issues —tokio::spawnrequires futures to beSend. Holding a non-Sendtype (likeRcorMutexGuardfromstd) across an.awaitpoint causes a compile error.- Colored function problem — async functions can only be called from async contexts. This "colors" your entire call stack. Design your libraries with this in mind.
- Assuming async is faster — for compute-bound work, async adds overhead without benefit. Use threads or Rayon instead.
Key Takeaways
async/.awaitenables cooperative concurrency: many tasks share a small thread pool.- Futures are lazy — they do nothing until awaited or polled by a runtime.
- You must choose a runtime (tokio is the standard). Rust's stdlib provides the trait, not the executor.
- Use
join!orspawnfor concurrent execution. Sequential.awaitchains run one at a time. - Async is for I/O-bound work. For CPU-bound work, use threads or
spawn_blocking. - Never block the async runtime with synchronous operations.