Async/Await in Rust
Learn async Rust — futures, async/await syntax, the tokio runtime, spawning tasks, channels, and common async patterns.
What you'll learn
- ✓How async/await works in Rust (futures, polling, runtimes)
- ✓Setting up and using the tokio runtime
- ✓Spawning tasks, joining, and selecting
- ✓Async channels and common patterns
Prerequisites
- •Solid Rust fundamentals (ownership, traits, generics)
- •Understanding of concurrency concepts
Rust’s async/await lets you write concurrent code that looks sequential. Unlike Go’s goroutines or JavaScript’s event loop, Rust’s async model is zero-cost — futures do nothing until polled, and the compiler transforms async functions into state machines.
The basics
An async fn returns a Future instead of executing immediately.
async fn fetch_data() -> String {
"data".to_string()
}
The future does nothing until you .await it:
#[tokio::main]
async fn main() {
let data = fetch_data().await;
println!("{data}");
}
Setting up tokio
Add tokio to Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["full"] }
The #[tokio::main] macro sets up the runtime:
#[tokio::main]
async fn main() {
println!("Running on tokio!");
}
Making HTTP requests
use reqwest;
async fn fetch_page(url: &str) -> Result<String, reqwest::Error> {
let body = reqwest::get(url).await?.text().await?;
Ok(body)
}
#[tokio::main]
async fn main() {
match fetch_page("https://httpbin.org/get").await {
Ok(body) => println!("Response: {}", &body[..100]),
Err(e) => eprintln!("Error: {e}"),
}
}
Spawning tasks
tokio::spawn runs a future on a new task (similar to goroutines).
use tokio::task;
#[tokio::main]
async fn main() {
let handle = task::spawn(async {
// runs concurrently
expensive_computation().await
});
// do other work...
let result = handle.await.unwrap();
println!("Result: {result}");
}
Running tasks in parallel
use tokio::join;
async fn fetch_users() -> Vec<String> {
vec!["Alice".into(), "Bob".into()]
}
async fn fetch_posts() -> Vec<String> {
vec!["Post 1".into(), "Post 2".into()]
}
#[tokio::main]
async fn main() {
let (users, posts) = join!(fetch_users(), fetch_posts());
println!("Users: {users:?}, Posts: {posts:?}");
}
join! runs both futures concurrently and waits for both to complete.
Select: racing futures
tokio::select! runs multiple futures and acts on the first one to complete.
use tokio::{select, time::{sleep, Duration}};
#[tokio::main]
async fn main() {
select! {
_ = sleep(Duration::from_secs(1)) => {
println!("Timeout!");
}
result = fetch_data() => {
println!("Got data: {result}");
}
}
}
Async channels
Tokio provides mpsc (multi-producer, single-consumer) channels.
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(32);
let tx2 = tx.clone();
tokio::spawn(async move {
tx.send("from task 1").await.unwrap();
});
tokio::spawn(async move {
tx2.send("from task 2").await.unwrap();
});
while let Some(msg) = rx.recv().await {
println!("Received: {msg}");
}
}
oneshot channels
For a single response:
use tokio::sync::oneshot;
#[tokio::main]
async fn main() {
let (tx, rx) = oneshot::channel();
tokio::spawn(async move {
tx.send(42).unwrap();
});
let value = rx.await.unwrap();
println!("Got: {value}");
}
Shared state with Mutex
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(tokio::spawn(async move {
let mut lock = counter.lock().await;
*lock += 1;
}));
}
for handle in handles {
handle.await.unwrap();
}
println!("Counter: {}", *counter.lock().await);
}
Use tokio::sync::Mutex for async code, std::sync::Mutex for sync code.
Timeouts
use tokio::time::{timeout, Duration};
async fn slow_operation() -> String {
tokio::time::sleep(Duration::from_secs(10)).await;
"done".to_string()
}
#[tokio::main]
async fn main() {
match timeout(Duration::from_secs(3), slow_operation()).await {
Ok(result) => println!("Got: {result}"),
Err(_) => println!("Timed out!"),
}
}
Streams
Streams are the async equivalent of iterators.
use tokio_stream::{self as stream, StreamExt};
#[tokio::main]
async fn main() {
let mut stream = stream::iter(vec![1, 2, 3, 4, 5])
.map(|n| n * 2)
.filter(|n| *n > 4);
while let Some(value) = stream.next().await {
println!("{value}");
}
}
Common pitfalls
Holding a MutexGuard across an await point. The guard is not Send, so the task cannot be moved to another thread.
// Bad
let lock = mutex.lock().await;
some_async_fn().await; // lock held across await!
drop(lock);
// Good
{
let mut lock = mutex.lock().await;
*lock += 1;
} // lock dropped before await
some_async_fn().await;
Blocking the async runtime. Never call blocking operations (file I/O, CPU-heavy work) directly in async code. Use tokio::task::spawn_blocking.
let result = tokio::task::spawn_blocking(|| {
std::fs::read_to_string("large_file.txt")
}).await.unwrap();
Summary
Rust async/await gives you concurrent I/O without garbage collection or runtime overhead. Use tokio as your runtime, spawn for concurrency, join! for parallel work, select! for racing, and channels for communication. Avoid blocking the runtime and holding locks across await points.
Related articles
- Rust Rust Async Programming with Tokio: Getting Started
Learn async Rust with Tokio from scratch. Covers async/await syntax, spawning tasks, channels, TCP servers, and common pitfalls with working examples.
- Rust Tokio vs async-std: Choosing a Rust Async Runtime
A practical comparison of Tokio and async-std covering performance, ecosystem, API design, and migration considerations for async Rust projects.
- Rust Rust Async Runtime Internals: How It Works Under the Hood
Explore how Rust async runtimes work internally, including futures, wakers, executors, and the reactor pattern that powers async I/O.
- Rust Rust Pin and Unpin Explained: Why They Exist
Understand Pin, Unpin, and self-referential types in Rust. Learn why Pin exists, when you need it, and how to work with pinned data safely.