Skip to main content

Command Palette

Search for a command to run...

Demystifying Rust's Async: A Comprehensive Guide to Futures, Executors, Reactors, and Beyond

Published
6 min read
Demystifying Rust's Async: A Comprehensive Guide to Futures, Executors, Reactors, and Beyond

Rust's asynchronous programming model is one of its most powerful features, enabling efficient, non-blocking I/O and concurrent operations without the overhead of threads. However, it can be confusing for newcomers—especially concepts like futures, awaiting, and what really happens under the hood. In this in-depth blog post, we'll break it all down step by step, from the basics to advanced internals. By the end, you'll have a clear mental model, and common confusions (like what happens during an await) will be resolved.This guide is based on real-world examples, code snippets, and traces from popular runtimes like Tokio. Whether you're a beginner or an experienced Rustacean looking to solidify your understanding, let's dive in.

Introduction: Why Async in Rust?

Rust's async is designed for zero-cost abstractions—meaning it's efficient, scalable, and integrates seamlessly with the borrow checker. Unlike threads (which are OS-heavy and can lead to blocking), async uses cooperative multitasking: tasks yield control voluntarily, allowing a single thread to handle thousands of operations.Common confusions we'll address:

  • What happens during await? Does the code below the await run while waiting? (Spoiler: No, but the CPU isn't blocked.)

  • Polling vs. Events: Why isn't the system constantly checking if a task is ready?

  • Roles of Futures, Wakers, Executors, and Reactors: How do they fit together?

We'll cover everything with code, analogies, and a real-world trace.

Section 1: What is a Future?

At the heart of Rust async is the Future trait from std::future. A future represents a computation that may not be complete yet—like reading from a network or waiting for a timer.
Here's the trait (simplified):

use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;  // The result type (e.g., Result<u8, Error>)

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
  • Output: What the future resolves to.

  • poll: The method to "advance" the future. It returns:

    • Poll::Ready(value): Done!

    • Poll::Pending: Not ready; try later.

  • Pin<&mut Self>: Ensures the future doesn't move in memory (more on pinning later).

  • Context: Provides a Waker for notifications.

Futures are lazy: They do nothing until polled. Async functions (async fn) and blocks (async {}) compile into state machines that implement Future.
Example: A simple async function.

async fn delay_add(a: u32, b: u32) -> u32 {
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;
    a + b
}

This becomes a Future<Output = u32> under the hood.

Section 2: The Polling Model

Rust async is poll-based: An executor repeatedly calls poll on futures. But it's not wasteful—polling only happens when progress is likely.

  • Initial Poll: Starts the future.

  • Subsequent Polls: Only after a "wake-up" signal.

No constant looping! This leads to our next piece: wakers.Section

3: Wakers – The Notification System

A Waker is a handle to notify the executor: "This task can make progress now." Think of it as the sender (tx) half of a oneshot channel—the future/resource holds it, and the executor "receives" notifications.

  • Created by the executor per task.

  • Passed via Context in poll.

  • When an event occurs (e.g., data ready), call waker.wake() to reschedule the task.

Analogy: Like passing a callback: "Call me when ready."

In code:

// Inside a future's poll
if !ready {
    let waker = cx.waker().clone();
    // Store waker or pass to resource (e.g., reactor)
    Poll::Pending
} else {
    Poll::Ready(value)
}

Wakers are cloneable and idempotent—multiple wakes are fine.

Section 4: Executors – The Task Driver

Executors (e.g., Tokio's runtime) manage tasks:

  • Spawn futures as tasks.

  • Poll ready tasks.

  • Park pending ones.

  • Reschedule on wakes.

Executors use queues: Ready tasks are polled; pending ones wait for wakes.

Example in Tokio:

tokio::spawn(async {
    // Your async code
});

A single-threaded executor might loop like:

  • Check wake queue.

  • Poll ready tasks.

  • Sleep if idle.

Multi-threaded ones (like Tokio) use work-stealing for efficiency.

Section 5: Reactors – The Event Watcher

Reactors handle OS-level events (I/O readiness) and bridge to wakers.

  • Uses system calls: epoll (Linux), kqueue (macOS), IOCP (Windows).

  • Registers resources (sockets, timers) during initial poll.

  • Waits efficiently (blocks until events).

  • On event: Calls stored waker.

In Tokio, the reactor is a background thread using mio.

No polling tasks—reactors push notifications.

Section 6: The Full Flow – Tracing a Real Example: TcpStream::read

Let's trace stream.read(&mut buf).await in Tokio.

  1. Executor polls the read future (first time).

  2. Future tries non-blocking read → WouldBlock.

  3. Registers socket for "readable" with reactor, stores waker.

  4. Returns Pending → executor parks task.

  5. CPU free; executor runs other tasks.

  6. Network data arrives → OS notifies reactor (via epoll_wait).

  7. Reactor calls waker.wake() → enqueues task in executor's ready queue.

  8. Executor re-polls future.

  9. Read succeeds → Ready(n_bytes) → await completes.

Key: Only 2 polls typically (initial + post-event). No constant checks!

Section 7: Implementing Custom Futures

Any struct can be a future by implementing the trait.Requirements:

  • Define Output.

  • Handle poll with Pin<&mut Self>.

Example: A counter future.

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct CounterFuture {
    count: u32,
    max: u32,
}

impl Future for CounterFuture {
    type Output = String;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let this = self.get_mut();
        if this.count >= this.max {
            Poll::Ready(format!("Done at {}", this.count))
        } else {
            this.count += 1;
            // Wake only if needed (e.g., after external event)
            // cx.waker().wake_by_ref();  // Avoid for busy loops!
            Poll::Pending
        }
    }
}

To run: Use an executor like tokio::runtime::Runtime::new().block_on(fut).

Section 8: Pinning – Why and How?

Futures can be self-referential (e.g., a pointer to its own field across awaits).Pin guarantees no moves after pinning, preventing invalid references.
Patterns:

  • No self-refs: Use self.get_mut().

  • With self-refs: Use pin_project crate for safe projections.

Example with pin_project:

use pin_project::pin_project;

#[pin_project]
struct SelfRefFuture {
    data: String,
    #[pin]
    ref_to_data: Option<&'static str>,
}

impl Future for SelfRefFuture {
    // ...
}

Section 9: Resolving Common Confusions

Let's tackle the big one: What happens during await?

Suppose:

async fn example() {
    println!("Start");
    tokio::time::sleep(Duration::from_secs(2)).await;  // Await a 2s timer
    println!("After await");  // Sequential code
}

Confusion: While waiting 2 seconds, does "After await" run? Or other code in the function?

Reality:

  • await yields control back to the executor.

  • The entire task (the future for example()) is parked—no code after await runs until the timer resolves.

  • Sequential code is paused; it's like a state machine stopping at the await point.

  • However, the thread/CPU is not blocked. The executor can:

    • Run other spawned tasks (independent futures via tokio::spawn).

    • Handle other I/O or wakes.

  • The "outer world" (OS, other threads, or the runtime) can use the CPU freely—no spinning or blocking syscalls.

Why not run sequential code?

  • Async is cooperative: Await is a yield point. Continuing would violate the linear flow you wrote.

Analogy: Like pausing a video at a scene— the player (executor) can play other videos, but this one resumes from the pause.

Other confusions:

  • Busy Polling? No—reactors wait passively via OS events.

  • Multiple Polls? Only when woken; e.g., your counter example busy-looped because of unnecessary wakes—real futures wake only on events.

  • Blocking vs. Async: Blocking (e.g., thread::sleep) hogs the thread; async frees it.

Conclusion: Key Takeaways

  • Futures: Placeholders for async results, advanced via polling.

  • Wakers: Notify when ready.

  • Executors: Schedule and drive tasks.

  • Reactors: Handle events and wake.

  • Await: Parks the task, frees CPU, but pauses sequential code until done.

  • Efficiency: Event-driven, scales to massive concurrency.

To experiment: Start with Tokio, read the Async Book, and implement a custom future. Rust async isn't magic—it's a well-engineered system that rewards understanding.

Questions? Drop a comment! If this cleared your confusions, share it with fellow Rustaceans. Happy async coding!