23 — Async / Await
Async lets you write concurrent code that looks sequential. Rust's async is zero-cost — futures are state machines compiled by the compiler.
Async Functions
async fn fetch(url: &str) -> String {
// ... await something ...
String::from("data")
}
Calling fetch(...) returns a future, not a value. The body doesn't run until the future is polled.
await
let s = fetch("https://x").await;
.await yields control to the executor if the future is pending. The current task is suspended and later resumed.
Async Is Lazy
let f = async { println!("hi"); };
// nothing happens yet
f.await; // body runs now
You must .await (or spawn) a future for it to make progress.
Runtimes
Rust ships no built-in async runtime — you choose one:
tokio: most popular, multi-threaded scheduler, mature ecosystem.async-std: mirrors std API, single-threaded by default.smol: small, simple.embassy: embedded (no_std).
#[tokio::main]
async fn main() {
println!("hello from tokio");
}
tokio::main builds a runtime and runs your async main.
Spawning Tasks
#[tokio::main]
async fn main() {
let h = tokio::spawn(async {
5
});
let n: i32 = h.await.unwrap();
println!("{n}");
}
tokio::spawnreturns aJoinHandle<T>.- Spawned tasks must be
Send + 'static. .awaiton the handle givesResult<T, JoinError>(panic propagates).
Futures
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}
enum Poll<T> { Ready(T), Pending }
You rarely implement Future manually. Async functions desugar to anonymous Future-implementing state machines.
Pin
Pin guarantees a value won't be moved in memory. Required because self-referential futures (which reference their own stack across .await) would break if moved.
let mut fut = async { 5 };
let pinned: Pin<&mut _> = Pin::new(&mut fut);
You mostly encounter Pin in trait signatures and APIs (e.g., Future::poll). The pin-utils or Box::pin handle the common cases.
Box<dyn Future> and Pin<Box<dyn Future>>
Because futures have unique unnameable types, storing them in collections or returning them generically requires boxing:
fn make_fut() -> Pin<Box<dyn Future<Output = i32> + Send>> {
Box::pin(async { 5 })
}
Pin<Box<dyn Future>> is the trait-object form of a future.
impl Future
fn make_fut() -> impl Future<Output = i32> {
async { 5 }
}
Returns a concrete future type, hidden. Single type per return site.
Common Async Crates
tokio— runtime, I/O, networking, synchronization.futures— combinators, streams, sinks.async-trait— async functions in traits (until native support stabilizes; partial in 1.75+).reqwest— HTTP client.hyper— HTTP server/client.sqlx— async DB.axum— web framework (tokio-based).
Async IO
use tokio::fs;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let s = fs::read_to_string("file.txt").await?;
println!("{s}");
Ok(())
}
Async read/write yield when the syscall would block. The runtime parks the task and wakes it when the OS signals readiness.
tokio::select!
Race multiple futures, take the first to complete:
tokio::select! {
v = first_future() => println!("first: {v}"),
_ = tokio::time::sleep(Duration::from_secs(1)) => println!("timeout"),
}
Unselected branches are dropped. Use biased for ordering, or branch with &mut futures to reuse them.
Streams (Async Iterators)
use futures::stream::{self, StreamExt};
let mut s = stream::iter(vec![1, 2, 3]).map(|x| x * 2);
while let Some(v) = s.next().await {
println!("{v}");
}
StreamExt::next().await is the async equivalent of Iterator::next(). try_stream/tokio_stream for building streams.
Channels
tokio::sync::mpsc, tokio::sync::broadcast, tokio::sync::oneshot, tokio::sync::watch:
let (tx, mut rx) = tokio::sync::mpsc::channel(100);
tokio::spawn(async move {
tx.send(5).await.unwrap();
});
let v = rx.recv().await;
Async channels .await on send/recv instead of blocking.
spawn_blocking
For CPU-bound work or blocking syscalls inside async code:
let v = tokio::task::spawn_blocking(|| {
cpu_heavy_computation()
}).await.unwrap();
Offloads work to a separate thread pool so the async executor isn't blocked.
Holding Locks Across .await — Pitfall
// BAD: holding std Mutex across await can deadlock / block executor
let guard = std_mutex.lock().unwrap();
some_async().await; // ⚠️ guard held
// GOOD:
let val = {
let g = std_mutex.lock().unwrap();
g.clone()
};
some_async(val).await;
// OR use tokio's async Mutex:
let guard = tokio_mutex.lock().await;
some_async().await;
std::sync::Mutex is fine within an async function if released before .await. For locks held across .await, use tokio::sync::Mutex.
Canceling Futures
Dropping a future cancels it. The select! drop semantics mean unselected branches are canceled. Use CancellationToken for cooperative cancellation.
Backpressure
Use bounded channels (mpsc::channel(n)). .send().await blocks when full, naturally propagating backpressure to producers.
Async Traits (1.75+)
trait Service {
async fn call(&self, req: Request) -> Response;
}
Native async traits stabilized in 1.75 with limitations (no dyn dispatch without #[async_trait] crate, no recursion in some cases). For full features including dyn, use the async-trait crate.
Common Patterns
Concurrency with join!
let (a, b, c) = tokio::join!(fa(), fb(), fc());
Runs all three concurrently, waits for all, returns a tuple.
Concurrency with try_join!
let (a, b) = tokio::try_join!(fa(), fb())?;
Like join! but short-circuits on Err.
FuturesUnordered
use futures::stream::FuturesUnordered;
let mut futs = FuturesUnordered::new();
futs.push(fa());
futs.push(fb());
while let Some(r) = futs.next().await { /* ... */ }
Spawn N futures, await results as they complete (unordered).
Common Pitfalls
.awaitin aforloop over a sync iterator: fine; just don't accidentally serialize tasks you wanted to run concurrently — usejoin!orspawn.- Forgetting to
await: the future is created but never runs — silent bug. async fnin a trait still has rough edges; check current support.- Runtime-locked I/O: mixing
tokio::fsandasync-std::fsis fine functionally but wasteful; pick one runtime's I/O. Sendfutures: futures that hold non-Sendtypes across.awaitare!Sendand can't betokio::spawned.- Long-running blocking code in async: blocks the executor. Use
spawn_blocking. - Memory leaks with
select!loops: each iteration may allocate. Usepin_mut!or pinned variables. tokio::mainflavor:#[tokio::main(flavor = "current_thread")]is single-threaded (less overhead). Default is multi-threaded.Dropcancels futures: a future dropped mid-awaitis silently canceled; resources are cleaned up viaDrop.
When to Use Async
- Many concurrent I/O-bound tasks (HTTP servers, proxies, scrapers).
- Latency-sensitive workloads with lots of waiting.
- Avoid for CPU-bound work — use threads or
rayon. - Avoid in
no_std/embedded unless using ano_std-friendly runtime (embassy).
Summary
Async is lazy (futures are polled); runtimes drive them. tokio is the dominant runtime. await yields control; spawn schedules tasks. select! races; join!/try_join! runs concurrently. Use async-aware channels and locks. Beware holding std::sync::Mutex across .await. Use spawn_blocking for CPU work or blocking calls.
Next: Macros — code that writes code.