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.
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.
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
| Rule | What to do |
|---|---|
| Do not block the runtime | CPU work and blocking I/O go on a blocking pool, or stay on the operating-system threads from the previous lesson. |
| Cancellation | A task can be cancelled at an await. Cleanup that must run belongs in Drop, not only in code after the await. |
Send | A spawned task must be Send. It cannot hold an Rc or a lock guard that is not Send. |
| Structured concurrency | A parent waits for the tasks it spawned. A detached task is an orphan you can no longer cancel with the request. |
| Backpressure | Put a timeout on I/O and a bound on how much work you accept. |
| Two kinds of failure | A task’s own failure is a Result. A join failure is a different error. Do not flatten them into one panic. |
axum, hyper, tonic, sqlx, and tracing sit on this model in real services. The names are the ecosystem. The rule is the parent task.
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.
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());
}