Skip to main content

Async/Await

TL;DR async fn desugars to a function that returns a Future. The future is a compiler-generated state machine. Calling .await suspends the current task if the future isn't ready, yielding control back to the runtime.

The Desugaring

async fn fetch(url: &str) -> String {
// ...
}

// Equivalent to:
fn fetch(url: &str) -> impl Future<Output = String> + '_ {
// compiler generates a state machine struct here
}

The state machine captures all local variables that survive across .await points. This is why async functions have larger stack frames than equivalent synchronous code — state is stored on the heap (in a Box<dyn Future>) when tasks are spawned.

The Future Trait

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

pub enum Poll<T> {
Ready(T),
Pending,
}

The runtime calls poll. If it returns Pending, the future is suspended. When it's ready to make progress (e.g., I/O is available), the Waker stored in Context is called, which tells the runtime to poll the future again.

You rarely implement Future manually — async/await generates the implementation.

Tokio: The Runtime

async functions don't run themselves. They need an executor — a runtime that drives the futures to completion. Tokio is the standard choice for Rust servers.

#[tokio::main]
async fn main() {
let result = fetch("https://example.com").await;
println!("{result}");
}

#[tokio::main] desugars to:

fn main() {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async {
// your async main body
})
}

Spawning Tasks

tokio::spawn creates a lightweight task that runs concurrently. Tasks are multiplexed across OS threads by Tokio's work-stealing scheduler.

let handle = tokio::spawn(async {
// runs concurrently with the caller
compute_something().await
});

let result = handle.await.unwrap(); // JoinHandle<T>

Spawned tasks must be Send + 'static — they may move between threads.

Concurrency vs Parallelism

.await is cooperative: a task yields only at .await points. CPU-bound work blocks the thread and starves other tasks.

// BAD: blocks the Tokio thread
tokio::spawn(async {
std::thread::sleep(Duration::from_secs(1)); // never yield
});

// GOOD: yield-friendly sleep
tokio::spawn(async {
tokio::time::sleep(Duration::from_secs(1)).await;
});

// GOOD: CPU-bound work on a dedicated thread pool
tokio::task::spawn_blocking(|| {
heavy_computation() // runs on a blocking thread, doesn't starve async tasks
});

Join Multiple Futures

Run futures concurrently and wait for all:

let (a, b) = tokio::join!(fetch_a(), fetch_b());

join! polls both futures, interleaving progress. This is concurrent but still single-threaded (unless spawned).

// Race — first one wins, others are cancelled
let result = tokio::select! {
r = fetch_a() => r,
r = fetch_b() => r,
};

Gotchas

Holding a MutexGuard across .await:

// will deadlock or fail to compile
async fn bad(lock: &Mutex<Vec<i32>>) {
let guard = lock.lock().unwrap();
something_async().await; // guard held across yield point
// ...
}

Use tokio::sync::Mutex for async-safe locking, or drop the guard before the .await.

async closures are not stable yet (as of 2025, tracked in RFC #2394). Use async move {} blocks instead.

Tokio RuntimeHow Tokio's work-stealing scheduler drives futures Arc<Mutex<T>>Shared mutable state across async tasks