Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 33: Async/Await and Futures

You will understand

  • How async fn compiles to a state machine
  • The Future trait and polling model
  • join! vs tokio::spawn for concurrency

Reading time

45 min
+ 25 min exercises
State Machine

An `async fn` Becomes a Pollable Future

async fn call creates future Future state machine with paused internal state Executor polls when progress might be possible Pending ↺ Ready
Concurrency Shape

`join!` vs `spawn`

`join!` fut A fut B same task, same parent `spawn` rt A B runtime owns scheduled tasks
Await Semantics

`.await` Yields Cooperatively

task A task B run until await task B runs resume Pending → executor switches work
async fn fetch_data(url: &str) -> String {
    let resp = reqwest::get(url).await?;
    resp.text().await?
}

#[tokio::main]
async fn main() {
    let (a, b) = tokio::join!(
        fetch_data("https://api.a.com"),
        fetch_data("https://api.b.com"),
    );
}
async fn Returns a Future, not the value. Body doesn't run until polled.
.await Yield point: suspends this future, lets the executor poll others. Resumes when I/O completes.
join! concurrency Both futures polled concurrently on one thread. Not parallel — cooperative multitasking.

In Your Language: Async Models

Rust — zero-cost async
#![allow(unused)]
fn main() {
async fn fetch(url: &str) -> String {
    reqwest::get(url).await?.text().await?
}
// Future is a state machine — no heap alloc per task
// Must choose runtime: tokio, async-std, smol
}
Python — asyncio
async def fetch(url: str) -> str:
    async with aiohttp.get(url) as r:
        return await r.text()
# Coroutine objects heap-allocated
# Single built-in event loop (asyncio)
# GIL limits true parallelism

Step 1 - The Problem

Learning Objective By the end of this chapter, you should be able to explain how async/await transforms functions into pollable state machines, and why calling an async fn does not start execution immediately.

Threads are powerful, but they are an expensive unit for waiting on I/O.

A web server that handles ten thousand mostly-idle connections does not want ten thousand blocked OS threads if it can avoid it. Each thread carries stack memory, scheduler cost, and coordination overhead. The problem is not that threads are bad. The problem is that “waiting” is too expensive when the unit of waiting is an OS thread.

Other ecosystems solve this by using:

  • event loops and callbacks
  • green threads managed by a runtime
  • goroutines plus a scheduler

Those work, but they often hide memory, scheduling, or cancellation costs behind a runtime or garbage collector.

Step 2 - Rust’s Design Decision

Rust chose a different model:

  • async fn compiles into a state machine
  • that state machine implements Future
  • an executor polls the future when it can make progress
  • there is no built-in runtime in the language

This design keeps async as a library-level ecosystem choice rather than a hard-coded runtime commitment.

Rust accepted:

  • steeper learning curve
  • explicit runtime choice
  • Send and pinning complexity at task boundaries

Rust refused:

  • mandatory GC
  • hidden heap traffic as the price of async
  • a single scheduler model forced on CLI, server, embedded, and desktop code alike

Step 3 - The Mental Model

Plain English rule: calling an async fn creates a future, but it does not run the body to completion right away.

A future is a paused computation that can be resumed later by polling.

The key reframe is this:

  • threads are scheduled by the OS
  • futures are scheduled cooperatively by an executor

Step 4 - Minimal Code Example

async fn answer() -> u32 {
    42
}

fn main() {
    let future = answer();
    drop(future);
}

This program does not print anything and does not evaluate the 42 in a useful way. The point is structural: calling answer() builds a future value. Nothing drives it.

Step 5 - Line-by-Line Compiler Walkthrough

Take this version:

async fn load() -> String {
    String::from("done")
}

#[tokio::main]
async fn main() {
    let result = load().await;
    println!("{result}");
}

What the compiler sees conceptually:

  1. load() is transformed into a type that implements Future<Output = String>.
  2. The body becomes states in that generated future.
  3. #[tokio::main] creates a runtime and enters it.
  4. load().await polls the future until it yields Poll::Ready(String).
  5. println! runs with the produced value.

What .await means is often misunderstood. It does not “spawn a thread and wait.” It asks the current async task to suspend until the future is ready, allowing the executor to run something else in the meantime.

The central invariant is:

the executor may pause and resume the computation at each .await, but the future’s internal state must remain valid across those pauses.

Step 6 - Three-Level Explanation

An async function gives you a task-shaped value. .await is how you ask Rust to keep checking that task until it finishes.

Use async when the workload is dominated by waiting on I/O: sockets, files, timers, database round-trips, RPC calls. Do not use async because it feels modern. CPU-bound work inside async code still consumes executor time and may need spawn_blocking or dedicated threads.

Tokio dominates server-side Rust because it provides:

  • runtime
  • reactor for I/O readiness
  • scheduler
  • channels and synchronization primitives
  • timers

The Future trait is a polling interface:

#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

trait DemoFuture {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

Poll::Pending means “not ready yet, but I have registered how to wake me.” That wakeup path is how the runtime avoids busy-waiting.

Rust async is zero-cost in the sense that the state machine is concrete and optimizable. But that does not mean “free.” Futures still have size, allocation strategies still matter, and scheduler behavior still matters. Zero-cost means the abstraction does not force extra indirection beyond what the semantics require.

Executors, tokio::spawn, and join!

tokio::spawn schedules a future onto the runtime and returns a JoinHandle.

use tokio::task;

async fn work(id: u32) -> u32 {
    id * 2
}

#[tokio::main]
async fn main() {
    let a = task::spawn(work(10));
    let b = task::spawn(work(20));

    let result = a.await.unwrap() + b.await.unwrap();
    assert_eq!(result, 60);
}

Why the Send + 'static requirement often appears:

