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.
| Runtime | Highlights | Typical use |
| Tokio | Mature ecosystem; multi-scheduler; powerful I/O | Servers; clients; production services |
| async-std | Std-like surface; broad utilities | Utilities; simpler async tasks |
| Minimal executors | Tiny footprint; no bundled I/O | Embedded; custom schedulers |
.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
}
!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");
}
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.
- Blocking in async code: Do not call blocking operations inside an async task. Use nonblocking APIs or run blocking work on a dedicated thread pool. In Tokio use
spawn_blockingfor CPU-bound or blocking calls. - Missing
Sendbounds: Spawning on a multithreaded runtime requires futures to beSend. If a future captures non-Senddata the compiler rejects the spawn; consider!Sendruntimes or refactor captured values. - Holding locks across
.await: Do not hold a mutex guard over an.await. Release the guard before awaiting to prevent deadlocks and priority inversion; consider scoping blocks ortokio::syncprimitives tailored for async. - Recursive async functions: Direct recursion creates infinitely sized futures. Box the recursive call or convert to an explicit stack structure.
- Cancellation: Dropping a future cancels it. Ensure you handle partial progress and cleanup. For Tokio use
select!and cancellation-safe APIs where available. - No async
Drop: Destructors cannot be async. Provide a separateasyncclose method for resources that need asynchronous shutdown.
.await creates temporary lifetimes that may not live long enough. Clone small data or restructure code so borrows do not span suspension points.