Async Rust
Async Rust lets you write concurrent code that handles thousands of connections without threads. Futures are lazy, zero-cost, and composable - but the ecosystem has a learning curve.
Futures and async/await
A Future is a value that will resolve later. async fn returns a future. .await suspends execution until the future completes.
async fn fetch_data(url: &str) -> Result<String, reqwest::Error> {
let response = reqwest::get(url).await?;
let body = response.text().await?;
Ok(body)
}
Gotcha: Futures are lazy in Rust. They do nothing until polled. Just calling
fetch_data("...")returns a future but doesn’t start the request. You must.awaitit or spawn it on a runtime.
Tokio: The Standard Runtime
Rust’s standard library defines the Future trait but doesn’t include a runtime. Tokio is the de facto choice.
[dependencies]
tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
let result = fetch_data("https://httpbin.org/get").await;
match result {
Ok(body) => println!("{body}"),
Err(e) => eprintln!("Error: {e}"),
}
}
#[tokio::main] is a macro that sets up the multi-threaded runtime. It expands to:
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async { /* your code */ })
}
Spawning Tasks
tokio::spawn - fire-and-forget concurrent work
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
sleep(Duration::from_secs(1)).await;
"task complete"
});
println!("Doing other work...");
let result = handle.await.unwrap(); // wait for the spawned task
println!("{result}");
}
JoinSet - manage multiple spawned tasks
use tokio::task::JoinSet;
async fn fetch_all(urls: Vec<String>) -> Vec<String> {
let mut set = JoinSet::new();
for url in urls {
set.spawn(async move {
reqwest::get(&url).await?.text().await
});
}
let mut results = Vec::new();
while let Some(res) = set.join_next().await {
match res {
Ok(Ok(body)) => results.push(body),
Ok(Err(e)) => eprintln!("Request failed: {e}"),
Err(e) => eprintln!("Task panicked: {e}"),
}
}
results
}
Tip:
JoinSetis the modern way to manage groups of tasks. It replaces the old pattern of collectingVec<JoinHandle>and awaiting each one.
Concurrent Execution
tokio::join! - run futures concurrently, wait for all
let (users, posts, comments) = tokio::join!(
fetch_users(),
fetch_posts(),
fetch_comments(),
);
tokio::select! - race futures, take the first to complete
use tokio::time::{sleep, Duration};
async fn fetch_with_timeout() -> Result<String, &'static str> {
tokio::select! {
result = fetch_data("https://slow-api.example.com") => {
result.map_err(|_| "request failed")
}
_ = sleep(Duration::from_secs(5)) => {
Err("request timed out")
}
}
}
Gotcha:
select!cancels the other futures when one completes. Make sure your futures handle cancellation safely - drop any partially-acquired resources.
Async Closures (Rust 1.88+)
Async closures are now stable. They capture variables properly and return futures.
let urls = vec!["https://a.com", "https://b.com"];
// Async closure - captures `client` by reference
let client = reqwest::Client::new();
let fetch = async |url: &str| -> Result<String, reqwest::Error> {
client.get(url).send().await?.text().await
};
for url in &urls {
let body = fetch(url).await?;
println!("{}: {} bytes", url, body.len());
}
Before Rust 1.88, you had to use workarounds with move closures returning async blocks. Async closures simplify this significantly.
Async Functions in Traits (Native)
Async functions in traits work natively without the async-trait crate.
trait DataStore {
async fn get(&self, key: &str) -> Option<String>;
async fn set(&self, key: &str, value: &str) -> Result<(), Box<dyn std::error::Error>>;
}
struct RedisStore { /* ... */ }
impl DataStore for RedisStore {
async fn get(&self, key: &str) -> Option<String> {
// actual Redis lookup
todo!()
}
async fn set(&self, key: &str, value: &str) -> Result<(), Box<dyn std::error::Error>> {
// actual Redis write
todo!()
}
}
Tip: If you need trait objects (
dyn DataStore), you’ll need thetrait_variantcrate or manual desugaring, since async methods aren’t directly object-safe.
Channels
Tokio provides async channels for task communication.
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<String>(100); // buffer size 100
// Producer
let tx2 = tx.clone();
tokio::spawn(async move {
tx2.send("hello from task".to_string()).await.unwrap();
});
// Send from main
tx.send("hello from main".to_string()).await.unwrap();
drop(tx); // drop sender to close the channel
// Consumer
while let Some(msg) = rx.recv().await {
println!("Received: {msg}");
}
}
| Channel | Use case |
|---|---|
mpsc | Multiple producers, single consumer |
oneshot | Send exactly one value (request/response) |
broadcast | Multiple producers, multiple consumers (pub/sub) |
watch | Single producer, multiple consumers (latest value) |
When NOT to Use Async
Async Rust adds complexity. Use it when you need it, not by default.
| Use async | Use threads or sync |
|---|---|
| Many concurrent I/O operations (HTTP, DB, files) | CPU-heavy computation |
| Web servers handling thousands of connections | Simple CLI tools |
| Network clients making parallel requests | Batch processing with limited concurrency |
| Event-driven applications | Anything where you have < 100 concurrent operations |
Gotcha: Mixing blocking code with async is a common mistake. Never call
std::thread::sleepor blocking I/O in an async context. Usetokio::task::spawn_blockingfor blocking work inside an async runtime.
// Wrong: blocks the async executor
let data = std::fs::read_to_string("big-file.txt")?;
// Right: offload to a blocking thread
let data = tokio::task::spawn_blocking(|| {
std::fs::read_to_string("big-file.txt")
}).await??;
// Or use Tokio's async file I/O
let data = tokio::fs::read_to_string("big-file.txt").await?;
Exercises
- Write an async function that fetches 3 URLs concurrently using
tokio::join!and returns the total byte count - Build a simple web scraper with
JoinSetthat fetches N pages in parallel with a concurrency limit (hint: limitJoinSetsize) - Use
tokio::select!to implement a timeout wrapper:async fn with_timeout<T>(future: impl Future<Output = T>, secs: u64) -> Option<T>
Next: Pattern Matching | Ecosystem