Async / Await in Rust
Modern applications must handle thousands of concurrent operations — network connections, file reads, database queries — without creating an OS thread for each one. OS threads are powerful but heavy: each one costs around 2–8 MB of stack space, and context-switching between thousands of them is expensive.
Rust's async / await system solves this by letting a small pool of OS threads drive an enormous number of concurrent tasks. When a task is waiting for I/O, it is suspended and the thread moves on to another task. No thread sits idle.
Model | Concurrency unit | Overhead | Best for |
|---|---|---|---|
OS thread | Thread (1:1 with kernel) | MB of stack, kernel scheduling | CPU-bound parallelism |
Async task | Future (N:M onto thread pool) | Kilobytes, user-space scheduling | I/O-bound concurrency |
async fn and Futures
Marking a function with async transforms it into a function that returns a
Future. A Future is a value representing a computation that may not have
completed yet. The trait looks like this:
// Simplified — the real trait uses Pin, but the concept is the same
pub trait Future {
type Output;
fn poll(&mut self, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T), // computation complete, here is the result
Pending, // not done yet — wake me when there is progress
}When you write an async function, the compiler rewrites it into a state machine
that implements Future. Each .await point becomes a state boundary where
execution can suspend and resume.
// What you write:
async fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
// What the compiler produces (conceptually):
fn greet(name: &str) -> impl Future<Output = String> {
async move { format!("Hello, {}!", name) }
}Futures Are Lazy
This is the single most important thing to understand about async Rust:
a Future does nothing until it is driven. Calling an async fn merely
constructs the future — no code inside it runs yet.
async fn expensive_work() -> u32 {
println!("doing work...");
42
}
fn main() {
let _future = expensive_work(); // nothing printed — future is just a value
// _future is dropped here — the work NEVER ran
}.await it or drive it with a runtime, the computation never runs. This is a common source of bugs for async beginners..await: Suspending Until Done
The .await operator does two things:
- Polls the future to check if it is ready.
- If it is not ready (
Poll::Pending), suspends the current task and yields control back to the runtime so another task can run. - When the future becomes ready, the runtime wakes this task and it resumes from exactly where it paused.
.await can only appear inside an async function or block.
async fn fetch_data() -> String {
// In a real program this would hit a network endpoint
String::from("data from server")
}
async fn process() {
let data = fetch_data().await; // suspends here if not ready
println!("Got: {}", data);
}Async Runtimes
Rust's standard library defines the Future trait but ships no runtime. A
runtime is responsible for:
- Maintaining a pool of OS threads.
- Scheduling which task runs next.
- Waking tasks when their I/O is ready (via
epoll,kqueue, IOCP, etc.).
You must choose a runtime and add it as a dependency. The most widely used options are:
Runtime | crates.io name | Notes |
|---|---|---|
Tokio | tokio | Most popular, full-featured, multi-threaded by default |
async-std | async-std | std-mirroring API, simpler mental model |
smol | smol | Tiny, composable, good for embedded or learning |
Tokio is the dominant choice in the ecosystem. Most async libraries — reqwest (HTTP), sqlx (database), axum (web framework) — target Tokio and compose with it naturally.
Getting Started with Tokio
Add Tokio to Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["full"] }The #[tokio::main] attribute macro transforms your main function into one
that starts the Tokio runtime and runs your async code on it:
#[tokio::main]
async fn main() {
println!("Running inside the Tokio runtime");
say_hello().await;
}
async fn say_hello() {
println!("Hello from async Rust!");
}Running inside the Tokio runtime Hello from async Rust!
Under the hood, #[tokio::main] expands to roughly:
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
// your async main body runs here
});
}Reading a File Asynchronously
use tokio::fs;
#[tokio::main]
async fn main() {
// tokio::fs::read_to_string suspends the task while the OS reads the file.
// The thread is free to run other tasks in the meantime.
let contents = fs::read_to_string("hello.txt").await.unwrap();
println!("File contents: {}", contents);
}tokio::fs (and other Tokio I/O types) inside async code — not std::fs. The std versions block the OS thread; the Tokio versions suspend the task and allow other work to proceed.Making HTTP Requests with reqwest
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let url = "https://httpbin.org/get";
let response = reqwest::get(url).await?; // suspends here
let body = response.text().await?; // suspends here too
println!("Response length: {} bytes", body.len());
Ok(())
}Response length: 307 bytes
tokio::spawn: Concurrent Tasks
.await runs one future at a time in sequence within a task. To run multiple async
operations concurrently — without waiting for each to finish before starting the
next — use tokio::spawn. Each spawned task is a lightweight unit of work
scheduled independently by the runtime.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let handle_a = tokio::spawn(async {
sleep(Duration::from_millis(200)).await;
println!("task A done");
"result A"
});
let handle_b = tokio::spawn(async {
sleep(Duration::from_millis(100)).await;
println!("task B done");
"result B"
});
// Both tasks run concurrently. Await the handles to get results.
let a = handle_a.await.unwrap();
let b = handle_b.await.unwrap();
println!("a={}, b={}", a, b);
}task B done task A done a=result A, b=result B
tokio::spawn returns a JoinHandle. You must.await the handle to get the result. If you drop the handle without awaiting it, the task is detached and continues running in the background.tokio::join!: Wait for All
tokio::join!(f1, f2, f3) runs all futures concurrently on the current task
and waits until every one completes. It is more efficient than awaiting them in
sequence because all futures make progress simultaneously. Total elapsed time equals
the slowest single future, not the sum.
use tokio::time::{sleep, Duration};
async fn step(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await;
format!("{} finished", name)
}
#[tokio::main]
async fn main() {
// Sequential would take 150+100+120 = 370 ms.
// join! takes ~150 ms — the longest single step.
let (a, b, c) = tokio::join!(
step("alpha", 150),
step("beta", 100),
step("gamma", 120),
);
println!("{}", a);
println!("{}", b);
println!("{}", c);
}alpha finished beta finished gamma finished
tokio::select!: Take the First
tokio::select! races multiple futures and returns as soon as the first one
completes, cancelling the rest. This is useful for timeouts, fallbacks, and
choosing between competing sources of data.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let slow = sleep(Duration::from_millis(500));
let fast = sleep(Duration::from_millis(100));
tokio::select! {
_ = slow => println!("slow branch fired"),
_ = fast => println!("fast branch fired — slow was cancelled"),
}
}fast branch fired — slow was cancelled
// Timeout pattern using tokio::time::timeout
use tokio::time::{timeout, Duration};
async fn might_be_slow() -> &'static str {
tokio::time::sleep(Duration::from_secs(10)).await;
"done"
}
#[tokio::main]
async fn main() {
match timeout(Duration::from_millis(200), might_be_slow()).await {
Ok(result) => println!("got: {}", result),
Err(_) => println!("timed out!"),
}
}timed out!
Never Block the Async Thread
The golden rule of async Rust: never call a blocking function inside an async
context. If you call std::thread::sleep or a blocking file read, you block
the entire OS thread — no other tasks on that thread can make progress until it
returns.
Blocking (wrong in async) | Async equivalent |
|---|---|
std::thread::sleep(dur) | tokio::time::sleep(dur).await |
std::fs::read_to_string(path) | tokio::fs::read_to_string(path).await |
std::net::TcpListener::accept() | tokio::net::TcpListener::accept().await |
blocking database driver | sqlx (async), or tokio::task::spawn_blocking |
use tokio::time::{sleep, Duration};
// WRONG — blocks the entire OS thread for 1 second
async fn wrong_sleep() {
std::thread::sleep(Duration::from_secs(1)); // all tasks on this thread stall!
}
// RIGHT — suspends only this task; thread serves other tasks
async fn correct_sleep() {
sleep(Duration::from_secs(1)).await;
}tokio::task::spawn_blocking(|| blocking_fn()). This moves the blocking call to a dedicated thread pool so the async threads stay free.Async Channels with tokio::sync::mpsc
The standard library's std::sync::mpsc channels are blocking. Inside async code,
use tokio::sync::mpsc instead — its send and recv methods are async and
do not block the thread while waiting.
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<String>(32); // buffer of 32 messages
// Producer task
let producer = tokio::spawn(async move {
for i in 0..5 {
tx.send(format!("message {}", i)).await.unwrap();
}
// tx dropped here — the channel closes, rx.recv() will return None
});
// Consumer on the main task
while let Some(msg) = rx.recv().await {
println!("received: {}", msg);
}
producer.await.unwrap();
}received: message 0 received: message 1 received: message 2 received: message 3 received: message 4
async in Traits
Until Rust 1.75, async fn in traits was not directly supported in stable Rust.
The async-trait crate provides a procedural macro workaround. Rust 1.75+
added native support through return-position impl Trait in traits (RPITIT).
// Using the async-trait crate — works on all stable Rust versions
// Cargo.toml: async-trait = "0.1"
use async_trait::async_trait;
#[async_trait]
trait Fetcher {
async fn fetch(&self, url: &str) -> String;
}
struct HttpFetcher;
#[async_trait]
impl Fetcher for HttpFetcher {
async fn fetch(&self, url: &str) -> String {
format!("fetched from {}", url)
}
}
#[tokio::main]
async fn main() {
let f = HttpFetcher;
println!("{}", f.fetch("https://example.com").await);
}// Rust 1.75+ — async fn in traits natively (no crate needed)
trait Fetcher {
async fn fetch(&self, url: &str) -> String;
}dyn Fetcher requires extra work. For dynamic dispatch, the async-trait crate boxes the future and remains the practical choice when trait objects are needed.Async vs Threads: When to Use Each
Scenario | Recommended |
|---|---|
Web server handling thousands of connections | Async (Tokio + Axum) |
HTTP client making many requests | Async (Tokio + reqwest) |
Database queries | Async (sqlx) |
Image processing, video encoding, heavy math | OS threads or rayon |
CPU-bound work inside an async app | tokio::task::spawn_blocking |
Simple script with one network call | Either — threads may be simpler |
A useful mental model: async for waiting, threads for working. If your task
spends most of its time waiting for external systems (network, disk, database), async
is the right tool. If it spends most of its time computing, OS threads — potentially
with the rayon crate for easy data parallelism — are a better fit.
The Async Ecosystem
tokio — the runtime: async threads, I/O, timers, sync primitives (mpsc, Mutex, RwLock).
reqwest — ergonomic HTTP client built on Tokio.
sqlx — async SQL driver for PostgreSQL, MySQL, SQLite with compile-time query checking.
axum — web framework from the Tokio team; routes, extractors, middleware.
tower — middleware and service abstractions shared across the ecosystem.
tracing — async-aware structured logging and spans.
These crates compose naturally. An Axum web server uses reqwest to call external APIs, sqlx to query a database, and tracing to log every request — all async, all on the same Tokio runtime, all without blocking a thread.
Complete Example: Concurrent HTTP Requests
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = "0.12"use reqwest;
async fn fetch(url: &str) -> Result<usize, reqwest::Error> {
let body = reqwest::get(url).await?.text().await?;
Ok(body.len())
}
#[tokio::main]
async fn main() {
let urls = [
"https://httpbin.org/get",
"https://httpbin.org/ip",
"https://httpbin.org/user-agent",
];
// Spawn all requests concurrently
let handles: Vec<_> = urls.iter()
.map(|url| {
let url = url.to_string();
tokio::spawn(async move {
match fetch(&url).await {
Ok(len) => println!("{} => {} bytes", url, len),
Err(e) => println!("{} => error: {}", url, e),
}
})
})
.collect();
// Wait for all of them
for h in handles {
h.await.unwrap();
}
}https://httpbin.org/ip => 28 bytes https://httpbin.org/user-agent => 45 bytes https://httpbin.org/get => 307 bytes
All three requests are in flight simultaneously. The total time is roughly equal to the slowest single request, not the sum of all three.
Key Points to Remember
async fnreturns aFuture— no code inside it runs until the future is polled..awaitsuspends the current task (not the thread) until the future is ready.You need a runtime (Tokio, async-std, or smol) — Rust's std provides none.
tokio::spawncreates a concurrent task;tokio::join!waits for all;tokio::select!takes the first.Never call blocking APIs inside async code — use their async equivalents or
spawn_blocking.Use async for I/O-bound work; use OS threads or rayon for CPU-bound work.
#[tokio::main], learn .await andtokio::join!, then reach for spawn andselect! as your needs grow. The type system ensures even complex async code compiles without data races, and the zero-cost abstraction model means you pay only for the concurrency you actually use.