What Actually Happens When You .await in Rust: Futures, Pin, Wakers, and the Executor
You type .await. The code pauses. Later it resumes with a value. Magic? No. It's a precise chain of mechanical steps: a state machine gets pinned in memory, an executor calls poll, a waker gets registered, and the runtime sleeps until something wakes it up.
Most Rust developers use async daily but treat .await as syntax sugar. It's not. Understanding what actually happens changes how you debug hangs, why Pin exists, and why forgetting to wake a task is a bug that silently hangs forever.
The Big Picture
When you write:
async fn fetch_data() -> Result<Data, Error> {
let response = http_client.get(url).await?; // <-- What happens HERE?
Ok(response.json().await?)
}
The compiler transforms this into a state machine — an enum where each variant represents a pause point (every .await). Calling the function doesn't run it. It returns a future: an inert value implementing Future. Nothing executes until something calls poll on that future.
The Future trait is the contract:
use std::pin::Pin;
use std::task::{Context, Poll};
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
Three things stand out immediately:
-
Pin<&mut Self>— the future must not move in memory -
Context— carries aWakerfor scheduling -
Poll— eitherReady(value)orPending(come back later)
Step 1: The Compiler Builds a State Machine
Every async fn or async {} block becomes an anonymous enum. Each .await becomes a state transition.
async fn example() {
let data = vec![1, 2, 3];
let reference = &data[0]; // borrows `data`
some_io().await; // <-- PAUSE POINT
println!("{}", reference); // uses reference AFTER await
}
The compiler generates roughly:
enum ExampleFuture {
State0 { data: Vec<i32> }, // before first .await
State1 { data: Vec<i32>, reference: *const i32 }, // after .await, reference points INTO data
Complete,
}
Notice the raw pointer *const i32? The reference &data[0] becomes a raw pointer because the state machine must store it across the .await boundary. If this enum moves in memory, that pointer dangles. This is why Pin exists.
Step 2: Pin — Preventing Moves That Break Self-References
Pin<P> is a wrapper around a pointer P that promises: the pointee will never move again.
use std::pin::Pin;
let mut future = example(); // returns the state machine (unpinned)
let pinned: Pin<&mut ExampleFuture> = Pin::new(&mut future); // NOW it's pinned
Once pinned, you cannot get a &mut T to the inner value (which would allow mem::swap or moving it). You only get &T — read-only access. The future is now frozen in place.
Why Pin<&mut Self> in poll? Because the executor must pin the future before polling it. The signature forces this: you literally cannot call poll without pinning first.
Two ways to pin in practice:
// Heap pin — future lives on heap, Box moves but heap allocation stays put
let pinned = Box::pin(async { 42 });
// Stack pin — future stays on stack, Pin<&mut> wrapper can move but underlying future doesn't
tokio::pin!(my_future);
Most types implement Unpin (they're safe to move). Compiler-generated futures from async fn are !Unpin — they must be pinned.
Step 3: The Executor Calls poll
An executor is just a loop that calls poll on futures that are ready to make progress.
// Conceptual executor loop (simplified)
fn executor_loop(tasks: &mut TaskQueue) {
loop {
// 1. Poll all woken tasks
while let Some(task) = tasks.get_woken_task() {
match task.poll() {
Poll::Ready(result) => task.complete(result),
Poll::Pending => { /* stays in queue, waiting for wake */ }
}
}
// 2. Sleep until something wakes us (epoll_wait, io_uring, etc.)
tasks.wait_for_events();
}
}
When you .await a future, the surrounding async function's state machine calls poll on the inner future. This chains: outer poll → inner poll → inner-inner poll... until something returns Pending.
Step 4: Pending Means "Register My Waker and Come Back"
When poll returns Poll::Pending, the future must have registered its waker with whatever it's waiting on (a socket, a timer, a channel). If it doesn't, the task hangs forever — no one will ever wake it.
impl Future for MyTcpRead {
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<Bytes, Error>> {
// Check if data is already available
if let Some(data) = self.try_read() {
return Poll::Ready(Ok(data));
}
// NOT ready — store the waker so the socket can wake us
self.waker_slot = Some(cx.waker().clone());
// Re-check after storing (race condition protection!)
if let Some(data) = self.try_read() {
Poll::Ready(Ok(data))
} else {
Poll::Pending
}
}
}
Critical rule: Always re-check the condition after storing the waker. The data might have arrived between your first check and storing the waker. This is the "double-check pattern" — skip it and you get a race where the task never wakes.
Step 5: The Waker — The Interrupt Mechanism
A Waker is a handle that lets an I/O source say "hey, this task can make progress now." It's the bridge between the kernel/IO system and the executor.
// Inside an I/O driver (simplified)
fn on_socket_readable(socket: &TcpSocket, waker: &Waker) {
// Data arrived — wake the task
waker.wake(); // signals executor: "put this task back in the ready queue"
}
The executor receives this wake notification (via a channel, eventfd, or similar) and moves the task to its ready queue. Next loop iteration, poll gets called again.
Spurious wakes are normal. A future might be polled even though its I/O isn't ready. That's why the double-check pattern matters — never assume poll being called means readiness.
Step 6: .await Desugars to a Poll Loop
The Rust Reference shows the approximate desugaring:
match operand.into_future() {
mut pinned => loop {
let mut pin = unsafe { Pin::new_unchecked(&mut pinned) };
match Pin::future::poll(Pin::borrow(&mut pin), &mut current_context) {
Poll::Ready(r) => break r,
Poll::Pending => yield Poll::Pending, // suspend HERE
}
}
}
The yield Poll::Pending is the suspension point. The outer async function returns Pending to its caller (the executor). When the executor polls it again later, execution resumes right after that yield — same state, same local variables, same pinned memory location.
The Complete Flow: What Happens When You Write .await
1. You call an async function → returns a Future (state machine, NOT running)
2. You .await it → compiler inserts poll loop around that future
3. Executor pins the future (Pin::new_unchecked / Box::pin / tokio::pin!)
4. Executor calls poll(future, context_with_waker)
5. Future checks: "am I ready?"
├─ YES → returns Poll::Ready(value) → .await expression evaluates to value
└─ NO → stores waker from context, returns Poll::Pending
6. Executor sees Pending → parks the task, goes to sleep (epoll/io_uring)
7. I/O completes → driver calls waker.wake()
8. Executor wakes up, puts task in ready queue
9. Executor calls poll again → loop repeats from step 4
10. Eventually Ready → value propagates up through all .await chains
Common Misconceptions
| Misconception | Reality |
|---|---|
".await runs the future in the background" |
Futures are lazy. Nothing runs until poll is called. The future is the computation. |
"async fn returns a running task" |
It returns an inert Future. You must spawn it or await it to drive it. |
"Pin prevents all moves" |
Pin<&mut T> prevents moving through that reference. You can still move a Box (the pointer moves, heap allocation doesn't). Unpin types ignore pinning entirely. |
| "Waking a task runs it immediately" |
wake() just schedules it. The executor decides when to actually poll. |
"If I don't call wake(), the future will still be polled eventually" |
No. Without a wake, the executor has no reason to poll it. The task hangs forever. |
Why This Matters Practically
Debugging hangs: If your async code hangs, something returned Pending but never got woken. Check: did the future register its waker? Did the I/O driver call wake()?
Pin errors: "future cannot be unpinned" means you're trying to move a !Unpin future after pinning it. Use Box::pin() for heap storage, tokio::pin!() for stack pinning in select!.
CPU starvation: An async loop with no .await points monopolizes the executor thread. Insert tokio::task::yield_now().await periodically.
Spurious wake bugs: If you assume poll means "data is ready" without re-checking, you'll read garbage or panic. Always re-check.
The Mental Model to Keep
- Future = a state machine that knows how to advance itself when polled
- Pin = a promise that this state machine won't move (so its internal pointers stay valid)
- Waker = a callback handle that lets I/O say "poll me now"
-
Executor = the event loop that sleeps on
epoll/io_uring, wakes tasks, and callspoll -
.await= syntactic sugar for "poll this inner future in a loop until Ready, yielding Pending to my caller each time"
None of this is magic. It's a carefully designed system where every piece has a mechanical purpose. The next time you type .await, you'll know exactly what you're invoking.
Tags: rust, async, futures, pin, waker, executor, systems
Top comments (0)