The State Machine behind async fn
Goal of This Episode
Unmask async fn: the compiler rewrites it into a state machine that can pause and resume.
Main Text
.await Doesn’t Open a New Thread
First, let’s dispel a possible misconception. Seeing .await, you might imagine it “secretly opens a Thread in the background to wait.” Absolutely not. From Episode 6 until now, the executor of our hand-written runtime has been one Thread polling over and over, start to finish — .await conjured no new Threads.
So what does .await actually do? It cuts your function into segments — every .await is a cut point. The function can pause at a cut point, hand control back to the executor, and later resume from that same cut point.
The compiler achieves this by rewriting the whole async fn into a state machine: “which state am I in” records the progress, and the next poll picks up from that state and continues.
What an async fn Gets Rewritten Into
Suppose we have this async fn that waits twice:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::thread;
use std::time::{Duration, Instant};
struct Delay {
when: Instant,
started: bool,
}
impl Delay {
fn new(duration: Duration) -> Delay {
Delay {
when: Instant::now() + duration,
started: false,
}
}
}
impl Future for Delay {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let this = self.get_mut();
if Instant::now() >= this.when {
Poll::Ready(())
} else {
if !this.started {
this.started = true;
let waker = cx.waker().clone();
let when = this.when;
thread::spawn(move || {
let now = Instant::now();
if now < when {
thread::sleep(when - now);
}
waker.wake();
});
}
Poll::Pending
}
}
}
async fn two_delays() {
Delay::new(Duration::from_secs(1)).await;
println!("one second is up");
Delay::new(Duration::from_secs(1)).await;
println!("two seconds are up");
}
fn main() {}
On seeing it, the compiler rewrites it into an enum — each “state” standing for “which segment am I stuck in”:
Start: not yet begun.FirstDelay: waiting on the firstDelay(the unfinishedDelayitself must be stored in here too).SecondDelay: waiting on the secondDelay.Done: finished.
It then implements Future for this enum, with poll using a match on the current state to decide what to do. Let’s write this rewrite out by hand, and you’ll see what an async fn looks like underneath:
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use std::thread::{self, Thread};
use std::time::{Duration, Instant};
struct ThreadWaker {
thread: Thread,
}
impl Wake for ThreadWaker {
fn wake(self: Arc<Self>) {
self.thread.unpark();
}
}
struct Delay {
when: Instant,
started: bool,
}
impl Delay {
fn new(duration: Duration) -> Delay {
Delay {
when: Instant::now() + duration,
started: false,
}
}
}
impl Future for Delay {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let this = self.get_mut();
if Instant::now() >= this.when {
Poll::Ready(())
} else {
if !this.started {
this.started = true;
let waker = cx.waker().clone();
let when = this.when;
thread::spawn(move || {
let now = Instant::now();
if now < when {
thread::sleep(when - now);
}
waker.wake();
});
}
Poll::Pending
}
}
}
fn block_on<F: Future>(future: F) -> F::Output {
let mut future = Box::pin(future);
let waker = Waker::from(Arc::new(ThreadWaker {
thread: thread::current(),
}));
let mut cx = Context::from_waker(&waker);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(value) => return value,
Poll::Pending => thread::park(),
}
}
}
// this is roughly what the two_delays async fn looks like underneath
enum TwoDelays {
Start,
FirstDelay(Delay), // waiting on the first Delay — keep it stored
SecondDelay(Delay), // waiting on the second Delay
Done,
}
impl Future for TwoDelays {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let this = self.get_mut();
loop {
match this {
TwoDelays::Start => {
// enter first segment: create first Delay and switch state
*this = TwoDelays::FirstDelay(Delay::new(Duration::from_secs(1)));
}
TwoDelays::FirstDelay(delay) => match Pin::new(delay).poll(cx) {
Poll::Ready(()) => {
println!("one second is up");
*this = TwoDelays::SecondDelay(Delay::new(Duration::from_secs(1)));
}
Poll::Pending => return Poll::Pending, // stuck in this segment — pause
},
TwoDelays::SecondDelay(delay) => match Pin::new(delay).poll(cx) {
Poll::Ready(()) => {
println!("two seconds are up");
*this = TwoDelays::Done;
return Poll::Ready(());
}
Poll::Pending => return Poll::Pending,
},
TwoDelays::Done => panic!("shouldn't be polled after Ready"),
}
}
}
}
fn main() {
println!("start");
block_on(TwoDelays::Start); // equivalent to block_on(two_delays())
}
The bare-bones
Delayandblock_onfrom earlier are included above only so this hand-written state machine actually runs. The star of this example isn’t the executor butTwoDelays: it demonstrates the kind of state machine anasync fnmight be rewritten into.
Side by Side
Compare this hand-written state machine with the original async fn:
- The progress through the original
async fnbecomes a variant of theenum. - Local variables still needed across an
.await(here, the unfinishedDelay) get stored inside the variant and carried along. - Each
.awaitbecomes “pollthe childFuture: onReady, switch to the next state and continue; onPending,return Poll::Pendingand pause.” - On the next
poll, thematchjumps straight to the state where it last stopped and continues from there — that’s “resuming in place.”
This exactly explains the phenomena of the past few episodes: why a Future remembers where it got to on every poll, and why it resumes from the same place after pausing. Because it simply is a state machine that remembers “which state I’m in.”
When you write async fn day to day, all of this is generated automatically by the compiler — you never hand-write such an enum. But knowing its true face is what makes the upcoming episodes on Pin meaningful — because this auto-generated state machine hides a danger related to “moving memory around.” Next episode we look at that danger.
Recap
.awaitdoes not open a newThread; it cuts the function into pausable, resumable segments.- The compiler rewrites an
async fn/asyncblock into a state machine (conceptually anenum): progress becomes variants; locals crossing an.awaitare stored in the variant. pollusesmatchon the current state: childFutureReady→ switch to the next state;Pending→ returnPendingand pause.- The next
polljumps back to the last state and resumes in place — that’s how aFuture“remembers its progress.” - The rewrite is normally done by the compiler automatically, but understanding it is the prerequisite for understanding
Pinlater.