async/await and futures

Rust · Advanced · 18 min

Network services in Rust are overwhelmingly async, and its poll-based model explains many of its rules and errors.

An async fn does not run when called. It returns a future: a value implementing std::future::Future, a state machine that the compiler generates from your function, with one state per .await point. Nothing happens until something polls it. `.await` inside another async context drives a future to completion, suspending the current task whenever the inner future is not ready.

The trait is small:

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

poll returns Poll::Ready(value) or Poll::Pending. When pending, the future stores the `Waker` from cx; when its I/O or timer is ready, it calls wake(), telling the executor to poll it again. No thread is blocked while waiting.

The standard library defines the trait but ships no executor. You choose a runtime (Tokio, smol, embassy for embedded) that polls tasks, plus an I/O reactor that integrates with epoll/kqueue/IOCP. The example builds a tiny executor using only std to show the mechanics.

Key consequences:

  • Async is cooperative: a task gives up the thread only at .await. A long CPU loop or a blocking call like std::thread::sleep or blocking file I/O stalls every task on that worker thread.
  • Async helps when you have many concurrent waits (thousands of sockets) with little CPU work. For CPU-bound parallelism, threads or rayon are simpler.
  • Futures can be cancelled by simply dropping them, which means code after an .await might never run.

Other syntax: async { ... } blocks create futures inline, async move captures by value, async closures async |x| ... are stable since 1.85, and async fn is allowed in traits since 1.75.

Example

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

struct ThreadWaker(Thread);
impl Wake for ThreadWaker {
    fn wake(self: Arc<Self>) { self.0.unpark(); } // waking = unparking the blocked thread
}
// a minimal executor: poll; if pending, park the thread until woken
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(v) => return v,
            Poll::Pending => thread::park(),
        }
    }
}

async fn add(a: u32, b: u32) -> u32 { a + b }
fn main() {
    let fut = async { add(1, 2).await * 10 }; // nothing runs yet: futures are lazy
    println!("{}", block_on(fut));             // 30
}

Remember

  • async fn returns a lazy future; .await drives it
  • Executors poll futures; wakers tell them when to poll again
  • Never block inside async code: tasks only yield at .await

Watch out for

  • Calling an async function without .await and wondering why nothing happened (the compiler warns: futures do nothing unless awaited).

Check yourself

  1. What happens when you call `fetch()` (an async fn) without `.await` and drop the result?
  2. Why does `std::thread::sleep` inside an async task harm other tasks?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: doc.rust-lang.org/std/future/trait.Future.html