<< Home

Chapter 12: Async Rust

Rust supports asynchronous programming through zero-cost abstractions that compile to efficient state machines. You write familiar code using async and await; the compiler lowers this into a Future that an executor drives to completion. This chapter explains how Future works, how executors schedule tasks, why pinning matters, how to work with Stream and Sink, how to use Tokio for tasks and timers, and how to approach async traits while avoiding common pitfalls.

Futures and executors

A Future is a value that represents a computation that may complete later. It has a single required method, poll, which asks whether the future can make progress. An executor repeatedly polls pending futures until they are ready. Most day-to-day code does not call poll directly; you use async and await, and the runtime handles polling for you.

What a Future really is

When you write an async fn, the compiler produces a type that implements Future. The function body becomes a state machine that stores any local variables across suspension points. This keeps allocations to a minimum and avoids hidden threads.

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

pin_project! {
  struct ReadyOnce {
    done: bool,
  }
}

impl Future for ReadyOnce {
  type Output = 'static str;
  fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
    let this = self.project();
    if *this.done { Poll::Ready("done") } else { *this.done = true; Poll::Pending }
  }
}

fn main() {
  // A toy executor that polls until ready (illustrative only).
  let mut fut = Box::pin(ReadyOnce { done: false });
  let waker = futures::task::noop_waker();
  let mut cx = Context::from_waker(&waker);
  match fut.as_mut().poll(&mut cx) {
    Poll::Ready(v) => println!("{v}"),
    Poll::Pending => println!("pending"),
  }
}

This example shows the poll contract. Real programs rely on an executor to drive futures instead of calling poll directly.

Executors and runtimes

An executor schedules and runs tasks (futures that have been spawned). A runtime bundles an executor with I/O, timers, and other services. Tokio is a popular choice; async-std is another. The right choice depends on your needs.

RuntimeHighlightsTypical use
TokioMature ecosystem; multi-scheduler; powerful I/OServers; clients; production services
async-stdStd-like surface; broad utilitiesUtilities; simpler async tasks
Minimal executorsTiny footprint; no bundled I/OEmbedded; custom schedulers
💡 Think of an executor as a cooperative scheduler. Futures yield when they hit .await, which lets other tasks run without preemptive context switches.

async, await, and Pinning

async turns a function or block into a future. await pauses that future until the awaited one is ready. Pinning guarantees that a future will not move in memory after it starts running, which is important because the state machine may contain self-referential data.

Declaring and awaiting futures

An async fn returns an opaque future (impl Future<Output = T>). You can run it by awaiting inside an async context or by spawning it on a runtime.

async fn fetch_value() -> u32 {
  42
}

async fn use_it() -> u32 {
  let v = fetch_value().await;
  v + 1
}

To call use_it from main you need a runtime, since await requires an async context.

Why Pin matters

Most futures are !Unpin. Once they start running they must not move. Pin<T> enforces that a value stays at a stable address. Boxing with Box::pin is a common way to pin on the heap.

use std::pin::Pin;

async fn step() { /* work … */ }

async fn chained() {
  step().await;
  step().await;
}

fn pin_example() {
  let fut = chained();         // not pinned yet
  let _pinned = Box::pin(fut); // now pinned on the heap
}
⚠️ Avoid moving a !Unpin future after it has been polled. Moving can invalidate internal references. Use Pin to keep locations stable.

Streams and Sinks

Stream models a sequence of asynchronous values. Sink models an asynchronous consumer that you can send values into. Libraries such as futures provide traits and adapters for both.

Consuming a Stream

You can iterate a stream with while let plus next, or use combinators such as map, filter, and take. This supports backpressure because polling advances only when there is demand.

use futures::{StreamExt, stream};

async fn sum_first_three() -> i32 {
  let s = stream::iter([1, 2, 3, 4, 5]);
  s.take(3).fold(0, |acc, x| async move { acc + x }).await
}

Sending into a Sink

A Sink accepts values asynchronously. Many channel types implement Sink, which lets you pipe a Stream into a Sink with helpers such as forward.

use futures::{SinkExt, StreamExt};
use futures::channel::mpsc;

async fn forward_example() {
  let (tx, mut rx) = mpsc::channel(8);
  let sender = async move {
    let mut tx = tx;
    for i in 0..5 { tx.send(i).await.unwrap(); }
  };
  let receiver = async move {
    while let Some(v) = rx.next().await {
      println!("got {v}");
    }
  };
  futures::join!(sender, receiver);
}

Tokio tasks and timers

Tokio provides an efficient multi-threaded runtime, a scheduler for lightweight tasks, asynchronous I/O, and utilities like timers. You write #[tokio::main] to bootstrap the runtime, then tokio::spawn to schedule work.

Spawning tasks with tokio::spawn

Spawning detaches a future to run concurrently. The return value is a JoinHandle<T>. Await the handle to observe completion and to receive the task result.

use tokio::task;

#[tokio::main]
async fn main() {
  let h = task::spawn(async {
    "result from task"
  });
  let out = h.await.unwrap();
  println!("{out}");
}

Sleeping and intervals

Tokio timers integrate with the scheduler. sleep suspends a task until a deadline. interval yields on a fixed cadence, which is useful for heartbeats and housekeeping.

use tokio::time::{sleep, interval, Duration};

#[tokio::main]
async fn main() {
  let mut tick = interval(Duration::from_millis(200));
  for _ in 0..3 {
    tick.tick().await;
    println!("tick");
  }
  sleep(Duration::from_secs(1)).await;
  println!("done");
}
💡 Prefer tokio::time::sleep to busy loops. Yielding lets the runtime schedule other tasks and improves throughput.

async traits and pitfalls

Traits cannot contain async fn directly without indirection because the return type would be an opaque future whose exact type depends on the implementer. You can use explicit return futures, boxed futures, or helper crates that generate the necessary plumbing.

Approaches to async traits

One option is to return an associated type that implements Future. Another is to return Pin<Box<dyn Future<Output = T> + Send + '_>>. The associated type avoids allocation; the boxed approach simplifies signatures at the cost of a heap allocation.

use core::future::Future;
use std::pin::Pin;

trait Fetch {
  type Fut<'a>: Future<Output = String> + Send where Self: 'a;
  fn get<'a>('a self) -> Self::Fut<'a>;
}

struct Client;

impl Fetch for Client {
  type Fut<'a> = impl Future<Output = String> + Send + 'a;
  fn get<'a>('a self) -> Self::Fut<'a> {
    async move { "ok".to_string() }
  }
}

// Boxed variant for simpler signatures.
trait BoxedFetch {
  fn get(&self) -> Pin<Box<dyn Future<Output = String> + Send + '_>>;
}

impl BoxedFetch for Client {
  fn get(&self) -> Pin<Box<dyn Future<Output = String> + Send + '_>> {
    Box::pin(async move { "ok".to_string() })
  }
}

Common pitfalls

There are a few sharp edges to watch for in async Rust. Keeping them in mind will save time and avoid subtle bugs.

⚠️ Borrowing across .await creates temporary lifetimes that may not live long enough. Clone small data or restructure code so borrows do not span suspension points.