15. Async/Await Essentials 🔴
What you'll learn:
- How Rust's
Futuretrait differs from Go's goroutines and Python's asyncio- Tokio quick-start: spawning tasks,
join!, and runtime configuration- Common async pitfalls and how to fix them
- When to offload blocking work with
spawn_blocking
Futures, Runtimes, and async fn
Rust's async model is fundamentally different from Go's goroutines or Python's asyncio.
Understanding three concepts is enough to get started:
- A
Futureis a lazy state machine — callingasync fndoesn't execute anything; it returns aFuturethat must be polled. - You need a runtime to poll futures —
tokio,async-std, orsmol. The standard library definesFuturebut provides no runtime. async fnis sugar — the compiler transforms it into a state machine that implementsFuture.
// A Future is just a trait:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
// async fn desugars to:
// fn fetch_data(url: &str) -> impl Future<Output = Result<Vec<u8>, Error>>
async fn fetch_data(url: &str) -> Result<Vec<u8>, reqwest::Error> {
let response = reqwest::get(url).await?; // .await yields until ready
let bytes = response.bytes().await?;
Ok(bytes.to_vec())
}
Tokio Quick Start
Cargo.toml
[dependencies] tokio = { version = "1", features = ["full"] }
use tokio::time::{sleep, Duration};
use tokio::task;
#[tokio::main]
async fn main() {
// Spawn concurrent tasks (like lightweight threads):
let handle_a = task::spawn(async {
sleep(Duration::from_millis(100)).await;
"task A done"
});
let handle_b = task::spawn(async {
sleep(Duration::from_millis(50)).await;
"task B done"
});
// .await both — they run concurrently, not sequentially:
let (a, b) = tokio::join!(handle_a, handle_b);
println!("{}, {}", a.unwrap(), b.unwrap());
}
Async Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Blocking in async | std::thread::sleep or CPU work blocks the executor | Use tokio::task::spawn_blocking or rayon |
Send bound errors | Future held across .await contains !Send type (e.g., Rc, MutexGuard) | Restructure to drop non-Send values before .await |
| Future not polled | Calling async fn without .await or spawning — nothing happens | Always .await or tokio::spawn the returned future |
Holding MutexGuard across .await | std::sync::MutexGuard is !Send; async tasks may resume on different thread | Use tokio::sync::Mutex or drop the guard before .await |
| Accidental sequential execution | let a = foo().await; let b = bar().await; runs sequentially | Use tokio::join! or tokio::spawn for concurrency |
// ❌ Blocking the async executor:
async fn bad() {
std::thread::sleep(std::time::Duration::from_secs(5)); // Blocks entire thread!
}
// ✅ Offload blocking work:
async fn good() {
tokio::task::spawn_blocking(|| {
std::thread::sleep(std::time::Duration::from_secs(5)); // Runs on blocking pool
}).await.unwrap();
}
Comprehensive async coverage: For
Stream,select!, cancellation safety, structured concurrency, andtowermiddleware, see our dedicated Async Rust Training guide. This section covers just enough to read and write basic async code.
Spawning and Structured Concurrency
Tokio's spawn creates a new asynchronous task — similar to thread::spawn but
much lighter:
use tokio::task;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
// Spawn three concurrent tasks
let h1 = task::spawn(async {
sleep(Duration::from_millis(200)).await;
"fetched user profile"
});
let h2 = task::spawn(async {
sleep(Duration::from_millis(100)).await;
"fetched order history"
});
let h3 = task::spawn(async {
sleep(Duration::from_millis(150)).await;
"fetched recommendations"
});
// Wait for all three concurrently (not sequentially!)
let (r1, r2, r3) = tokio::join!(h1, h2, h3);
println!("{}", r1.unwrap());
println!("{}", r2.unwrap());
println!("{}", r3.unwrap());
}
join! vs try_join! vs select!:
| Macro | Behavior | Use when |
|---|---|---|
join! | Waits for ALL futures | All tasks must complete |
try_join! | Waits for all, short-circuits on first Err | Tasks return Result |
select! | Returns when FIRST future completes | Timeouts, cancellation |
use tokio::time::{timeout, Duration};
async fn fetch_with_timeout() -> Result<String, Box<dyn std::error::Error>> {
let result = timeout(Duration::from_secs(5), async {
// Simulate slow network call
tokio::time::sleep(Duration::from_millis(100)).await;
Ok::<_, Box<dyn std::error::Error>>("data".to_string())
}).await??; // First ? unwraps Elapsed, second ? unwraps inner Result
Ok(result)
}
Send Bounds and Why Futures Must Be Send
When you tokio::spawn a future, it may resume on a different OS thread.
This means the future must be Send. Common pitfalls:
use std::rc::Rc;
async fn not_send() {
let rc = Rc::new(42); // Rc is !Send
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
println!("{}", rc); // rc is held across .await — future is !Send
}
// Fix 1: Drop before .await
async fn fixed_drop() {
let data = {
let rc = Rc::new(42);
*rc // Copy the value out
}; // rc dropped here
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
println!("{}", data); // Just an i32, which is Send
}
// Fix 2: Use Arc instead of Rc
async fn fixed_arc() {
let arc = std::sync::Arc::new(42); // Arc is Send
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
println!("{}", arc); // ✅ Future is Send
}
Comprehensive async coverage: For
Stream,select!, cancellation safety, structured concurrency, andtowermiddleware, see our dedicated Async Rust Training guide. This section covers just enough to read and write basic async code.
See also: Ch 5 — Channels for synchronous channels. Ch 6 — Concurrency for OS threads vs async tasks.
Key Takeaways — Async
async fnreturns a lazyFuture— nothing runs until you.awaitor spawn it- Use
tokio::task::spawn_blockingfor CPU-heavy or blocking work inside async contexts- Don't hold
std::sync::MutexGuardacross.await— usetokio::sync::Mutexinstead- Futures must be
Sendwhen spawned — drop!Sendtypes before.awaitpoints
Exercise: Concurrent Fetcher with Timeout ★★ (~25 min)
Write an async function fetch_all that spawns three tokio::spawn tasks, each
simulating a network call with tokio::time::sleep. Join all three with
tokio::try_join! wrapped in tokio::time::timeout(Duration::from_secs(5), ...).
Return Result<Vec<String>, ...> or an error if any task fails or the deadline
expires.
use tokio::time::{sleep, timeout, Duration};
async fn fake_fetch(name: &'static str, delay_ms: u64) -> Result<String, String> {
sleep(Duration::from_millis(delay_ms)).await;
Ok(format!("{name}: OK"))
}
async fn fetch_all() -> Result<Vec<String>, Box<dyn std::error::Error>> {
let deadline = Duration::from_secs(5);
let (a, b, c) = timeout(deadline, async {
let h1 = tokio::spawn(fake_fetch("svc-a", 100));
let h2 = tokio::spawn(fake_fetch("svc-b", 200));
let h3 = tokio::spawn(fake_fetch("svc-c", 150));
tokio::try_join!(h1, h2, h3)
})
.await??;
Ok(vec![a?, b?, c?])
}
#[tokio::main]
async fn main() {
let results = fetch_all().await.unwrap();
for r in &results {
println!("{r}");
}
}