Frontend Labs
⋯

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() -> String does not run when you call it. It returns a future, a value that will produce a String when it is polled to completion.
  • .await polls the inner future. If it returns Pending, the current future also returns Pending and gives the thread back to the executor.
  • When the socket becomes ready, the reactor (epoll/kqueue) calls the Waker from cx. The executor then polls that task again.
  • The compiler turns each async fn into an enum, with one variant per .await point 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.


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36