Async

async in Rust does not run anything. It just transforms we code into a state machine.

async fn fetch_user(id: u32) -> String {
    let data = read_from_db(id).await;
    format!("User: {}", data)
}

When we call fetch_user(42), no work happens. we get back a value of type impl Future<Output = String>. It’s a struct that knows how to make progress when asked.

Compare this to JavaScript, where calling an async function immediately starts executing it. Rust is lazy — futures do nothing until polled.

The Future Trait

Under the hood, every async function compiles down to something that implements:

trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}
 
enum Poll<T> {
    Ready(T),
    Pending,
}

The contract: someone calls poll(). The future either:

  • Returns Poll::Ready(value) — done, here’s the result.
  • Returns Poll::Pending — not done, but I’ve registered a waker so we ll be notified when it’s worth polling me again.

That waker is the magic. When a future returns Pending, it stashes a Waker somewhere (e.g., the OS epoll system, a timer wheel). When the I/O is ready, that subsystem calls waker.wake(), which tells the runtime “this task is ready to make progress again.”

When the compiler sees:

async fn example() -> u32 {
    let a = step_one().await;  // suspend point 1
    let b = step_two(a).await; // suspend point 2
    a + b
}

It generates roughly:

enum ExampleState {
    Start,
    WaitingOnStepOne(StepOneFuture),
    WaitingOnStepTwo(StepTwoFuture, /* a */ u32),
    Done,
}

Each .await is a possible suspension point. The state machine remembers exactly where it left off. When poll is called again, it resumes from that state.

This is why async functions can have local variables that “survive” across .await points without heap allocation — they’re fields in the state machine struct.

Why Rust Needs a Runtime

A Future just sits there. Someone has to:

  1. Call poll() on it the first time.
  2. Listen for wakers and re-poll ready tasks.
  3. Talk to the OS for I/O readiness (epoll on Linux, kqueue on macOS, IOCP on Windows).
  4. Schedule tasks across threads.

That’s a runtime. Languages like Go bake one in. Rust deliberately doesn’t — it would force a single design choice on everyone (embedded folks, web folks, and game devs all want different things). So the standard library only defines Future, Poll, Waker, Pin, etc. The actual executing is left to libraries.

Tokio

Tokio is the dominant async runtime. It provides:

  • An executor — the loop that polls futures and schedules tasks across a thread pool (work-stealing, by default).
  • A reactor — interfaces with the OS event notification system to wake tasks when I/O is ready.
  • Async versions of standard library typestokio::fs::File, tokio::net::TcpStream, tokio::sync::Mutex, channels, timers, etc. These are async-aware: they return Pending and register wakers instead of blocking.

Minimal Tokio program:

#[tokio::main]
async fn main() {
    let result = tokio::spawn(async {
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
        42
    }).await.unwrap();
    
    println!("{}", result);
}

The #[tokio::main] macro expands roughly to:

fn main() {
    tokio::runtime::Runtime::new().unwrap().block_on(async {
        // we  code
    });
}

block_on is the entry point — it polls the top-level future on the current thread until it completes, driving everything beneath it.

tokio::spawn hands a future to the runtime to run concurrently — like spawning a thread, but it’s a task on the existing thread pool.

Concurrency Patterns

Sequential (default):

let a = fetch(1).await;
let b = fetch(2).await; // waits for a to finish first

Concurrent with join!:

let (a, b) = tokio::join!(fetch(1), fetch(2));
// both run concurrently on the same task

Race with select!:

tokio::select! {
    result = fetch(1) => println!("got {result}"),
    _ = tokio::time::sleep(Duration::from_secs(5)) => println!("timeout"),
}

Parallel tasks via spawn:

let h1 = tokio::spawn(fetch(1));
let h2 = tokio::spawn(fetch(2));
let (a, b) = (h1.await.unwrap(), h2.await.unwrap());
// these can run on different threads

Edition

An edition = a snapshot of language rules at a point in time. project chooses one edition. Compiler adapts parsing + meaning accordingly.

Available editions

  • 2015 → original Rust
  • 2018 → major ergonomics improvements
  • 2021 → refinements, stricter rules
  • 2024 → latest (more consistency, cleanup)
# Cargo.toml
[package]
edition = "2021"