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

Lesson 2: State Machines

Real-life analogy: the vending machine

A vending machine is a state machine:

┌─────────┐  insert coin    ┌──────────┐   press button  ┌────────────┐
│  Idle   │ ──────────────► │ HasCoin  │ ──────────────► │ Dispensing │
│         │                 │          │                 │            │
└─────────┘                 └──────────┘                 └─────┬──────┘
     ▲                                                         │
     │                    item drops out                       │
     └─────────────────────────────────────────────────────────┘

At any moment, the machine is in one state. An event causes a transition to the next state. It never skips states or goes backwards unexpectedly.

async fn works the same way. Each .await is a state transition. The compiler turns your sequential code into an enum where each variant is a state.

What async fn compiles to

When you write:

#![allow(unused)]
fn main() {
async fn fetch_data() -> String {
    let url = build_url().await;         // await #1
    let response = http_get(url).await;  // await #2
    response.body
}
}

The compiler generates something like:

#![allow(unused)]
fn main() {
enum FetchData {
    // State 0: haven't started yet
    Start,
    // State 1: waiting for build_url() to complete
    // Holds the sub-future for build_url
    WaitingForUrl {
        build_url_future: BuildUrlFuture,
    },
    // State 2: got the url, waiting for http_get() to complete
    // Holds `url` (needed later) and the sub-future for http_get
    WaitingForResponse {
        http_get_future: HttpGetFuture,
    },
    // State 3: done
    Done,
}
}

And implements Future for it:

#![allow(unused)]
fn main() {
impl Future for FetchData {
    type Output = String;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<String> {
        loop {
            match self.as_mut().get_mut() {
                FetchData::Start => {
                    // Create the sub-future for build_url
                    *self = FetchData::WaitingForUrl {
                        build_url_future: build_url(),
                    };
                }
                FetchData::WaitingForUrl { build_url_future } => {
                    match Pin::new(build_url_future).poll(cx) {
                        Poll::Pending => return Poll::Pending,  // not ready, yield
                        Poll::Ready(url) => {
                            // Transition to next state
                            *self = FetchData::WaitingForResponse {
                                http_get_future: http_get(url),
                            };
                        }
                    }
                }
                FetchData::WaitingForResponse { http_get_future } => {
                    match Pin::new(http_get_future).poll(cx) {
                        Poll::Pending => return Poll::Pending,
                        Poll::Ready(response) => {
                            *self = FetchData::Done;
                            return Poll::Ready(response.body);
                        }
                    }
                }
                FetchData::Done => panic!("polled after completion"),
            }
        }
    }
}
}

Visualizing the state transitions

poll #1:  Start → WaitingForUrl → poll build_url → Pending
                                                     ↑ return to executor

poll #2:  WaitingForUrl → poll build_url → Ready(url)
          → transition to WaitingForResponse
          → poll http_get → Pending
                              ↑ return to executor

poll #3:  WaitingForResponse → poll http_get → Ready(response)
          → transition to Done
          → return Ready(response.body)

Each poll() call resumes exactly where the last one left off. No stack needed — the enum variant holds all the state.

Why this matters

Memory: enum vs thread stack

Thread:
┌────────────────────┐
│  Stack: 8 MB       │  Fixed allocation, mostly empty.
│  (99% wasted)      │  Every thread gets this whether it
│                    │  needs it or not.
└────────────────────┘

Async state machine:
┌────────────────────┐
│  Enum: ~100 bytes  │  Size = largest variant.
│  (nothing wasted)  │  Only stores what the current
│                    │  state actually needs.
└────────────────────┘

10,000 tasks:
  Threads:    10,000 × 8 MB   = 80 GB
  Async:      10,000 × 100 B  = 1 MB

The compiler does the hard work

Writing state machines by hand is tedious and error-prone. async/.await gives you:

  • Readability of sequential code
  • Performance of hand-written state machines
  • Safety guaranteed by the compiler

A simpler example: add_slowly

Let’s desugar a simple async function step by step:

#![allow(unused)]
fn main() {
// The async version (what you write):
async fn add_slowly(a: u32, b: u32) -> u32 {
    let x = yield_once(a).await;   // yields once, returns a
    let y = yield_once(b).await;   // yields once, returns b
    x + y
}
}

Where yield_once is a future that returns Pending once, then Ready(value):

#![allow(unused)]
fn main() {
struct YieldOnce<T> {
    value: Option<T>,
    yielded: bool,
}
}

The state machine for add_slowly:

           ┌──────────────────┐
           │   State: Start   │  holds: a, b
           │                  │
           └────────┬─────────┘
                    │ create YieldOnce(a), poll it → Pending
                    ▼
           ┌──────────────────┐
           │ State: YieldingA │  holds: b, yield_future_a
           │                  │
           └────────┬─────────┘
                    │ poll yield_future_a → Ready(x)
                    │ create YieldOnce(b), poll it → Pending
                    ▼
           ┌──────────────────┐
           │ State: YieldingB │  holds: x, yield_future_b
           │                  │
           └────────┬─────────┘
                    │ poll yield_future_b → Ready(y)
                    │ compute x + y
                    ▼
           ┌──────────────────┐
           │   State: Done    │  return Ready(x + y)
           └──────────────────┘

Notice: each state only holds what’s needed going forward. State YieldingB holds x (needed for the final addition) but NOT a (already consumed).

See what the compiler generates

# Install cargo-expand
cargo install cargo-expand

# Write a simple async fn and expand it
cargo expand --bin 2-state-machines 2>/dev/null | head -100

The output is verbose but you’ll see an enum with variants matching the await points.

Exercises

Exercise 1: YieldOnce future

Implement YieldOnce<T> — a future that returns Pending on the first poll (and wakes), then Ready(value) on the second poll. This simulates one async operation completing.

Exercise 2: Manual AddSlowly state machine

Implement AddSlowly as an enum with the states shown above. Implement Future for it by hand — match on the current state, poll sub-futures, transition states.

Run it with poll_to_completion from Lesson 1 and verify it returns the correct sum.

Exercise 3: Async version comparison

Write the same logic as async fn add_slowly using actual async/.await. Run both (your hand-written state machine and the async version). Verify they produce the same result.

Exercise 4: Future sizes

Print the size of various futures:

#![allow(unused)]
fn main() {
async fn no_awaits() -> u32 { 42 }
async fn one_await() -> u32 { yield_once(42).await }
async fn holds_big_data() -> u32 {
    let buf = [0u8; 1024];
    yield_once(0).await;
    buf[0] as u32
}
}

Use std::mem::size_of_val(&future). Compare the sizes — the future that holds [u8; 1024] across an await will be ~1KB larger.