Rust Async/Await: Asynchronous Programming Guide (2026)
Asynchronous programming in Rust allows a single thread to handle thousands of concurrent tasks efficiently, making it ideal for I/O-bound applications like web servers and network clients. Unlike threads (which are preempted by the OS and require ~2 MB of stack each), async tasks are lightweight and cooperatively scheduled on an executor. Rust's async/await syntax makes asynchronous code look almost synchronous, dramatically improving readability compared to callback-heavy approaches in other languages.
Key Takeaways
- Async enables high concurrency: Thousands of tasks on a few OS threads; much lower overhead than threads
async fnreturns aFuture: The function does not execute until awaited.awaitpauses execution: Suspends until the Future completes, allowing the executor to run other tasks- Async runtimes required:
tokio,async-std, orsmolprovide the executor; Rust's core library does not - Futures are lazy: Calling
async_function()does not start the work; only.awaitdoes
What Is Asynchronous Programming?
Asynchronous programming is a concurrency model where a single thread executes multiple logical tasks by switching between them as they become ready. When an async task waits for I/O (network request, file read, etc.), the thread can switch to another task instead of blocking.
Key characteristics:
- Non-blocking: I/O operations do not freeze the thread.
- Cooperative scheduling: Tasks yield control (via
.await) rather than being preempted. - Lightweight: A single thread can manage thousands of async tasks.
Compare this to threads, which are preemptively scheduled by the OS and each requires 1–2 MB of stack memory. A single machine can run thousands of async tasks but only dozens of threads efficiently.
How Do async fn and .await Work?
An async fn is syntax sugar for a function that returns a Future. A Future is a type that represents a value that will be available at some point in the future.
async fn hello() {
println!("Hello, world!");
}
fn main() {
// Calling async fn returns a Future immediately (no work happens yet)
let future = hello(); // future is not awaited, so hello() does not print yet
// To actually run the code, we need an async runtime
}
The Future is inert until awaited. You use the .await operator to pause the current async function and let the executor run the awaited Future:
async fn hello() {
println!("Hello from hello!");
}
async fn main_logic() {
println!("Starting...");
hello().await; // Pauses here until hello() completes
println!("Done!");
}
If you just call hello() without .await, the function body never executes, and the Future is dropped.
How Do You Set Up and Run an Async Runtime?
Rust's standard library provides the Future trait and async syntax, but not an executor. You need an async runtime. The most popular is tokio.
Using tokio with the #[tokio::main] Macro
Add tokio to Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["full"] }
Then use the #[tokio::main] attribute:
use tokio;
async fn fetch_data() {
println!("Fetching data...");
}
#[tokio::main]
async fn main() {
fetch_data().await;
println!("Done!");
}
What #[tokio::main] does:
- Transforms
maininto an async function. - Sets up the
tokioruntime (spawning a thread pool by default). - Blocks on the future returned by
main, running all.awaitpoints until completion.
Under the hood, it expands to:
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { /* your async code */ })
}
How Do You Spawn Concurrent Tasks with tokio::spawn?
To run multiple async tasks concurrently, use tokio::spawn:
use tokio;
async fn task(id: u32) {
println!("Task {} started", id);
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!("Task {} completed", id);
}
#[tokio::main]
async fn main() {
let handle1 = tokio::spawn(task(1));
let handle2 = tokio::spawn(task(2));
handle1.await.unwrap();
handle2.await.unwrap();
}
tokio::spawn returns a JoinHandle immediately. Awaiting the handle waits for the task to complete. Both tasks run concurrently (the output will be interleaved).
What Is the Difference Between Async and Threads in Rust?
| Aspect | Async | Threads |
|---|---|---|
| Memory per task | ~64 bytes | ~2 MB |
| Tasks per core | 10,000s | 10s–100s |
| Context switch | Cooperative (at .await points) | Preemptive (OS controlled) |
| Overhead | Very low | Moderate to high |
| Ideal for | I/O-bound work (web servers, APIs) | CPU-bound work, or tasks needing true parallelism |
| Syntax | async/.await | Closures, move capturing |
Use async for I/O-bound tasks where you have many concurrent operations (e.g., 1,000 simultaneous HTTP requests). Use threads for CPU-bound tasks or when you need true parallelism across multiple cores.
How Do You Handle Multiple Concurrent Futures?
To wait for multiple futures to complete, use tokio::join! or futures::join_all:
Using tokio::join!
use tokio;
async fn fetch_user() -> String {
"Alice".to_string()
}
async fn fetch_posts() -> Vec<String> {
vec!["Post 1".to_string()]
}
#[tokio::main]
async fn main() {
let (user, posts) = tokio::join!(fetch_user(), fetch_posts());
println!("User: {}, Posts: {:?}", user, posts);
}
tokio::join! waits for all futures in parallel and returns a tuple of results.
Using futures::join_all for Dynamic Collections
use futures::future;
async fn task(id: u32) -> u32 {
id * 2
}
#[tokio::main]
async fn main() {
let tasks = vec![
task(1),
task(2),
task(3),
];
let results = future::join_all(tasks).await;
println!("{:?}", results); // [2, 4, 6]
}
What Are Common Async Patterns?
Timeout
Use tokio::time::timeout to add a time limit:
use tokio::time::{timeout, Duration};
async fn slow_operation() {
tokio::time::sleep(Duration::from_secs(5)).await;
}
#[tokio::main]
async fn main() {
match timeout(Duration::from_secs(2), slow_operation()).await {
Ok(_) => println!("Completed"),
Err(_) => println!("Timed out"),
}
}
Select Multiple Futures
Use tokio::select! to run multiple futures and respond to whichever completes first:
use tokio;
#[tokio::main]
async fn main() {
tokio::select! {
_ = tokio::time::sleep(tokio::time::Duration::from_secs(1)) => {
println!("Sleep finished first");
}
_ = some_async_task() => {
println!("Task finished first");
}
}
}
async fn some_async_task() {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
What Pitfalls Should You Avoid?
- Forgetting
.await: Calling an async function without.awaitdoes nothing. - Blocking the executor: Do not call blocking operations (e.g.,
std::fs::read_file) inside async functions. Usetokio::fsortokio::task::spawn_blocking. - Not handling panics in
spawn: A panicked task does not crash the entire program; always handle theResultfromJoinHandle::await. - Holding locks across
.awaitpoints: Mutex locks can deadlock if held while awaiting. Use message passing or lock-free structures instead.
Frequently Asked Questions
Can you use .await in non-async functions?
No. .await can only appear inside an async fn or async block. If you need to call async code from synchronous code, wrap it in tokio::runtime::Runtime::block_on().
What happens if an async task panics?
The panic is caught and stored in the JoinHandle. Awaiting the handle will return Err(JoinError). The entire runtime does not crash. Always check the result:
match handle.await {
Ok(result) => println!("Task succeeded: {:?}", result),
Err(e) => println!("Task panicked or was cancelled: {:?}", e),
}
How do you cancel an async task?
Dropping a JoinHandle does not cancel the task; it just stops awaiting it. To actually cancel, check for cancellation signals (e.g., tokio::sync::broadcast or CancellationToken from the tokio_util crate).
Is async/await faster than threads?
For I/O-bound workloads with high concurrency, yes—async is much faster (lower memory, less overhead). For CPU-bound work, threads (or rayon for parallelism) are better because they can run on multiple cores.
Which async runtime should I use?
tokio is the most popular and mature. async-std is simpler and closer to Rust's standard library API. smol is minimal and composable. Most production code uses tokio.