Step 14 of 17
Async Rust
Write concurrent I/O code with async/await and Tokio: lazy futures, spawning tasks, select!, timeouts, and the pitfalls of blocking.
Threads are great for CPU-heavy work, but a server handling 50,000 open connections can't afford 50,000 OS threads, each with its own stack and scheduling cost. Async Rust lets one thread juggle thousands of tasks that spend most of their time waiting on the network. The model is different from JavaScript's or Go's in ways that matter: futures are lazy, the runtime isn't built in, and blocking calls are a real hazard. This lesson covers what a future is, how async/await works, the Tokio runtime, spawning tasks and sharing state between them, select! and timeouts, and the mistakes everyone makes once.
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. Nothing executes until something awaits or polls it.
async fn fetch_greeting() -> String {
println!("fetching...");
String::from("hello")
}
#[tokio::main]
async fn main() {
let fut = fetch_greeting(); // nothing printed yet
println!("created the future");
let greeting = fut.await; // now it runs: prints "fetching..."
println!("{greeting}");
}created the future
fetching...
helloThis is the opposite of JavaScript, where calling an async function starts it immediately and returns a running promise. Forgetting .await in Rust means the work never happens; the compiler warns that the future is unused.
Under the hood, the compiler turns each async fn into a state machine: an enum with one variant per .await point, holding the local variables live at that point. It implements the Future trait:
use std::pin::Pin;
use std::task::{Context, Poll};
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}poll either finishes with Poll::Ready(value) or returns Poll::Pending after arranging to be woken up later through the Waker in the Context. No heap allocation or thread is needed per future. That's why async Rust is cheap: a task is just a state machine waiting in a queue.
Runtimes and Tokio
The standard library defines the Future trait but ships no executor to poll futures and no reactor to watch sockets. You pick a runtime, and in practice that's almost always Tokio:
cargo add tokio --features full#[tokio::main] turns async fn main into a normal main that starts a multi-threaded runtime (one worker thread per CPU core by default) and blocks on your future. Here is what happens when a task waits on a socket:
Tokio provides async versions of everything that waits: tokio::net (TCP, UDP), tokio::fs, tokio::time (sleep, interval, timeout), tokio::sync (channels, mutexes, semaphores) and tokio::process. Use these inside async code, not their std counterparts.
Concurrency without threads
.await waits for one future. To run several at once, combine them. tokio::join! polls futures concurrently on the current task and waits for all of them:
use std::time::{Duration, Instant};
use tokio::time::sleep;
async fn fetch(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await; // simulate a network call
format!("{name} done")
}
#[tokio::main]
async fn main() {
let start = Instant::now();
// Sequential: about 300 ms
let a = fetch("users", 100).await;
let b = fetch("orders", 200).await;
println!("{a}, {b} in {:?}", start.elapsed());
// Concurrent: about 200 ms, the longest of the two
let start = Instant::now();
let (a, b) = tokio::join!(fetch("users", 100), fetch("orders", 200));
println!("{a}, {b} in {:?}", start.elapsed());
}For fallible futures, tokio::try_join! returns the first error and drops the rest.
Spawning tasks
tokio::spawn hands a future to the runtime as an independent task, which may run on any worker thread in parallel with others. It returns a JoinHandle you can await for the result:
use std::time::Duration;
#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for id in 0..5u64 {
handles.push(tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50 * (5 - id))).await;
id * id
}));
}
for h in handles {
let v = h.await.expect("task panicked"); // JoinError if the task panicked
print!("{v} ");
}
println!();
}Because a spawned task can run on another thread and outlive the code that spawned it, its future must be Send + 'static, exactly like thread::spawn's closure. In practice: use async move, share data with Arc, and don't hold a non-Send value (an Rc, a RefCell borrow, a std::sync::MutexGuard) across an .await.
For a dynamic set of tasks, tokio::task::JoinSet collects them and yields results as they finish.
Sharing state between tasks
The same tools as threads apply, with one async twist. For short, synchronous critical sections, std::sync::Mutex is fine and faster, as long as you never hold its guard across an .await:
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Clone, Default)]
struct Cache {
inner: Arc<Mutex<HashMap<String, String>>>,
}
impl Cache {
fn get(&self, k: &str) -> Option<String> {
self.inner.lock().unwrap().get(k).cloned() // lock, copy out, unlock
}
fn put(&self, k: String, v: String) {
self.inner.lock().unwrap().insert(k, v);
}
}
#[tokio::main]
async fn main() {
let cache = Cache::default();
let c = cache.clone();
tokio::spawn(async move { c.put("lang".into(), "rust".into()) }).await.unwrap();
println!("{:?}", cache.get("lang"));
}When you must hold a lock across an .await (say, to serialize access to a connection), use tokio::sync::Mutex, whose lock() is itself async. Often a better design is a single task that owns the resource and receives requests over a tokio::sync::mpsc channel: the actor pattern.
Tokio's channels: mpsc (many producers, one consumer, bounded for backpressure), oneshot (a single reply), broadcast (every receiver gets every message) and watch (receivers see the latest value, ideal for config reloads and shutdown signals).
select! and timeouts
tokio::select! waits on several futures and runs the branch of whichever completes first, dropping the others:
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::{sleep, timeout};
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<String>(16);
tokio::spawn(async move {
sleep(Duration::from_millis(30)).await;
tx.send("job-1".into()).await.ok();
});
loop {
tokio::select! {
Some(job) = rx.recv() => println!("got {job}"),
_ = sleep(Duration::from_millis(100)) => {
println!("idle for 100ms, stopping");
break;
}
}
}
// timeout wraps any future and returns Err(Elapsed) if it takes too long.
match timeout(Duration::from_millis(50), sleep(Duration::from_secs(5))).await {
Ok(()) => println!("finished"),
Err(_) => println!("timed out"),
}
}Dropping a future cancels it: it simply stops being polled, and its locals are dropped. That's how timeouts and select! cancel work, and why cancellation in Rust needs no tokens for the common case (compare Go's context cancellation). The flip side is cancellation safety: if a future is dropped halfway through, any work done before its last .await stays done. Reading half a message from a socket and then being cancelled loses that half. Tokio's docs mark which methods are cancellation-safe; mpsc::Receiver::recv is, for example, so it's safe in a select! loop.
Don't block the runtime
A Tokio worker thread runs one task at a time between .await points. If a task does something that blocks the thread — std::thread::sleep, synchronous file or network I/O, a long CPU loop — every other task scheduled on that worker stalls. A few of those and the whole server freezes, while CPU usage looks low.
async fn handler_bad() {
std::thread::sleep(std::time::Duration::from_secs(1)); // blocks a worker thread
}
async fn handler_good() {
tokio::time::sleep(std::time::Duration::from_secs(1)).await; // yields to other tasks
}
async fn hash_password(pw: String) -> String {
// CPU-heavy or blocking work goes to a dedicated thread pool.
tokio::task::spawn_blocking(move || {
// e.g. argon2 hashing, a blocking SDK call, compressing a big file
format!("hashed:{}", pw.len())
})
.await
.expect("blocking task panicked")
}A rough rule: if something can take more than 10–100 microseconds without hitting an .await, move it to spawn_blocking (for blocking I/O) or a rayon pool (for heavy CPU work).
Practice
Write a concurrent URL "checker" with simulated latency (no real network needed).
- Write
async fn check(url: String) -> Result<u64, String>that fails quickly for URLs containing"bad", and otherwise sleeps for a pseudo-random time based on the URL's length (e.g.(url.len() as u64 * 37) % 400ms) and succeeds. - Check 20 URLs concurrently with
tokio::spawn, but allow at most 5 in flight at once, usingtokio::sync::Semaphore(shared viaArc, acquired inside each task). - Apply a 300 ms timeout per check with
tokio::time::timeout; report timeouts separately from errors. - Print a summary: OK count, error count, timeout count, and total elapsed time.
Show solution
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tokio::time::{sleep, timeout};
async fn check(url: String) -> Result<u64, String> {
if url.contains("bad") {
sleep(Duration::from_millis(20)).await; // servers that fail tend to fail fast
return Err(format!("{url}: 500"));
}
let ms = (url.len() as u64 * 37) % 400;
sleep(Duration::from_millis(ms)).await;
Ok(ms)
}
enum Outcome {
Ok,
Failed,
TimedOut,
}
#[tokio::main]
async fn main() {
let urls: Vec<String> = (0..20)
.map(|i| if i % 7 == 0 { format!("https://bad{i}.example") } else { format!("https://site{i}.example/{}", "x".repeat(i)) })
.collect();
let limit = Arc::new(Semaphore::new(5));
let start = Instant::now();
let mut set = JoinSet::new();
for url in urls {
let limit = Arc::clone(&limit);
set.spawn(async move {
let _permit = limit.acquire_owned().await.expect("semaphore closed");
match timeout(Duration::from_millis(300), check(url)).await {
Ok(Ok(_)) => Outcome::Ok,
Ok(Err(_)) => Outcome::Failed,
Err(_) => Outcome::TimedOut,
}
});
}
let (mut ok, mut failed, mut timed_out) = (0, 0, 0);
while let Some(res) = set.join_next().await {
match res.expect("task panicked") {
Outcome::Ok => ok += 1,
Outcome::Failed => failed += 1,
Outcome::TimedOut => timed_out += 1,
}
}
println!("ok={ok} failed={failed} timed_out={timed_out} in {:?}", start.elapsed());
}All 20 tasks are spawned immediately, but each waits for a permit, so at most 5 run check at once. The permit is dropped when the task finishes, letting the next one in. timeout returns a nested Result: the outer one says whether it finished in time, the inner one is check's own result. Dropping a timed-out check future cancels it. JoinSet::join_next yields results in completion order.
Key takeaways
- Futures are lazy state machines; nothing runs until it's awaited or spawned.
- The standard library defines
Future; a runtime such as Tokio supplies the executor, the I/O reactor, timers and async channels. join!runs futures concurrently within a task;tokio::spawncreates independentSend + 'statictasks that can run in parallel.select!andtimeoutcancel by dropping futures; be aware of what's lost when a future is cancelled midway.- Never block a runtime thread: use async APIs,
spawn_blockingfor blocking calls, and don't hold astdmutex guard across.await.
Check yourself
Quiz
0/4 correct · 4 left
1.What prints first? let f = work(); println!("A"); f.await; — where async fn work() { println!("B") }
2.A handler calls std::thread::sleep(Duration::from_secs(2)) inside an async fn on Tokio. What's the effect?
3.tokio::spawn(async { use_rc(rc.clone()).await }) fails with 'future cannot be sent between threads safely'. Why?
4.How does tokio::time::timeout cancel the future it wraps when time runs out?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
- Context and cancellationOne value that carries a deadline and a cancel signal through every call a request makes.
- Interior mutabilityMutate through a shared reference with Cell, RefCell, Mutex or atomics — the aliasing rule still holds, checked at runtime.
- Send and SyncTwo auto traits that say which types may move to or be shared with other threads — why safe Rust has no data races.
- Lazy futures and async runtimesA Rust future does nothing until polled; a runtime such as Tokio polls it and wakes it when I/O is ready.
Finished reading?
Mark it complete to fill in your roadmap.