Skip to content

Async Deep Dive

The Future trait is the foundation of async programming in Rust. It represents a value that may Become available at some point in the future:

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 does not do anything on its own. It must be polled by an executor. Each call to poll Either returns Ready(value) if the computation is complete, or Pending if it needs more time. When returning PendingThe future registers the current Waker with the reactor, so the executor Knows to poll it again when progress can be made.

Context provides access to the WakerWhich is used to notify the executor that a future should Be polled again:

pub struct Context<"a> {
waker: &'a Waker,
}
impl Waker {
fn wake(self);
fn wake_by_ref(&self);
}

When a future returns PendingThe executor records that the future is waiting. When the Underlying I/O operation completes (e.g., a socket becomes readable), the reactor calls waker.wake()Which schedules the future for re-polling.

Executor Future Reactor
│ │ │
├── poll() ──────────────► │ │
│ │ │
│ check state │
│ (not ready) │
│ │ │
│ ◄── Pending ────────────┤ │
│ │ │
│ ├── register waker ──────► │
│ │ │
│ │ I/O event
│ │ │
│ │ ◄── wake() ────────────┤
│ │ │
├── poll() ──────────────► │ │
│ │ │
│ check state │
│ (ready) │
│ │ │
│ ◄── Ready(value) ───────┤ │
│ │ │

An async fn compiles into a state machine. Each .await point becomes a state transition. The Compiler generates an anonymous enum type for the state machine:

async fn fetch_data(url: &str) -> Result<String, Error> {
let response = reqwest::get(url).await?;
let body = response.text().await?;
Ok(body)
}

This roughly desugars to:

enum FetchDataFuture<'a> {
State0 { url: &'a str },
State1 { future: reqwest::ResponseFuture },
State2 { response: reqwest::Response, future: reqwest::BodyFuture },
Resolved,
}
impl<'a> Future for FetchDataFuture<'a> {
type Output = Result<String, Error>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
loop {
match self.get_mut() {
State0 { url } => {
let future = reqwest::get(url);
*self = State1 { future };
}
State1 { future } => {
match Pin::new(future).poll(cx) {
Poll::Ready(Ok(response)) => {
let body_future = response.text();
*self = State2 { response, future: body_future };
}
Poll::Ready(Err(e)) => {
*self = Resolved;
return Poll::Ready(Err(e));
}
Poll::Pending => return Poll::Pending,
}
}
State2 { future, .. } => {
match Pin::new(future).poll(cx) {
Poll::Ready(Ok(body)) => {
*self = Resolved;
return Poll::Ready(Ok(body));
}
Poll::Ready(Err(e)) => {
*self = Resolved;
return Poll::Ready(Err(e));
}
Poll::Pending => return Poll::Pending,
}
}
Resolved => panic!("polled after completion"),
}
}
}
}

The key insight: each .await is a yield point where control returns to the executor. The future’s State is saved across yield points so execution can resume from where it left off.

The compiler-generated state machine for async blocks can contain self-referential data — a field That points to another field within the same struct. If the struct were moved, the pointer would Become invalid. Pin prevents the wrapped value from being moved after it has been pinned.

use std::pin::Pin;
use std::marker::PhantomPinned;
struct SelfReferential {
data: String,
pointer: *const String,
_marker: PhantomPinned,
}
impl SelfReferential {
fn new(data: String) -> Self {
SelfReferential {
data,
pointer: std::ptr::null(),
_marker: PhantomPinned,
}
}
}
MethodDescription
new(pointer)Creates a Pin from a pointer (requires Unpin)
as_ref()Returns Pin<&T>
as_mut()Returns Pin<&mut T> (requires Unpin)
get_ref()Returns &T
get_mut()Returns &mut T (requires Unpin)
into_inner()Unwraps and returns the inner value (requires Unpin)

Most types are Unpin — they can be safely moved even when pinned. Types that are self-referential (like the compiler-generated state machine for async blocks) are !Unpin:

// Most types are Unpin
let x = 42;
let mut pinned = Box::pin(x);
let _ = pinned.as_mut().get_mut(); // OK: i32 is Unpin
// Self-referential types are !Unpin
let mut pinned = Box::pin(SelfReferential::new(String::from("hello")));
// pinned.as_mut().get_mut(); // ERROR: SelfReferential is !Unpin

pin-project is the standard crate for safely accessing fields of pinned structs:

[dependencies]
pin-project = "1"
use pin_project::pin_project;
#[pin_project]
struct MyFuture {
#[pin]
inner: SomeFuture,
extra_data: String,
}
impl Future for MyFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
this.inner.poll(cx)
}
}

Tokio uses a multi-threaded work-stealing scheduler:

┌─────────────────────────────────────────┐
│ Tokio Runtime │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Worker 1│ │ Worker 2│ │ Worker N│ │
│ │ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │
│ │ │Local│ │ │ │Local│ │ │ │Local│ │ │
│ │ │Queue│ │ │ │Queue│ │ │ │Queue│ │ │
│ │ └──┬──┘ │ │ └──┬──┘ │ │ └──┬──┘ │ │
│ │ │ │ │ │ │ │ │ │ │
│ │ ▼ │ │ ▼ │ │ ▼ │ │
│ │ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │
│ │ │Tasks│ │ │ │Tasks│ │ │ │Tasks│ │ │
│ │ └─────┘ │ │ └─────┘ │ │ └─────┘ │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ ▲ │
│ │ work stealing │
│ └─────────────────────────────── │
│ ┌──────────────────────────────────┐ │
│ │ Reactor (epoll/kqueue/IOCP) │ │
│ │ Timer, I/O Driver │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘

Each worker thread has a local deque of tasks. When a worker exhausts its local queue, it steals Tasks from other workers’ queues. This provides automatic load balancing.

#[tokio::main]
async fn main() {
let handle1 = tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(100)).await;
"task 1"
});
let handle2 = tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(50)).await;
"task 2"
});
assert_eq!(handle2.await.unwrap(), "task 2");
assert_eq!(handle1.await.unwrap(), "task 1");
}

tokio::spawn creates a new task (not an OS thread). Tasks are cooperatively scheduled — they yield Control at .await points.

Tokio’s reactor maps to the OS’s native async I/O mechanism:

PlatformMechanismDescription
LinuxepollEvent notification for file descriptors
macOSkqueueEvent notification for file descriptors
WindowsIOCPI/O Completion Ports
FreeBSDkqueueEvent notification for file descriptors

The reactor registers file descriptors with the OS and receives notifications when they become Readable, writable, or error. When a notification arrives, the reactor wakes the corresponding Future.

File I/O on most platforms is not natively async. Tokio uses a thread pool for file operations:

use tokio::fs;
async fn read_file(path: &str) -> Result<String, io::Error> {
let content = fs::read_to_string(path).await?;
Ok(content)
}

On Linux with io_uringFile I/O can be truly async, but io_uring support in tokio is still Evolving.

Networking I/O is natively async on all platforms:

use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("0.0.0.0:8080").await?;
loop {
let (socket, addr) = listener.accept().await?;
tokio::spawn(async move {
// handle connection
});
}
}

In Rust, dropping a future cancels it. There is no explicit cancellation token — if you drop the JoinHandleThe task continues running but its result is ignored:

let handle = tokio::spawn(async {
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
println!("still running");
}
});
tokio::time::sleep(Duration::from_millis(500)).await;
drop(handle);
// The task is NOT cancelled — it continues running in the background

To truly cancel a task, use tokio::select! with a cancellation signal or CancellationToken:

use tokio_util::sync::CancellationToken;
let token = CancellationToken::new();
let cloned_token = token.clone();
let handle = tokio::spawn(async move {
loop {
tokio::select! {
_ = cloned_token.cancelled() => {
println!("cancelled");
return;
}
_ = tokio::time::sleep(Duration::from_secs(1)) => {
println!("tick");
}
}
}
});
token.cancel();
handle.await?;

Some async operations are cancellation-safe and some are not. An operation is cancellation-safe if Dropping it at any await point leaves the system in a consistent state:

  • Cancellation-safe: Reading from a channel, accepting a TCP connection
  • Not cancellation-safe: Writing to a channel (message may be partially sent), holding a lock across await points

The tokio documentation marks each function with its cancellation safety category.

Drop guards ensure cleanup runs even when a future is cancelled:

struct DropGuard {
resource_id: String,
}
impl Drop for DropGuard {
fn drop(&mut self) {
eprintln!("cleaning up resource: {}", self.resource_id);
}
}
async fn with_cleanup(resource_id: &str) {
let _guard = DropGuard {
resource_id: resource_id.to_string(),
};
do_work().await;
// _guard dropped here — cleanup runs
}

Wait on multiple futures and handle the first one to complete:

use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(32);
tokio::spawn(async move {
sleep(Duration::from_millis(100)).await;
tx.send("delayed message").await.unwrap();
});
tokio::select! {
msg = rx.recv() => {
println!("received: {:?}", msg);
}
_ = sleep(Duration::from_millis(50)) => {
println!("timeout");
}
}
}
## Intuition

Async Rust compiles to state machines that yield control at await points. The Future trait defines the async contract: poll returns Ready when complete or Pending when waiting. Executors like tokio drive futures to completion. Pin prevents self-referential structs from moving in memory. This zero-cost abstraction generates code comparable to hand-written state machines, with the compiler optimizing away the async machinery.

  • [[rust/06-concurrency/concurrency]] - Synchronous concurrency primitives
  • [[rust/05-traits-generics/traits-and-generics]] - Future trait and async trait bounds
  • [[rust/04-error-handling/error-handling]] - Error handling in async contexts
  • [[rust/06-concurrency/channels-and-message-passing]] - Async channel patterns