Async Deep Dive
The Future Trait
Section titled “The Future Trait”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 and Waker
Section titled “Context and Waker”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.
Polling Model
Section titled “Polling Model”Executor Future Reactor │ │ │ ├── poll() ──────────────► │ │ │ │ │ │ check state │ │ (not ready) │ │ │ │ │ ◄── Pending ────────────┤ │ │ │ │ │ ├── register waker ──────► │ │ │ │ │ │ I/O event │ │ │ │ │ ◄── wake() ────────────┤ │ │ │ ├── poll() ──────────────► │ │ │ │ │ │ check state │ │ (ready) │ │ │ │ │ ◄── Ready(value) ───────┤ │ │ │ │How async fn Desugars
Section titled “How async fn Desugars”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.
Pin and Unpin
Section titled “Pin and Unpin”Why Pin Exists
Section titled “Why Pin Exists”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, } }}Pin<P> API
Section titled “Pin<P> API”| Method | Description |
|---|---|
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 Unpinlet x = 42;let mut pinned = Box::pin(x);let _ = pinned.as_mut().get_mut(); // OK: i32 is Unpin
// Self-referential types are !Unpinlet mut pinned = Box::pin(SelfReferential::new(String::from("hello")));// pinned.as_mut().get_mut(); // ERROR: SelfReferential is !UnpinPin Projection
Section titled “Pin Projection”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) }}Executor Model
Section titled “Executor Model”Tokio Executor
Section titled “Tokio Executor”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.
Task Scheduling
Section titled “Task Scheduling”#[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.
Async I/O
Section titled “Async I/O”Under the Hood: epoll/kqueue/IOCP
Section titled “Under the Hood: epoll/kqueue/IOCP”Tokio’s reactor maps to the OS’s native async I/O mechanism:
| Platform | Mechanism | Description |
|---|---|---|
| Linux | epoll | Event notification for file descriptors |
| macOS | kqueue | Event notification for file descriptors |
| Windows | IOCP | I/O Completion Ports |
| FreeBSD | kqueue | Event 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.
Async File I/O
Section titled “Async File I/O”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.
Async Networking
Section titled “Async Networking”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 }); }}Cancellation
Section titled “Cancellation”Drop-Based Cancellation
Section titled “Drop-Based Cancellation”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 backgroundTo 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?;Cancellation Safety
Section titled “Cancellation Safety”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
Section titled “Drop Guards”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}Async Patterns
Section titled “Async Patterns”select!
Section titled “select!”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"); } }}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.
Cross-References
Section titled “Cross-References”- [[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