  • the runtime may move tasks between worker threads
  • the task may outlive the current stack frame

That is the same ownership story from threads, now expressed in async form.

join! is different:

#![allow(unused)]
fn main() {
let (a, b) = tokio::join!(work(10), work(20));
}

join! runs futures concurrently within the current task and waits for all of them. It does not create detached background tasks. This distinction matters in real code because it changes:

  • cancellation behavior
  • task ownership
  • error handling shape
  • Send requirements

Step 7 - Common Misconceptions

Wrong model 1: “Calling an async function starts it immediately.”

Correction: it constructs a future. Progress begins only when something polls it.

Wrong model 2: “Async makes code faster.”

Correction: async makes waiting cheaper. CPU-heavy work is not magically accelerated.

Wrong model 3: “.await blocks like a thread join.”

Correction: .await yields cooperatively so the executor can schedule other tasks.

Wrong model 4: “Tokio is Rust async.”

Correction: Tokio is the dominant runtime, not the language feature itself.

Step 8 - Real-World Pattern

Serious async Rust repositories usually separate:

  • protocol parsing
  • application logic
  • background tasks
  • shutdown and cancellation paths

In an axum or hyper service, request handlers are async because socket and database operations are mostly waiting. In tokio, spawned background tasks often own their state and communicate via channels. In observability stacks, async pipelines decouple ingestion from export with bounded buffers and backpressure.

That is the pattern to notice: async is most powerful when paired with explicit ownership boundaries and capacity boundaries.

Step 9 - Practice Block

Code Exercise

Write a Tokio program that:

  • concurrently fetches two simulated values with tokio::time::sleep
  • uses join! to wait for both
  • logs total elapsed time

Then rewrite it with sequential .await and explain the difference.

Code Reading Drill

What is concurrent here and what is not?

#![allow(unused)]
fn main() {
let a = fetch_user().await;
let b = fetch_orders().await;
}

What changes here?

#![allow(unused)]
fn main() {
let (a, b) = tokio::join!(fetch_user(), fetch_orders());
}

Spot the Bug

Why is this likely a bad design in a server?

#![allow(unused)]
fn main() {
async fn handler() {
    let mut total = 0u64;
    for i in 0..50_000_000 {
        total += i;
    }
    println!("{total}");
}
}

Refactoring Drill

Take a callback-style network flow from another language you know and redesign it as futures plus join! or spawned tasks. Explain where ownership lives.

Compiler Error Interpretation

If tokio::spawn complains that a future is not Send, translate it as: “some state captured by this task cannot safely move between runtime worker threads.”

Step 10 - Contribution Connection

After this chapter, you can start reading:

  • async handlers in web frameworks
  • background worker loops
  • retry or timeout wrappers around network calls
  • task spawning and task coordination code

Approachable PRs include:

  • replacing accidental sequential awaits with join!
  • moving CPU-heavy work off the async executor
  • clarifying task ownership and shutdown behavior in async tests

In Plain English

Async is Rust’s way of letting one thread juggle many waiting jobs without creating a new thread for each one. That matters to systems engineers because servers spend most of their time waiting on networks and disks, and waiting is exactly where wasted threads become wasted capacity.

What Invariant Is Rust Protecting Here?

A future’s state must remain valid across suspension points, and task boundaries must not capture data that can become invalid or unsafely shared.

If You Remember Only 3 Things

  • An async fn call returns a future; it does not run to completion by itself.
  • .await is a cooperative suspension point, not an OS-thread block.
  • join! means “run together and wait for all,” while tokio::spawn means “hand this task to the runtime.”

Memory Hook

An async task is a folded travel itinerary in your pocket. It is the whole trip, but you only unfold the next section when the train arrives.

Flashcard Deck

QuestionAnswer
What does calling an async fn produce?A future value representing suspended work.
What does .await do conceptually?Polls a future until ready, yielding control cooperatively when it is pending.
What problem does async primarily solve?Making I/O waiting cheaper than dedicating one OS thread per waiting operation.
Why does Rust have external runtimes instead of one built-in runtime?Different domains need different scheduling and runtime tradeoffs, and Rust avoids forcing one global model.
What does tokio::spawn usually require?A future that is Send + 'static.
What is the difference between join! and tokio::spawn?join! runs futures concurrently in the current task; spawn schedules a separate task on the runtime.
Does async help CPU-bound work by itself?No. It helps waiting-heavy work, not raw computation.
What does Poll::Pending imply besides “not ready”?The future has arranged to be woken when progress is possible.

Chapter Cheat Sheet

NeedToolWhy
Wait for one async operation.awaitCooperative suspension
Run several futures and wait for alljoin!No detached background task needed
Start a background tasktokio::spawnRuntime-managed task
Run blocking CPU or sync I/Ospawn_blocking or threadsProtect the executor from starvation
Add timerstokio::timeRuntime-aware sleeping and intervals

Chapter Resources