30. Async Rust: futures, poll, and why async exists
30.1 Why: the cost of a thread per connection
A server that spawns a thread per connection pays a few KiB to MiB of stack
and a kernel context switch per connection. At 10,000 mostly idle
connections, the threads cost far more than the work. The kernel's answer
is readiness notification, epoll on Linux and kqueue on macOS: one
thread asks "which of these 10,000 sockets is ready?" and handles only those.
The C guide's select() chat server
does this by hand. Go hides it inside the runtime's netpoller
(OS Ch 78).
Rust exposes it as async/.await, with no built-in runtime. You pick one,
usually Tokio.
30.2 A Future is a state machine you poll
// fragment: the real trait in std::future
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> { Ready(T), Pending }
async fn fetch() -> Stringdoes not run when you call it. It returns a future, a value that will produce aStringwhen it is polled to completion..awaitpolls the inner future. If it returnsPending, the current future also returnsPendingand gives the thread back to the executor.- When the socket becomes ready, the reactor (epoll/kqueue) calls the
Wakerfromcx. The executor then polls that task again. - The compiler turns each
async fninto an enum, with one variant per.awaitpoint holding the variables that are live there. Ch 59 shows the generated shape and builds an executor.
use std::time::{Duration, Instant};
async fn step(name: &str, ms: u64) -> String {
tokio::time::sleep(Duration::from_millis(ms)).await; // yields; does not block the thread
format!("{name} done")
}
#[tokio::main]
async fn main() {
let t = Instant::now();
let f = step("lazy", 10); // nothing has happened yet
println!("created future after {:?}", t.elapsed());
println!("{}", f.await); // now it runs
let t = Instant::now();
let (a, b, c) = tokio::join!(step("a", 100), step("b", 100), step("c", 100));
println!("{a}, {b}, {c} in {:?}", t.elapsed()); // ~100ms, not 300ms: concurrent on ONE task
}
30.3 Go goroutines vs Rust async
| Go goroutine | Rust async task | |
|---|---|---|
| Runtime | built in, always present | a library you choose (Tokio, smol, embassy for embedded) |
| Stack | growable stack per goroutine | no stack: the future stores exactly the state it needs across .await points |
| Preemption | yes (async preemption since Go 1.14) | cooperative: a task runs until it hits .await |
| Blocking call inside | fine, the runtime adds threads | stalls a worker thread: use spawn_blocking |
| Function color | none; any function can block | async fn can only be awaited from async code |
| Cancellation | context.Context, by convention |
drop the future; it stops at its next .await |
The cost of async Rust is the "function color" split and the cooperative model. The benefit is that a task is a small heap object of known size, often a few hundred bytes, with no stack or GC. One process can hold a million of them.