Skip to content
AgileLogics
8 min read

Async Rust: Futures & async/await

Prerequisites: Advanced lifetimes

Threads are a good fit for a handful of CPU-bound jobs. They are a poor fit for ten thousand network connections that spend most of their time waiting. Async Rust lets one thread juggle many waiting tasks. This lesson explains the machinery; the next one uses Tokio to do real work.

What a future is

A Future is a value representing a computation that may not have finished. The whole trait is one method:

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

poll either returns Poll::Ready(value) or Poll::Pending. Before returning Pending, the future arranges for cx.waker() to be called when it can make progress (a socket became readable, a timer fired). Whoever is polling — the executor — waits for that wake-up instead of spinning.

Futures are lazy

Calling an async fn runs none of its body. It returns a future, and nothing happens until something polls it. The standard library defines Future but ships no executor, so you need a runtime (Tokio, smol, or your own) to drive futures to completion.

To see every moving part, here is a hand-written future and a minimal executor using only std:

use std::future::Future;
use std::pin::{Pin, pin};
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use std::thread::{self, Thread};

// A future that needs to be polled `n` more times before it finishes.
struct CountDown(u32);

impl Future for CountDown {
    type Output = &'static str;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        if self.0 == 0 {
            return Poll::Ready("liftoff");
        }
        println!("poll: {} left", self.0);
        self.0 -= 1;
        cx.waker().wake_by_ref(); // "poll me again"
        Poll::Pending
    }
}

// Waking unparks the thread that is running the executor.
struct ThreadWaker(Thread);

impl Wake for ThreadWaker {
    fn wake(self: Arc<Self>) {
        self.0.unpark();
    }
}

fn block_on<F: Future>(fut: F) -> F::Output {
    let mut fut = pin!(fut);
    let waker = Waker::from(Arc::new(ThreadWaker(thread::current())));
    let mut cx = Context::from_waker(&waker);
    loop {
        match fut.as_mut().poll(&mut cx) {
            Poll::Ready(value) => return value,
            Poll::Pending => thread::park(), // sleep until woken
        }
    }
}

async fn launch() -> String {
    let msg = CountDown(2).await;
    format!("{msg}!")
}

fn main() {
    let fut = launch(); // nothing has run yet
    println!("created");
    println!("{}", block_on(fut));
}

Expected output:

created
poll: 2 left
poll: 1 left
liftoff!

A real runtime adds a reactor that talks to the OS (epoll, kqueue, IOCP) so that wakers fire when I/O is ready, plus a scheduler that runs many tasks across a thread pool. You will almost never write poll yourself; you will compose futures with async and .await.

async fn and .await

The compiler turns an async fn into a state machine that implements Future. Each .await is a point where the function may pause: if the inner future is Pending, the whole outer future returns Pending and resumes from the same spot when polled again.

async fn load_user(id: u32) -> String {
    format!("user-{id}")
}

async fn greeting(id: u32) -> String {
    let name = load_user(id).await; // pauses here until ready
    format!("hello, {name}")
}

async blocks create futures inline, and async move captures by value just like a move closure:

fn make_task(id: u32) -> impl std::future::Future<Output = String> {
    async move { format!("task {id}") }
}

Two .awaits in a row run sequentially. To wait on several futures at once you need a combinator such as tokio::join! — covered next lesson.

Pin, conceptually

The state machine for an async fn may hold a reference to one of its own local variables across an .await. If the future were moved in memory after being polled, that internal reference would dangle. Pin<&mut Self> is the type-level promise that the future will not move again once polled. In practice you only meet it when storing futures yourself: Box::pin(fut) or std::pin::pin!(fut) pins a future so it can be polled through &mut.

Common mistakes

  • Calling an async function without .await. Nothing runs; the compiler warns about an "unused implementer of Future that must be used".
  • Using .await in a normal function:
fn main() {
    let s = greeting(1).await;
}
error[E0728]: `await` is only allowed inside `async` functions and blocks
  • Blocking inside async code. std::thread::sleep, heavy CPU loops and blocking file or database calls stall the executor thread and every task on it. Use the runtime's async equivalents or move the work off the async threads (spawn_blocking).
  • Expecting async to make CPU-bound work faster. It helps with waiting, not computing; use threads or rayon for that.

Practice

Using the block_on function above, write a YieldOnce future that returns Pending the first time it is polled (after waking itself) and Ready(()) the second time. Then write an async fn steps() that prints step 1, awaits a YieldOnce, and prints step 2. Run it with block_on.

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

struct YieldOnce {
    yielded: bool,
}

impl Future for YieldOnce {
    type Output = ();

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
        if self.yielded {
            Poll::Ready(())
        } else {
            self.yielded = true;
            println!("(yielding)");
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

// ThreadWaker and block_on (and their imports): copy unchanged from above.

async fn steps() {
    println!("step 1");
    YieldOnce { yielded: false }.await;
    println!("step 2");
}

fn main() {
    block_on(steps());
}

This prints step 1, (yielding), step 2 — the async fn paused at the .await, returned Pending to the executor, and resumed where it left off.