Async runtime & Pin
An async fn compiles to a state-machine struct that implements Future. An executor polls that state machine to completion. Pin enforces the rule that self-referential state machines cannot move. Tokio provides one such executor; it's where the runtime in "async runtime" lives.
Long read · from async/await desugaring through to tokio internals
1 · What async fn becomes
The compiler rewrites an async fn into a struct that holds local variables
as fields and a state enum that records which .await point execution paused at.
The struct implements Future by stepping forward each time it's polled.
async fn count() -> i32 {
let a = read_one().await;
let b = read_one().await;
a + b
}
// Roughly compiles to:
enum CountState {
Start,
AwaitingA(ReadOneFut),
AwaitingB { a: i32, fut: ReadOneFut },
Done,
}
struct CountFuture {
state: CountState,
}
impl Future for CountFuture {
type Output = i32;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<i32> {
let me = unsafe { self.get_unchecked_mut() };
loop {
match me.state {
CountState::Start => {
me.state = CountState::AwaitingA(read_one());
}
CountState::AwaitingA(ref mut fut) => {
match unsafe { Pin::new_unchecked(fut) }.poll(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(a) => {
me.state = CountState::AwaitingB { a, fut: read_one() };
}
}
}
// ...
}
}
}
}2 · The Future trait
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T),
Pending,
}A future is a value you can poll. Each poll either returns Ready(T)
(the work is done) or Pending (the work isn't done and the future has
arranged to be polled again later). The Context carries a Waker —
the handle the future uses to ask the executor to poll it again.
3 · Why Pin
The state machine can hold references into its own fields. A local
let a = ... followed by fn f(&a) compiles to a struct
where one field holds a and another holds &a. If you
moved that struct in memory, the &a field would point to the old location.
use std::pin::Pin;
// Pin<P> is a pointer type that promises:
// "the pointed-to value will not move until it's dropped"
// Future::poll takes Pin<&mut Self> because once a Future starts
// being polled, it cannot move — moving could invalidate the
// self-references inside its state struct.
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
// safe: we promise not to move *self
}Unpin
auto-trait, meaning Pin<&mut T> is no stronger than &mut T for them.
Most futures generated by async fn are not Unpin — hence the dance
with Box::pin, pin!, and tokio::pin!.4 · The executor
The runtime — tokio, async-std, smol — provides three things: an executor that polls futures, a reactor that turns I/O events into Waker calls, and timers. The skeleton loop is conceptually:
loop {
while let Some(task) = ready_queue.pop() {
match task.future.as_mut().poll(&mut cx) {
Poll::Ready(_) => { /* task complete; drop it */ }
Poll::Pending => { /* waker will re-queue when ready */ }
}
}
reactor.wait_for_event(); // epoll / kqueue / IOCP
}Tokio's real implementation adds per-thread queues, work stealing, scheduling fairness, and integration with timers — but the core is "poll until pending, sleep until woken".
5 · Wakers — the glue
When a future returns Pending, it must have arranged for a Waker to be called
later. For an I/O future, the runtime's reactor (an epoll wrapper) registers interest in
a file descriptor and, when readiness arrives, calls waker.wake() — which puts
the task back on the ready queue.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct Ready(bool);
impl Future for Ready {
type Output = i32;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<i32> {
if self.0 {
Poll::Ready(42)
} else {
// We can't make progress yet. Schedule a wake-up.
cx.waker().wake_by_ref();
Poll::Pending
}
}
}6 · What blocks the runtime
- Sync I/O.
std::fs::read_to_stringblocks the executor thread. Usetokio::fsor wrap inspawn_blocking. - CPU loops. An async fn that doesn't
.awaitnever yields. Other tasks starve. - Mutex held across
.await. Risk of deadlock if the holder is suspended. Either drop before await, or usetokio::sync::Mutex. - Blocking syscalls.
std::thread::sleepin async code blocks.tokio::time::sleepdoesn't.
7 · Multi-threaded vs single-threaded runtimes
Tokio offers two flavours. The single-threaded runtime is one event loop on one thread;
futures don't need to be Send. The multi-threaded runtime can move tasks
between worker threads via work-stealing — futures must be Send so they can
cross threads. The compiler enforces this at the spawn call site.
| Single-threaded | Multi-threaded |
|---|---|
| One worker thread | N worker threads (default: num CPUs) |
Tasks need not be Send | Tasks must be Send |
| Lower overhead per task | Higher overhead, but parallel |
| Best for I/O-only workloads | Best for I/O + light CPU mixes |
8 · Debugging async code
- tokio-console. Live view of tasks, their state, and how long they've been pending. Catches starved or stuck tasks.
- tracing + tracing-subscriber. Async-aware spans. Each
.awaitappears in the trace with timing. - cargo expand. Dumps the desugared state machine. Useful when you can't tell what a future is doing.
- tokio::time::timeout. Wrap suspect work; if a future doesn't complete in N seconds, you know something's wrong.
References
- The async book — The canonical primer.
- tokio.rs/tutorial — Production-grade examples.
- boats — Why async Rust — Design rationale from a core contributor.
- fasterthanli.me — Pin and suffering — A deep, accessible Pin walkthrough.
- Tokio scheduler design — Work-stealing internals in tokio.