Async/Await in Rust
Learn async/await, the Future trait, Tokio runtime, async streams, and the select! macro.
The Basics: async and await
Async programming lets a single thread handle thousands of concurrent operations by suspending tasks that are waiting for I/O instead of blocking the thread. In Rust, async fn marks a function as asynchronous — calling it does not run the body immediately, it returns a Future. The body only runs when something drives that future to completion, typically a runtime like Tokio. This model gives you concurrency with near-zero overhead compared to spawning threads.
use tokio::time::{sleep, Duration};
async fn fetch_data(id: u32) -> String {
// .await suspends this task without blocking the thread
// Other tasks can run while this sleep is in progress
sleep(Duration::from_millis(100)).await;
format!("data for id={}", id)
}
#[tokio::main] // sets up the Tokio runtime and runs main as an async task
async fn main() {
let result = fetch_data(42).await;
println!("{}", result);
}
Add to Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["full"] }
The Future Trait
Understanding Future demystifies what async/await actually generates. The compiler transforms an async fn body into a state machine that implements Future. Each .await point becomes a state — the state machine records where it was and yields control back to the runtime. The runtime polls the future repeatedly, advancing it state by state until it completes.
// Simplified definition from std::future
pub trait Future {
type Output;
// poll() is called by the runtime — Ready means done, Pending means "call me again later"
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T), // computation is complete, here is the result
Pending, // not done yet, runtime will call poll() again when ready
}
You rarely implement Future manually — async fn generates the implementation for you. The Pin wrapper ensures the state machine’s internal self-references remain valid if the runtime moves it in memory.
Running Multiple Futures Concurrently
tokio::join! — Concurrent, Wait for All
Running futures sequentially with .await wastes time — each operation waits for the previous to finish. tokio::join! runs all the given futures concurrently within a single task, polling each one in turn. Total elapsed time is the duration of the longest operation, not the sum.
use tokio::time::{sleep, Duration};
async fn task(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await;
format!("{} done after {}ms", name, ms)
}
#[tokio::main]
async fn main() {
// All three tasks run concurrently — total time ~300ms, not 600ms
let (a, b, c) = tokio::join!(
task("A", 300),
task("B", 200),
task("C", 100),
);
println!("{}", a);
println!("{}", b);
println!("{}", c);
}
tokio::spawn — Independent Tasks
tokio::spawn submits a future to the Tokio thread pool as an independent task. Unlike join!, which polls futures within the same task, spawn can run on a different thread. Use it when a task is long-running, CPU-intensive, or when you need to fire-and-forget without waiting for the result immediately.
use tokio::task;
#[tokio::main]
async fn main() {
// Each spawn returns a JoinHandle — await it later to get the result
let h1 = task::spawn(async {
42
});
let h2 = task::spawn(async {
"hello from task"
});
// join! on the handles waits for both independent tasks to complete
let (r1, r2) = tokio::join!(h1, h2);
println!("{}", r1.unwrap()); // 42
println!("{}", r2.unwrap()); // hello from task
}
tokio::select! — First to Finish Wins
select! polls multiple futures simultaneously and proceeds with whichever completes first, cancelling the others. This is the building block for timeouts, cancellation, and event loops where you want to react to whichever signal arrives next.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let mut interval = tokio::time::interval(Duration::from_millis(200));
let deadline = sleep(Duration::from_millis(550));
tokio::pin!(deadline);
let mut ticks = 0;
loop {
tokio::select! {
_ = interval.tick() => {
ticks += 1;
println!("tick {}", ticks);
}
// When the deadline future completes, this branch wins
_ = &mut deadline => {
println!("deadline reached after {} ticks", ticks);
break;
}
}
}
}
Error Handling in Async Code
Async functions work seamlessly with Result and the ? operator. The rules are the same as synchronous code — ? propagates errors up to the caller, and the return type must be Result. The only difference is that you .await async operations before applying ?.
use std::io;
use tokio::fs;
async fn read_config(path: &str) -> Result<String, io::Error> {
// .await the async operation, then ? to propagate any error
let content = fs::read_to_string(path).await?;
Ok(content)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
match read_config("config.toml").await {
Ok(content) => println!("config: {} bytes", content.len()),
Err(e) => eprintln!("failed to read config: {}", e),
}
Ok(())
}
HTTP Client with reqwest
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1", features = ["derive"] }
reqwest is an async HTTP client built on Tokio. Fetching multiple resources concurrently with join_all shows the real benefit of async — the requests all fly out simultaneously and results are collected when all have responded, far faster than fetching sequentially.
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct Post {
id: u32,
title: String,
body: String,
}
async fn fetch_post(id: u32) -> Result<Post, reqwest::Error> {
let url = format!("https://jsonplaceholder.typicode.com/posts/{}", id);
reqwest::get(&url).await?.json::<Post>().await
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// All three requests are in-flight at the same time
let futures = (1..=3).map(|id| fetch_post(id));
let posts = futures::future::join_all(futures).await;
for post in posts {
match post {
Ok(p) => println!("[{}] {}", p.id, p.title),
Err(e) => eprintln!("error: {}", e),
}
}
Ok(())
}
Async TCP Server
An async TCP server handles every client connection as an independent task. When a client connection is accepted, tokio::spawn hands it off to a new task so the accept loop can immediately go back to waiting for the next connection. This lets a single-threaded runtime handle thousands of simultaneous clients efficiently.
use tokio::net::{TcpListener, TcpStream};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn handle_client(mut stream: TcpStream) {
let mut buf = vec![0u8; 1024];
loop {
let n = match stream.read(&mut buf).await {
Ok(0) => return, // client disconnected cleanly
Ok(n) => n,
Err(e) => { eprintln!("read error: {}", e); return; }
};
// Echo the data back to the client
if let Err(e) = stream.write_all(&buf[..n]).await {
eprintln!("write error: {}", e);
return;
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("echo server listening on port 8080");
loop {
let (stream, addr) = listener.accept().await?;
println!("new connection from {}", addr);
// Each client runs in its own independent task
tokio::spawn(handle_client(stream));
}
}
Async Streams
Streams are the async equivalent of iterators — they produce values one at a time, and each next() call is an async operation that may suspend until the next value is ready. Use them for paginated API responses, database result sets, or any data source that trickles in over time.
[dependencies]
tokio-stream = "0.1"
use tokio_stream::{self as stream, StreamExt};
#[tokio::main]
async fn main() {
let mut s = stream::iter(vec![1, 2, 3, 4, 5]);
// while let drives the stream, awaiting each item
while let Some(n) = s.next().await {
println!("{}", n);
}
// Streams support familiar adapter-style operations
let sum: i32 = stream::iter(1..=100)
.filter(|&x| async move { x % 2 == 0 })
.fold(0, |acc, x| async move { acc + x })
.await;
println!("sum of even 1..=100: {}", sum); // 2550
}
Timeouts
Every async operation that could stall needs a timeout in production code. tokio::time::timeout wraps any future and cancels it if it has not completed within the deadline. It returns Ok(result) if the future finished in time, or Err(Elapsed) if it timed out — no threads are blocked in either case.
use tokio::time::{timeout, Duration};
async fn slow_operation() -> String {
tokio::time::sleep(Duration::from_secs(5)).await;
"done".to_string()
}
#[tokio::main]
async fn main() {
// The future is cancelled cleanly if it does not complete within 500ms
match timeout(Duration::from_millis(500), slow_operation()).await {
Ok(result) => println!("got: {}", result),
Err(_) => println!("operation timed out"),
}
}
Key Concepts Summary
| Concept | Description |
|---|---|
async fn | Returns a Future; body is lazy — does not run until polled |
.await | Suspends current task until future resolves; thread is not blocked |
tokio::main | Starts the Tokio runtime and runs the async main function |
tokio::spawn | Spawns an independent async task on the thread pool |
tokio::join! | Runs futures concurrently in the same task, waits for all |
tokio::select! | Runs futures concurrently, proceeds on the first to complete |
tokio::time::timeout | Cancels a future if it does not complete within a deadline |
Stream | Async version of Iterator — values arrive over time |
Pin<&mut Self> | Prevents self-referential futures from being moved in memory |