What Actually Makes a Rust Async Fn Bigger Across an Await
The common belief is that every .await you add makes the future bigger. It doesn't, quite. A future grows with whatever is still alive when it pauses, and the number of awaits is mostly irrelevant. Once you see that, the odd 20 KiB future in your profiler stops being mysterious.
An async fn is an enum in disguise
The compiler turns an async fn into a state machine: one variant for the start, one for each await point, plus "returned" and "panicked". The future's size is the size of the largest variant, plus a discriminant.
Each await-point variant stores the locals that are used again after that point, and the inner future being awaited. Anything not needed after the pause is not stored. Variants share the same memory, so ten awaits in a row do not cost ten times as much.
Measuring it
You can see this with std::mem::size_of_val on a future you have created but not polled. No runtime is needed.
use std::future::ready;
use std::hint::black_box;
use std::mem::size_of_val;
async fn many_awaits() {
ready(()).await;
ready(()).await;
ready(()).await;
ready(()).await;
}
async fn buffer_across_await() {
let buf = [0u8; 1024];
ready(()).await;
black_box(buf[0]);
}
async fn buffer_dropped_first() {
{
let buf = [0u8; 1024];
black_box(buf[0]);
}
ready(()).await;
}
fn main() {
println!("many awaits: {}", size_of_val(&many_awaits()));
println!("across await: {}", size_of_val(&buffer_across_await()));
println!("scoped first: {}", size_of_val(&buffer_dropped_first()));
}
On current stable, the first and third come out as a byte or two. The second is a little over 1024. Four awaits cost nothing; one array held over a single await costs a kilobyte.
The rule: live across a suspension point
A local lands in the future if it is alive across an await. That includes things you didn't name:
- arguments to the function, which are captured when the future is created
- a
letbinding used after the await - a temporary in the same statement as the await, such as
foo(&make_buf()).await - the inner future itself, stored inline while you wait on it
That last one is where sizes snowball. If a() awaits b(), which awaits c(), then c's whole state sits inside b's, which sits inside a's. A big buffer at the bottom is paid for at every level above it.
Quick detour: why recursion needs Box::pin
That inline rule explains a classic error. A recursive async fn would contain itself, so its size would be infinite, and the compiler refuses. Boxing the recursive call (Box::pin(recurse()).await) puts the inner future on the heap, so the outer one only holds a pointer. It is the same rule, seen from the other side.
Shrinking a fat future
Most fixes are about making locals die before the pause.
- Scope the big value in a block, as in
buffer_dropped_firstabove, so it is gone before the await. - Call
drop(x)early only if you actually need the value gone; moving a value out is also enough to stop it being live. - Allocate large buffers with
VecorBoxinstead of a big array on the stack, so only a pointer crosses the await. - Box a rarely used, expensive branch with
Box::pin(...)so every caller doesn't pay for it. - Pass references to large data rather than moving it into the future.
One caveat: the compiler's analysis of what is live is conservative in places, and it has improved between releases. Treat the exact numbers as something to measure on your toolchain, not as a promise.
Why you'd care
Futures are moved around. Spawning one onto an executor copies it into a task allocation, select! and join! keep several side by side, and a big future sitting on the stack can contribute to stack overflows on a deep call chain. Large futures are also just slower to move.
To find offenders, nightly offers -Zprint-type-sizes, which lists every type with its layout. On stable, Clippy has a pedantic lint, large_futures, that warns when a future passes a size threshold you can configure.
If a future is surprisingly large, bisect it: comment out the awaits one by one and watch which removal drops the size. It will be the one with a big local living over it, and the fix is usually three lines and a pair of braces.