Async and Await

Futures stay lazy until a runtime polls them. Timeouts, cancellation, streams, and select are how that scales.

Async and await

Threads were for CPU work and blocking calls. Async is for many I/O waits on a smaller pool of threads. Learn why a future does nothing until it is polled, then the rules that keep a runtime healthy, then streams and backpressure. The standard library has no server runtime, so the examples stay on the shape rather than pretending to start Tokio.

The future

A synchronous read holds a thread while the socket is idle. An async read starts the wait, yields, and the runtime runs another task until the socket is ready. async fn add(a: i32, b: i32) -> i32 returns a future. It does not add until something polls it: await, or spawn. await is only legal inside async.

read socket, thread idlesyncstart readasyncother tasksruntimeresumeready
StatusA blocking call occupies the thread

thread::sleep and a blocking recv do this. Inside an async task they stall every other task on that worker.

A production main is often an async fn main marked with the tokio::main attribute, then await on the client. tokio::spawn needs a move future that is Send. tokio::time::sleep yields. thread::sleep does not. A Tokio mutex should not be held across await if you can drop the guard first. select! races several futures. It appears again with streams.

01_async_mental_model.rsRust
fn main() {
    println!("async fn returns a future and does no work until it is awaited");
    println!("a runtime polls that future; thread::sleep must not run inside it");
}

Production rules

RuleWhat to do
Do not block the runtimeCPU work and blocking I/O go on a blocking pool, or stay on the operating-system threads from the previous lesson.
CancellationA task can be cancelled at an await. Cleanup that must run belongs in Drop, not only in code after the await.
SendA spawned task must be Send. It cannot hold an Rc or a lock guard that is not Send.
Structured concurrencyA parent waits for the tasks it spawned. A detached task is an orphan you can no longer cancel with the request.
BackpressurePut a timeout on I/O and a bound on how much work you accept.
Two kinds of failureA task’s own failure is a Result. A join failure is a different error. Do not flatten them into one panic.
requestparent taskqueryawaitupstreamtimeout
StatusThe parent owns the work

axum, hyper, tonic, sqlx, and tracing sit on this model in real services. The names are the ecosystem. The rule is the parent task.

02_async_production_rules.rsRust
fn main() {
    println!("do not block the runtime");
    println!("cancellation can stop a task at an await");
    println!("spawned tasks must be Send");
    println!("timeouts, limits, and Result are part of the task");
}

Streams and select

A stream is the async iterator: the next item might not be ready. select! waits on several futures and takes the one that becomes ready first. Fan-out is one input becoming many tasks. Fan-in is many results coming back to one consumer. A bounded channel is backpressure: try_send fails when the buffer is full, so the producer slows down. Batching, taking a few messages at a time, is how you amortize a flush. The example uses a bounded standard channel so you can see a full buffer without a runtime.

03_streams_and_select.rsRust
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::sync_channel(3);
    for n in 0..5 {
        if tx.try_send(n).is_err() {
            break;
        }
    }
    let mut batch = Vec::new();
    while batch.len() < 3 {
        match rx.try_recv() {
            Ok(n) => batch.push(n),
            Err(_) => break,
        }
    }
    println!("{}", batch.len());
}