The problem with waiting
You're building a chat bot. A user sends a message asking for a joke. Your bot needs to fetch the joke from a remote API, look up the user's preferences in a local database, and format the response. The API takes 200 milliseconds. The database takes 5 milliseconds.
If you write this synchronously, the thread blocks for 200 milliseconds waiting for the network. During that time, the CPU sits idle. If you have 1,000 users, you need 1,000 threads to keep them all responsive. Threads are heavy. Each one consumes memory for its stack, and the OS scheduler struggles to manage thousands of them. Your server crashes or slows to a crawl.
You need a way to pause the joke-fetching, switch to the database work, and jump back to the joke exactly when the network data arrives. You need to handle thousands of concurrent tasks on a handful of threads without blocking. That's what async programming gives you.
Async is cooperative multitasking
Async programming in Rust is a concurrency model where tasks voluntarily yield control whenever they are waiting for an external event. The CPU doesn't interrupt your code. Your code tells the runtime, "I'm waiting for this I/O. Go do something else until it's ready."
Think of a single cook in a busy kitchen. The cook is your CPU thread. Orders are tasks. Some orders require waiting for the oven (network or disk). The cook doesn't stand staring at the oven. The cook puts the tray in, writes down "check oven in 5 minutes," and grabs the next order. When the timer dings, the cook returns to that tray.
In Rust, async marks a function that can be paused. await is the point where the cook puts the tray in the oven and steps away. The runtime manages the timers and switches between tasks. This is cooperative multitasking. The runtime only switches tasks when your code hits an await point. If you do heavy CPU work without yielding, you starve the other tasks.
Futures are lazy state machines
Here's the twist that trips up everyone coming from Python or JS: calling an async function does not run it. It returns a value called a Future. A Future is a lazy computation. It's a recipe, not the cooked meal.
When you write an async fn, the compiler transforms it into a state machine. It generates a struct that holds the local variables and an enum representing the current state. The state machine has states like Start, WaitingForSleep, and Done. The Future is an instance of that struct. Nothing happens until you .await the future or pass it to the runtime.
use tokio;
/// Runs a simple async task that sleeps and prints.
#[tokio::main]
async fn main() {
println!("Starting task");
// Creates a future that sleeps for 1 second.
// The future is lazy; it doesn't start sleeping yet.
let sleep_future = tokio::time::sleep(tokio::time::Duration::from_secs(1));
// .await polls the future.
// This suspends main() until the sleep completes.
sleep_future.await;
println!("Task completed");
}
If you forget the .await, the code compiles. The future sits there unused. The compiler emits a warning about an unused future, but in complex code, this can slip by. The task never runs. Async is lazy. Await or it vanishes.
The runtime drives the loop
A Future needs a runtime to drive it. The runtime is a loop that polls futures. Polling asks the future, "Are you done yet?" The future returns Poll::Ready(value) if it's finished, or Poll::Pending if it needs more time.
When a future returns Pending, it registers a Waker with the runtime. The Waker is a callback that tells the runtime to poll this future again when the underlying event happens. For a sleep, the timer registers the waker. When the timer fires, it wakes the runtime. The runtime polls the future again. This time the future returns Ready.
use tokio;
use std::time::Instant;
/// Fetches two resources concurrently and measures total time.
#[tokio::main]
async fn main() {
let start = Instant::now();
// tokio::join! creates a future that waits for both inputs.
// The runtime polls both futures concurrently.
// When one sleeps, the runtime switches to the other.
let (result_a, result_b) = tokio::join!(
do_work("A", 1000),
do_work("B", 1000)
);
println!("A: {}, B: {}", result_a, result_b);
println!("Total time: {:?}", start.elapsed());
}
/// Simulates an async I/O operation.
async fn do_work(name: &str, ms: u64) -> String {
// Yields control to the runtime for the specified duration.
tokio::time::sleep(tokio::time::Duration::from_millis(ms)).await;
format!("Task {} done", name)
}
In this example, do_work("A", 1000) and do_work("B", 1000) run concurrently. They overlap. The total time is about 1 second, not 2 seconds. The runtime polls A, A sleeps and yields. The runtime polls B, B sleeps and yields. The runtime waits for the timers. When A's timer fires, the runtime polls A, A completes. When B's timer fires, the runtime polls B, B completes. join! collects both results.
Pitfalls and compiler errors
Async Rust has sharp edges. The compiler catches many mistakes, but some require understanding the model.
Blocking the runtime. The cardinal sin of async is calling a synchronous function that blocks. If you call std::thread::sleep inside an async function, you freeze the entire thread. The runtime can't poll other tasks. Your concurrency collapses. Always use async equivalents like tokio::time::sleep. If you must call a blocking function, offload it to a thread pool using tokio::task::spawn_blocking.
The Send trait. Futures often need to be Send to move across threads. The Send trait means a value can be transferred to another thread safely. If you hold a Rc inside a future, it won't be Send. Rc uses reference counting that isn't thread-safe. The compiler rejects this with E0277 (trait bound not satisfied). Use Arc instead. Arc provides atomic reference counting that works across threads.
Locks and awaits. Never .await while holding a std::sync::Mutex. You hold the lock, you yield the thread, the runtime moves the task to another thread, and you try to lock again. This can deadlock or cause starvation. Use tokio::sync::Mutex if you need to share state across tasks and the lock might be held across an .await point. tokio::sync::Mutex releases the lock when you yield and reacquires it when you resume.
Lifetimes in async. Async functions usually require 'static bounds for spawned tasks. If you spawn a task that borrows local data, the data might be dropped before the task finishes. The compiler enforces this. You can't hold references across .await points unless the references are valid for the entire duration of the future. This is why async code often boxes data or uses Arc to manage ownership.
Convention aside: The community convention is to use Rc::clone(&data) instead of data.clone() when cloning an Rc. Both compile and work. The explicit form signals to readers that you're cloning the reference, not the underlying data. It prevents confusion with deep clones.
Decision matrix
Use async/await for I/O-bound workloads where you need to handle thousands of concurrent connections with low memory overhead. Use threads for CPU-bound tasks where the work involves heavy computation and doesn't block on external resources. Use tokio::spawn when you need to detach a task and let it run independently without waiting for the result. Use tokio::join! when you need to run multiple tasks concurrently and wait for all of them to finish before proceeding. Use std::sync::Mutex for protecting shared state within a single thread or when the critical section is tiny and never involves .await. Use tokio::sync::Mutex when you must share state across tasks and the lock might be held across an .await point. Use Arc<T> for shared ownership in async code that crosses thread boundaries. Use Rc<T> only for single-threaded async code where thread safety is impossible or unnecessary.