Chapter 33: Async/Await and Futures
Prerequisites
You will understand
- How
async fncompiles to a state machine - The Future trait and polling model
join!vstokio::spawnfor concurrency
Reading time
An `async fn` Becomes a Pollable Future
`join!` vs `spawn`
`.await` Yields Cooperatively
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"),
);
}
Future, not the value. Body doesn't run until polled.
In Your Language: Async Models
#![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
}
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/awaittransforms functions into pollable state machines, and why calling anasync fndoes 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 fncompiles 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
Sendand 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:
load()is transformed into a type that implementsFuture<Output = String>.- The body becomes states in that generated future.
#[tokio::main]creates a runtime and enters it.load().awaitpolls the future until it yieldsPoll::Ready(String).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
Sendrequirements
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 fncall returns a future; it does not run to completion by itself. .awaitis a cooperative suspension point, not an OS-thread block.join!means “run together and wait for all,” whiletokio::spawnmeans “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
| Question | Answer |
|---|---|
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
| Need | Tool | Why |
|---|---|---|
| Wait for one async operation | .await | Cooperative suspension |
| Run several futures and wait for all | join! | No detached background task needed |
| Start a background task | tokio::spawn | Runtime-managed task |
| Run blocking CPU or sync I/O | spawn_blocking or threads | Protect the executor from starvation |
| Add timers | tokio::time | Runtime-aware sleeping and intervals |
Chapter Resources
- Official Source: Asynchronous Programming in Rust (The Async Book)
- Tokio Docs: Tokio Tutorial: Spawning
- Under the Hood: Without Boats: The Waker API