Rust Async Programming Cheat Sheet
Covers async/await syntax, Futures, the Tokio runtime, spawning tasks, and common concurrency primitives for asynchronous Rust code.
async/await Basics
Declaring and awaiting async functions.
// An async fn returns a value that implements Future<Output = T>async fn fetch_data() -> String { String::from("data")}async fn run() { let data = fetch_data().await; // .await suspends until the future resolves println!("{}", data);}// Futures do nothing until polled/awaited or spawned on a runtime
The Tokio Runtime
Rust has no built-in async runtime; Tokio is the most widely used.
// Cargo.toml: tokio = { version = "1", features = ["full"] }#[tokio::main]async fn main() { let result = fetch_data().await; println!("{}", result);}// Equivalent, without the macro:fn main() { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { let result = fetch_data().await; println!("{}", result); });}
Spawning Tasks
Running futures concurrently on the runtime's thread pool.
use tokio::task;#[tokio::main]async fn main() { let handle = task::spawn(async { // runs concurrently on the Tokio thread pool expensive_computation().await }); // do other work concurrently here... let result = handle.await.unwrap(); // join the task, propagate panics println!("{}", result);}async fn expensive_computation() -> u32 { 42}
Core Async Concepts
Vocabulary for reasoning about async Rust.
- Future- A trait representing a value that may not be ready yet; polled by an executor
- Executor/runtime- Drives futures to completion (e.g. Tokio, async-std); Rust has no built-in runtime
- .await- Suspends the current async fn until the future resolves, yielding control back to the executor
- async block- `async { ... }` creates an anonymous future without a named function
- Send + 'static- Requirements for futures spawned onto a multi-threaded runtime
- Pinning (Pin<Box<...>>)- Needed because async blocks can be self-referential and must not move once polled
Async Sync Primitives
Channels, mutexes, and combinators for coordinating async tasks.
use tokio::sync::{Mutex, mpsc};use tokio::time::{sleep, Duration};#[tokio::main]async fn main() { // tokio::sync::Mutex is async-aware (lock().await, not blocking) let data = std::sync::Arc::new(Mutex::new(0)); // mpsc channel for async message passing let (tx, mut rx) = mpsc::channel::<i32>(32); tokio::spawn(async move { tx.send(10).await.unwrap(); }); let val = rx.recv().await; // join! runs multiple futures concurrently on the same task let (_a, _b) = tokio::join!(sleep(Duration::from_millis(10)), sleep(Duration::from_millis(20))); // select! races futures, taking the first to complete tokio::select! { _ = sleep(Duration::from_secs(1)) => println!("timeout"), v = rx.recv() => println!("got {:?}", v), }}
Implementing Future Manually
What async/await desugars to: a state machine polled by an executor via Pin<&mut Self>.
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; cx.waker().wake_by_ref(); // reschedule immediately Poll::Pending } }}// Executors call poll() repeatedly; Pending means "park until woken",// Ready(T) means the future produced its output.
Streams & FuturesUnordered
Consuming async sequences and driving many independent futures to completion concurrently.
use futures::stream::{self, StreamExt, FuturesUnordered};async fn sum_stream() -> i32 { let mut s = stream::iter(vec![1, 2, 3]); let mut total = 0; while let Some(v) = s.next().await { total += v; } total}async fn fetch(id: u32) -> u32 { id * 2 }async fn fetch_all(ids: Vec<u32>) -> Vec<u32> { let mut futs: FuturesUnordered<_> = ids.into_iter().map(fetch).collect(); let mut results = Vec::new(); while let Some(r) = futs.next().await { results.push(r); // completes in whatever order finishes first } results}
Cancellation, Timeouts & Structured Concurrency
Dropping a future cancels it; use timeout/JoinSet to bound and manage groups of tasks.
use tokio::time::{timeout, Duration};use tokio::task::JoinSet;async fn slow_call() -> u32 { 42 }async fn bounded() -> Option<u32> { // Dropping the future returned by `timeout` on expiry cancels the inner future // at its next .await point -- no explicit cancellation token needed for simple cases. match timeout(Duration::from_millis(100), slow_call()).await { Ok(v) => Some(v), Err(_elapsed) => None, }}async fn run_batch(ids: Vec<u32>) -> Vec<u32> { let mut set = JoinSet::new(); for id in ids { set.spawn(async move { id * id }); } let mut out = Vec::new(); while let Some(res) = set.join_next().await { out.push(res.unwrap()); // aborts remaining tasks if `set` is dropped early } out}
Async Functions in Traits (AFIT)
Native async trait methods (stable since Rust 1.75) vs the older async-trait crate for object safety.
// Native async fn in traits -- zero-cost, but the trait is NOT object-safe// (you can't build a `Box<dyn Fetcher>` from it without extra work).trait Fetcher { async fn fetch(&self, url: &str) -> String;}struct HttpFetcher;impl Fetcher for HttpFetcher { async fn fetch(&self, url: &str) -> String { format!("body of {url}") }}// When you need dyn-compatible trait objects, use the `async-trait` crate:// #[async_trait]// trait Fetcher { async fn fetch(&self, url: &str) -> String; }// It boxes the returned future so `Box<dyn Fetcher>` works, at a small allocation cost.
Async Pitfalls & Advanced Vocabulary
Terms and gotchas that separate working async code from correct async code.
- Self-referential future- A generated async state machine that stores a reference into its own fields; the reason futures must be pinned
- Unpin- Auto-trait meaning a type is safe to move even while pinned; most types are Unpin, generated async blocks usually are not
- Executor starvation- A blocking or long CPU-bound call inside an async fn stalls the worker thread and delays unrelated tasks
- Cancellation safety- Whether a future can be dropped mid-await without corrupting shared state (e.g. losing a partially sent message)
- Runtime mixing- Using tokio::sync/tokio::time types under an async-std (or vice versa) executor silently hangs or panics
- Waker- Handle stored by a Pending future to tell the executor 'poll me again'; forgetting to call it means the task never wakes
- Bounded vs unbounded channel- mpsc::channel(n) applies backpressure by blocking senders; mpsc::unbounded_channel never blocks but can grow memory unbounded
Never call a blocking, CPU-heavy, or synchronous I/O function directly inside an async fn — it stalls the executor thread and starves other tasks. Use `tokio::task::spawn_blocking` to offload blocking work to a dedicated thread pool.