02 · Async/Await Basics¶
Module 01 covered OS threads — real parallelism, one
stack per thread, managed by the operating system's scheduler. Async Rust
solves a different problem: running huge numbers of tasks that spend most of
their time waiting (on a socket, a timer, a database) without paying for a
thread per task. An async fn compiles down to a state machine that can be
paused at each .await point and resumed later — Rust's standard library
defines the Future trait and the async/await syntax, but executing
futures requires a runtime. This module uses tokio, the
most widely used one.
async fn and .await¶
use std::time::Duration;
async fn fetch(name: &str, ms: u64) -> String {
tokio::time::sleep(Duration::from_millis(ms)).await;
format!("{name} done")
}
#[tokio::main]
async fn main() {
let result = fetch("job", 50).await;
println!("{result}");
}
async fn fetch(..) -> String doesn't actually return a String when
called — it returns a value implementing Future<Output = String> that does
nothing until polled. .await is what polls it (and yields control back to
the runtime while it's waiting). #[tokio::main] is a macro that wraps
main in the boilerplate needed to create a tokio runtime and block on the
async main body — nothing runs asynchronously without a runtime driving
it, which is the first surprise for people coming from JavaScript, where an
event loop is always implicitly present.
Sequential .await vs concurrent futures¶
Awaiting one future after another doesn't overlap their waiting time — that's easy to miss, because the syntax reads like it should:
use std::time::{Duration, Instant};
async fn fetch(name: &str, ms: u64) -> String {
tokio::time::sleep(Duration::from_millis(ms)).await;
format!("{name} done")
}
#[tokio::main]
async fn main() {
let start = Instant::now();
let a = fetch("a", 50).await;
let b = fetch("b", 50).await;
println!("{a}, {b}");
println!("sequential: {:?}", start.elapsed());
let start = Instant::now();
let (a, b) = tokio::join!(fetch("a", 50), fetch("b", 50));
println!("{a}, {b}");
println!("concurrent: {:?}", start.elapsed());
}
a.await fully completes before fetch("b", ..) is even created, so the two
50ms sleeps add up. tokio::join! polls both futures on the same task,
switching between them whenever one is waiting — so the two sleeps overlap
and the total is roughly the longest one, not the sum. Reaching for
join! (or tokio::spawn, below) instead of back-to-back .await is the
single most common fix for a slow async program that "should" be concurrent.
The trap: blocking the executor¶
use std::time::{Duration, Instant};
async fn blocking_task(name: &str) -> String {
std::thread::sleep(Duration::from_millis(50)); // WRONG inside async code
format!("{name} done")
}
#[tokio::main]
async fn main() {
let start = Instant::now();
let (a, b) = tokio::join!(blocking_task("a"), blocking_task("b"));
println!("{a}, {b}");
println!("blocking join: {:?}", start.elapsed());
}
This compiles fine — std::thread::sleep isn't an .await point, so
nothing here is technically wrong from the type checker's point of view. But
the timing gives it away: it took ~106ms, not ~52ms, even though we used the
same join! as the concurrent example above. std::thread::sleep blocks
the entire OS thread the task happens to be running on, so the runtime
can't switch to the other future while it waits — the two "concurrent"
tasks end up running one after another anyway. Any blocking call (sleeping,
synchronous file I/O, a CPU-heavy loop) inside an async fn has this same
effect. The fix is either an async-aware equivalent (tokio::time::sleep,
tokio::fs) or, for unavoidable blocking work, tokio::task::spawn_blocking
to move it onto a dedicated thread pool.
tokio::spawn — running a task in the background¶
use std::time::Duration;
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
for i in 1..=3 {
tokio::time::sleep(Duration::from_millis(10)).await;
println!("spawned: {i}");
}
"spawned task result"
});
println!("main continues");
let result = handle.await.unwrap();
println!("{result}");
}
tokio::spawn hands the future to the runtime immediately and returns a
JoinHandle, much like thread::spawn returns a JoinHandle in Module 01
— and just like that thread version, the spawned future must be 'static
(own everything it touches, typically via move) because the runtime may
run it long after the calling scope ends. Awaiting the handle blocks this
task until the spawned one finishes and yields a Result (Err if the
spawned task panicked), mirroring .join().unwrap() almost exactly.
The Rust-specific trap: cancellation¶
This one has no analogue in synchronous Rust. Dropping a future stops it wherever it happens to be paused — there's no "finally" block, no guarantee the rest of the function body ever runs:
use std::time::Duration;
async fn slow_write() {
for step in 1..=5 {
tokio::time::sleep(Duration::from_millis(20)).await;
println!(" slow_write: step {step} committed");
}
println!(" slow_write: finished cleanly");
}
#[tokio::main]
async fn main() {
println!("racing slow_write against a 50ms timeout");
tokio::select! {
_ = slow_write() => {
println!("slow_write won the race");
}
_ = tokio::time::sleep(Duration::from_millis(50)) => {
println!("timeout won the race -- slow_write is dropped here, mid-step");
}
}
println!("main exits");
}
racing slow_write against a 50ms timeout
slow_write: step 1 committed
slow_write: step 2 committed
timeout won the race -- slow_write is dropped here, mid-step
main exits
tokio::select! runs both branches concurrently and takes whichever
finishes first, dropping the other. Here the timeout wins after 50ms, and
slow_write — mid-loop, having only printed steps 1 and 2 — is simply
dropped. Its "step 3" print and its "finished cleanly" line never happen; no
panic, no error, the code after the cancelled point silently never runs.
This is why async code that must clean up after itself (releasing a lock,
finishing a partial write, decrementing a counter) needs to structure that
cleanup so it survives being dropped mid-await — typically via a type whose
Drop impl does the cleanup synchronously, since Drop::drop cannot itself
be async. Reaching for tokio::spawn plus JoinHandle::abort() when you
need cancellation with an explicit signal, instead of implicit
drop-on-select, is often clearer than relying on this behavior by accident.
How It Actually Works¶
An async fn is not a thread and doesn't run anything by itself — calling
it just constructs a value: the compiler desugars the function body into an
anonymous state machine implementing the Future trait, whose single
required method is fn poll(self: Pin<&mut Self>, cx: &mut Context) ->
Poll<Self::Output>. Every .await point becomes a state in that machine —
the local variables that are still "alive" across an await get stored as
fields of the generated struct so they survive being paused. Calling poll
resumes execution from wherever it last returned Poll::Pending; nothing
runs until an executor (like Tokio's runtime) actually calls poll
repeatedly, which is why an async fn call with no .awaiting runtime
attached does literally nothing.
This state machine is exactly why cancellation looks the way it does above:
"dropping a future" is nothing more exotic than dropping that generated
struct, and Rust's Drop runs on any value going out of scope regardless of
what kind of value it is — a Future's state machine gets no special
treatment. Whatever local state was captured in the current state's fields
gets dropped in place, but any code after the paused .await point that
hasn't executed yet simply never becomes part of any live state to run —
it's not skipped by a control-flow decision, it's just unreachable code in
a state machine that stopped advancing. Pin exists because these
generated structs are often self-referential (a reference into one
field pointing at another field of the same struct, to represent borrows
that live across an await point) — moving such a struct in memory would
invalidate that internal pointer, so Pin is the compiler's way of
guaranteeing the struct's address is fixed for as long as those internal
references need it to be.
Cheat sheet¶
| Tool | Use when |
|---|---|
async fn |
Declare a function whose body can pause at .await points |
.await |
Poll a future, yielding control back to the runtime while it waits |
#[tokio::main] |
Set up a tokio runtime and run an async main |
tokio::join!(a, b) |
Run multiple futures concurrently, wait for all of them |
tokio::spawn(fut) |
Hand a 'static future to the runtime to run in the background |
handle.await |
Wait for a spawned task, getting a Result (Err on panic) |
tokio::select! |
Race futures; the first to finish wins, the rest are dropped |
tokio::time::sleep |
Async-friendly sleep (unlike std::thread::sleep, doesn't block the executor) |
tokio::task::spawn_blocking |
Move genuinely blocking work off the async executor |
Exercise¶
Write an async function download(id: u32, ms: u64) -> String that sleeps
for ms milliseconds (via tokio::time::sleep) and returns a string like
"file {id} ready". In main, use tokio::join! to run three downloads
with different delays (e.g. 30ms, 10ms, 20ms) concurrently and print all
three results together with the total elapsed time (it should be close to
the longest individual delay, not the sum). Then wrap one of the downloads
in a tokio::select! against a tokio::time::sleep timeout shorter than
its delay, and print which branch won — confirm the download's "ready"
message never prints when the timeout wins.