Async and await
Write non-blocking IO with async/await syntax, understand the Future trait conceptually, and run async tasks with the Tokio runtime.
- Explain what a Future is and why async/await exists
- Write async functions and await their results
- Spawn concurrent tasks with tokio::spawn
- Know when to use async/await vs OS threads
OS threads are excellent for CPU-bound parallelism. But a web server that handles 10,000 concurrent connections can't spawn 10,000 threads — each thread uses 2–8 MB of stack space. Async/await solves this: it lets a small number of OS threads serve a huge number of concurrent IO-bound tasks, suspending each task while it waits for IO and resuming it when the data is ready.
Rust's async model is zero-cost: async functions compile to state machines with no heap allocation per suspension point — unlike Go goroutines, which have a small heap allocation per goroutine.
The Future trait
At the core of Rust's async model is the Future trait:
pub trait Future {
type Output;
fn poll(&mut self, cx: &mut Context<'_>) -> Poll<Self::Output>;
}poll() asks "are you done yet?" If the work is ready, it returns Poll::Ready(value). If not, it returns Poll::Pending and registers a waker that will notify the runtime when it should poll again.
You almost never implement Future directly. The async/await syntax compiles your async functions into state machines that implement Future automatically.
async functions and await
Mark a function async to make it return a Future:
async fn fetch_data() -> String {
// In real code, this would be an IO operation
String::from("some data")
}Use .await to suspend the current task until a Future resolves:
async fn process() -> String {
let data = fetch_data().await;
format!("Processed: {}", data)
}.await is only valid inside an async function or block. Calling an async fn does not run it immediately — it returns a Future. You must .await it (or pass it to a runtime) to make it run.
This is the key distinction from Python or JavaScript async: in Rust, calling fetch_data() does nothing — it returns an unstarted Future. Only .await drives it to completion. Forgetting .await is a common mistake; the compiler often warns about unused futures.
A runtime is required
The Rust standard library provides the Future trait and async/await syntax, but no built-in runtime. A runtime is a library that polls futures to completion, using an event loop and thread pool. The most widely-used runtime is Tokio:
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }Mark main as async with #[tokio::main]:
#[tokio::main]
async fn main() {
let result = process().await;
println!("{}", result);
}#[tokio::main] is a macro that creates a Tokio runtime and runs your async main inside it.
Running tasks concurrently with tokio::spawn
tokio::spawn starts an async task that runs concurrently with the current task — similar to thread::spawn but much lighter:
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let task1 = tokio::spawn(async {
sleep(Duration::from_millis(100)).await;
println!("task 1 done");
});
let task2 = tokio::spawn(async {
sleep(Duration::from_millis(50)).await;
println!("task 2 done");
});
task1.await.unwrap();
task2.await.unwrap();
println!("both done");
}Both tasks run concurrently. task2 finishes first because it sleeps for less time. Total elapsed time is ~100ms, not 150ms — the sleeps overlap.
tokio::spawn requires the future to be Send (safe to move between threads), because the Tokio runtime may run tasks on any thread in its pool. If you need to spawn a future that captures non-Send data, use tokio::task::spawn_local with a LocalSet.
async blocks
You can use async { ... } as an expression to create a local future without defining a whole function:
let future = async {
let a = fetch_data().await;
let b = fetch_data().await;
format!("{} + {}", a, b)
};
let result = future.await;When to use async vs threads
| Situation | Prefer |
|---|---|
| CPU-intensive computation | std::thread |
| Many concurrent IO operations (HTTP, DB, files) | async/await (Tokio) |
| Simple parallelism, small number of tasks | std::thread |
| Thousands of concurrent lightweight tasks | async/await |
| Interfacing with sync libraries | std::thread |
The two models can coexist: tokio::task::spawn_blocking runs a blocking (sync) operation on a dedicated thread pool without blocking the async runtime.
Check your understanding
Knowledge check
- 1.What does calling an async function in Rust return immediately?
- 2.Which workload is best served by OS threads rather than async tasks?
- 3.Rust's standard library includes a built-in async runtime, similar to Go's goroutine scheduler.
Do it yourself
Add Tokio to a new project and use tokio::join! to run two async operations concurrently and wait for both:
[dependencies]
tokio = { version = "1", features = ["full"] }#[tokio::main]
async fn main() {
let (a, b) = tokio::join!(
async { "result A" },
async { "result B" }
);
println!("{} {}", a, b);
}tokio::join! waits for all futures concurrently — unlike awaiting them sequentially, they overlap.
When the agent's away
# Add tokio to an existing project
cargo add tokio --features full
# Run async tests (tokio has its own test macro)
#[tokio::test]
async fn test_something() { ... }
cargo testWhere to go next
You've covered all four concurrency topics. Next module: ecosystem and tooling — deep-diving into Cargo workspaces, testing, and macros, the infrastructure that makes real Rust projects maintainable.