Async Rust 🌊
Threads (Lesson 16) are for working on many things at once. Async is for waiting on many things at once, and most of what servers do all day is wait. This is the machinery behind every Rust web service you have heard of.
The problem: waiting is not working ⏳
Picture a web server handling 10,000 connections. Almost all of them are just... waiting. Waiting for the network, waiting for the database, waiting for a slow phone on hotel wifi. One thread per connection means 10,000 mostly-sleeping threads, and threads are not free: each costs memory and scheduling work. Async flips the model: a handful of threads juggle thousands of paused tasks, and a task only occupies a thread while it is actually doing something.
The kitchen version: a thread-per-task cook stands motionless in front of each oven until it dings. An async cook puts six dishes in six ovens and does whatever is ready next. Same cook, six times the dinner.
async, await, and a runtime 🎬
Two keywords and one new dependency. Marking a function async
means "this can pause while it waits"; .await marks the places
where it may pause. And because pausing and resuming tasks needs a
scheduler, async Rust uses a runtime: the standard choice
is tokio
(cargo add tokio --features full):
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
println!("brewing coffee...");
sleep(Duration::from_millis(300)).await;
println!("coffee ready ☕");
}
#[tokio::main] starts the runtime and hands it your
async fn main. Note it is tokio's sleep, not
std::thread::sleep: the async version releases the thread to
do other work while the timer runs. Blocking calls inside async code are
the classic beginner bug; inside async fn, reach for the
tokio:: flavor of things.
Futures are lazy 😴
Calling an async function does not run it. It returns a
future: a value representing work that could happen.
Nothing moves until you .await it:
async fn say_hi() {
println!("hi from the future");
}
#[tokio::main]
async fn main() {
let future = say_hi(); // nothing printed yet!
println!("future created, not started");
future.await; // NOW it runs
}
future created, not started
hi from the future
This is Lesson 14's lazy iterators all over again: a description of work, run only when consumed. If an async function seems to "do nothing," check whether anything ever awaited it. The compiler even warns you.
The payoff: real concurrency 🚀
Awaiting things one after another is still sequential. The magic word is
tokio::join!, which drives several futures at the same
time and finishes when they all have:
use std::time::{Duration, Instant};
use tokio::time::sleep;
async fn cook(dish: &str, ms: u64) {
sleep(Duration::from_millis(ms)).await;
println!("{dish} done after {ms}ms");
}
#[tokio::main]
async fn main() {
let start = Instant::now();
tokio::join!(
cook("eggs", 300),
cook("toast", 200),
cook("coffee", 100),
);
println!("breakfast ready in {:?} (not 600ms!)", start.elapsed());
}
Three waits, 600ms of total sleeping, roughly 300ms on the clock: the cost
of the longest wait, not the sum. That one line is why async
exists. For a task that should run off on its own while you continue,
tokio::spawn hands back a handle you can await later:
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
let mut total: u64 = 0;
for n in 1..=1_000_000 {
total += n;
}
total
});
println!("main keeps going while the task works");
let sum = handle.await.expect("the task panicked");
println!("sum = {sum}");
}
Threads or async? 🤔
| Your workload is mostly... | Reach for | Because |
|---|---|---|
| Waiting (network, disk, timers, many connections) | async + tokio | thousands of cheap paused tasks per thread |
| Computing (number crunching, image processing) | threads (Lesson 16) | CPUs do the work; async adds nothing to pure math |
| A simple CLI or script | neither | sequential code is easier to read; concurrency must earn its keep |
The ecosystem built on this: axum and actix-web
for servers, reqwest for HTTP clients, sqlx for
databases. All of them are "async all the way down," which is why nearly
every Rust job posting mentions tokio. Fun fact for perspective: this
site's own little server uses plain threads, and for a tiny site that is
the correct choice. Tools, not fashion.
The download race
Write async fn download(name: &str, ms: u64) -> String
that sleeps for ms milliseconds and returns a label. "Download"
a.png (250ms), b.png (150ms), and c.png
(100ms) twice: once one-at-a-time with three awaits, once with
tokio::join!. Print both elapsed times with
Instant and explain the difference to your rubber duck.
Reveal solution
use std::time::{Duration, Instant};
use tokio::time::sleep;
async fn download(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await;
format!("{name} ({ms}ms)")
}
#[tokio::main]
async fn main() {
let start = Instant::now();
download("a.png", 250).await;
download("b.png", 150).await;
download("c.png", 100).await;
println!("one at a time: {:?}", start.elapsed());
let start = Instant::now();
let (a, b, c) = tokio::join!(
download("a.png", 250),
download("b.png", 150),
download("c.png", 100),
);
println!("got {a}, {b}, {c}");
println!("all at once: {:?}", start.elapsed());
}
Roughly 500ms sequential, roughly 250ms concurrent: the longest
download alone. Notice join! hands back all three results
as a tuple, in the order you wrote them, no matter which finished
first. 🏁
join! overlaps the waits, and blocking inside
async code is the cardinal sin. Learn this model once and axum, reqwest,
and every Rust service codebase suddenly read like plain Rust.