Async State Machines
Every async fn and async { ... } block in Rust is compiled into a generator-style enum state machine where each .await suspension point becomes a distinct enum variant storing only the local variables that remain live across that .await point.
How the Compiler Transforms an async fn into an Enum
Consider an async fn with two .await suspension points:
async fn workflow(id: u64) -> usize {
let step1_buf = [0_u8; 64];
let len1 = step_one(&step1_buf).await; // <--- Suspension Point #1 (`.await` 1)
let label = String::from("done");
let len2 = step_two(&label).await; // <--- Suspension Point #2 (`.await` 2)
len1 + len2 + (id as usize)
}
The compiler lowers workflow(id) into an enum with an initial Unresumed state, one suspended state per .await point, and terminal Returned / Panicked states:
Compiler-Generated State Machine for `workflow(id: u64)`:
enum WorkflowFuture {
// 0. Created by `workflow(id)`, before first `.poll()`:
Unresumed { id: u64 },
// 1. Suspended at `step_one(&step1_buf).await`:
Suspend1 {
id: u64,
step1_buf: [u8; 64],
fut1: StepOneFuture<'_>, // Borrows `self.step1_buf`!
},
// 2. Suspended at `step_two(&label).await` (`step1_buf` is no longer live!):
Suspend2 {
id: u64,
len1: usize,
label: String,
fut2: StepTwoFuture<'_>, // Borrows `self.label`!
},
// 3. Terminal states:
Returned,
Panicked,
}
Controlling Future Size (size_of_val(&fut)) by Scoping Temporary Buffers
Because an enum's total byte size is determined by its largest variant, holding a large stack buffer ([u8; 8192]) in scope across an .await point forces that 8,192-byte array to be stored inside the Future enum!
If the buffer is only needed between two .await points (and not across an .await point), dropping it inside an inner block { ... } before the .await keeps it on the ephemeral CPU stack during poll and excludes it from the Future struct completely:
use std::future::ready;
async fn bloated_future() -> u8 {
// `scratch` stays in scope across `ready(1).await`, inflating the Future's size!
let scratch = [1_u8; 1024];
let x = ready(1_u8).await;
scratch[0] + x
}
async fn compact_future() -> u8 {
// `scratch` is scoped inside `{ ... }` and dropped BEFORE `ready(1).await`,
// so it is NOT stored inside `compact_future`'s state machine!
let first_byte = {
let scratch = [1_u8; 1024];
scratch[0]
};
let x = ready(1_u8).await;
first_byte + x
}
fn main() {
let f_bloated = bloated_future();
let f_compact = compact_future();
println!("size of bloated_future: {} bytes", size_of_val(&f_bloated));
println!("size of compact_future: {} bytes", size_of_val(&f_compact));
}
size of bloated_future: 1026 bytes
size of compact_future: 2 bytes