The bot that never sleeps
You're writing a Discord bot. It needs to fetch a random cat picture from an API, check a local database for the user's nickname, and send a reply. In a synchronous language, the bot freezes while waiting for the image download. It can't process other messages. If ten users spam the bot, the queue backs up and the bot times out.
Rust's async/await solves this by letting you pause the download, handle other messages, and resume the download when the data arrives, all without spinning up a new thread for every request. You write code that looks sequential, but the compiler transforms it into a state machine that can pause and resume efficiently.
Futures are tickets, not threads
Rust's ownership rule says every value has exactly one owner. Async adds a twist: a computation might not finish yet. Rust represents unfinished work with a Future. A Future is not a thread. It's a value that implements a trait, holding the state of a computation that can be polled for progress.
Think of a Future like a ticket at a busy deli counter. You hand over your order and get a ticket. You don't stand there staring at the counter. You grab a seat, check your phone, maybe help a friend. When the deli calls your number, you walk back, pick up your sandwich, and move on.
The ticket is the Future. The act of waiting for the number is .await. The deli worker calling numbers is the executor. The bell that rings when your sandwich is ready is the Waker.
In Rust, you don't manage the ticket manually. You call an async fn, which returns a Future. You chain .await on that future to pause the current function until the future completes. The executor drives the futures, polling them and waking them when I/O is ready.
Minimal example
Async code needs an executor to run. Rust's standard library defines the Future trait but does not include a runtime. You bring your own executor, usually via a crate like tokio, async-std, or smol. This is a deliberate design choice: the language stays minimal, and the ecosystem provides runtimes tuned for different needs.
/// Simulates fetching data with a delay.
async fn fetch_data() -> String {
// Sleep without blocking the thread.
// The executor can run other tasks while this waits.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
"Data".to_string()
}
/// Entry point with the Tokio runtime.
#[tokio::main]
async fn main() {
// Call the async function.
// This returns a Future, which we immediately await.
let result = fetch_data().await;
println!("{}", result);
}
The #[tokio::main] attribute sets up the runtime and transforms main into a synchronous function that blocks until the async body completes. Inside main, fetch_data().await pauses execution. The runtime switches to other work. When the sleep finishes, the runtime resumes main.
Convention: Use #[tokio::main] for simple binaries. It configures the multi-threaded runtime automatically. For libraries, export async fn functions and let the caller decide on the runtime.
How the compiler rewrites your code
When you write an async fn, the compiler does not generate a function that runs to completion. It generates a struct that implements the Future trait. This struct contains an enum representing the current state of the function: Start, WaitingForSleep, Done, and so on.
The Future trait has one method: poll. The executor calls poll to ask if the future is ready. If the work is done, poll returns Poll::Ready(value). If the work is blocked, poll returns Poll::Pending and registers a Waker.
The Waker is a callback. When the underlying I/O operation completes, the driver calls the waker. The waker notifies the executor that the future is ready to be polled again. The executor puts the future back on its queue. The next time the executor runs, it polls the future, which transitions to the next state and returns Ready.
This cycle repeats until the future completes. The key insight is that the future owns its state. When you .await, you are yielding control back to the executor, which can poll other futures. No thread is blocked. The thread is free to do useful work.
Async isn't magic. It's a state machine driven by an executor.
Realistic example: concurrent fetches
The power of async shines when you have multiple independent I/O operations. You can spawn multiple futures and wait for them all to complete. The executor interleaves the work, overlapping the waits.
/// Fetches a URL and returns the length of the response.
async fn fetch_length(url: &str) -> Result<usize, Box<dyn std::error::Error>> {
// Create a client.
let client = reqwest::Client::new();
// Send the request.
// This returns a Future that waits for the response.
let response = client.get(url).send().await?;
// Read the body.
let body = response.bytes().await?;
Ok(body.len())
}
/// Fetches two URLs concurrently.
async fn fetch_concurrent() -> Result<(), Box<dyn std::error::Error>> {
// Create two independent futures.
let task1 = fetch_length("https://example.com/1");
let task2 = fetch_length("https://example.com/2");
// Wait for both to complete.
// The executor polls both futures, allowing overlap.
// If task1 is waiting for I/O, the executor polls task2.
let (len1, len2) = tokio::join!(task1, task2);
println!("Length 1: {:?}", len1);
println!("Length 2: {:?}", len2);
Ok(())
}
The tokio::join! macro takes multiple futures and returns a future that completes when all inputs complete. The macro generates code that polls each future in turn. If one returns Pending, the macro stores the waker and moves to the next future. This allows the executor to make progress on all tasks.
Convention: Use tokio::join! for a small, fixed number of futures. For dynamic collections, use tokio::task::JoinSet or futures::future::join_all. The macro expands to efficient code without heap allocations.
Pitfalls and compiler errors
Async code introduces new failure modes. The compiler catches some, but others require discipline.
If you forget to .await a future, the compiler rejects you with E0308 (mismatched types). You're holding a Future, not the result. The code compiles if you assign the future to a variable, but the work never runs until you poll it.
async fn bad_example() {
// This returns a Future, but we never await it.
// The fetch never happens.
let _future = fetch_data();
}
The cardinal sin of async is blocking the executor. If you call std::thread::sleep inside an async function, you block the entire executor thread. Other tasks starve. The executor cannot poll other futures because the thread is stuck in the blocking call. Use tokio::time::sleep instead, which yields to the executor.
Some futures capture &mut self and cannot be moved in memory. These futures are !Unpin. The compiler usually handles pinning automatically, but if you get an error about "cannot move out of pinned", you need to use Pin. Pinning guarantees that the future's address doesn't change, which is required for self-referential structs. You rarely need to touch Pin directly unless you're writing custom futures or low-level abstractions.
Never block the executor. If you must block, isolate it.
Decision: when to use what
Async Rust offers several tools for concurrency. Pick the right one for your pattern.
Use async fn when you're writing code that performs I/O or waits for external events and want to allow other work to proceed.
Use tokio::spawn when you want to run a future independently and don't need to wait for its result immediately. The spawned task runs until completion or cancellation.
Use tokio::join! when you need to wait for multiple futures to complete and collect their results together. The macro handles polling and error propagation.
Use block_on when you're in a synchronous context and need to run an async function to completion, like in main without #[tokio::main]. This blocks the current thread until the future finishes.
Use tokio::task::spawn_blocking when you have a CPU-bound operation or a blocking library call that would freeze the async executor. This runs the closure on a separate thread pool designed for blocking work.
Use async fn for I/O-bound work. Use spawn_blocking for CPU-bound or blocking work. The executor handles the rest.