Level 4 · Deep Cuts

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 forBecause
Waiting (network, disk, timers, many connections)async + tokiothousands 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 scriptneithersequential 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.

Exercise

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. 🏁

🧠 The takeaway Async is for waiting on many things with few threads. Futures are lazy until awaited, 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.