The async revolution in Rust
If you've built web services or data-heavy applications in Rust, you've likely hit a wall: synchronous code forces you to wait for I/O operations, wasting CPU cycles and limiting throughput. Rust async programming: a practical guide for developers shows us how to handle thousands of concurrent operations on a single thread without the complexity of threads themselves. In this guide, we'll work through async/await syntax, the Tokio runtime, spawning concurrent tasks, and the critical rule of never blocking the async executor.
Async functions—Return futures that run when awaited
An async function in Rust doesn't execute immediately. Instead, it returns a Future trait object that describes work to be done. The actual execution only happens when you call .await on it. This is fundamental: marking a function async is a promise that it can be paused and resumed without blocking the current thread.
Consider a scenario where we fetch user data from an API and log the response:
async fn fetch_user(id: u32) -> anyhow::Result<String> {
let url = format!("https://api.example.com/users/{}", id);
let response = reqwest::get(&url).await?;
let body = response.text().await?;
Ok(body)
}
async fn main() -> anyhow::Result<()> {
let user_data = fetch_user(42).await?;
println!("User: {}", user_data);
Ok(())
}Without the .await, fetch_user(42) returns a future but does nothing. The .await expression yields control to the runtime, which polls the future until it completes. This is where the magic happens: while waiting for the network response, the runtime can execute other tasks.
The key insight for senior developers: every .await point is a potential suspension. The compiler ensures that all data held across an .await must be Send if you want to move the future between threads. This is why Rust's ownership system and async work so well together—safety is built in.
Tokio runtime—The most popular async runtime
A runtime is the scheduler that drives your futures forward. Tokio is the de facto standard in the Rust ecosystem, used in production by companies running trading platforms, ride-hailing services, and high-scale SaaS products. It provides a multi-threaded work-stealing scheduler, timer management, and I/O integration via mio.
The simplest way to start a Tokio program is the #[tokio::main] attribute macro:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let body = reqwest::get("https://example.com").await?.text().await?;
println!("{}", body.len());
Ok(())
}This macro expands to create a runtime, block on the async main function, and clean up afterwards. Under the hood, it's equivalent to:
fn main() -> anyhow::Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let body = reqwest::get("https://example.com").await?.text().await?;
println!("{}", body.len());
Ok(())
})
}For production services, you may want to customize the runtime. Use tokio::runtime::Builder to tune worker threads, stack size, or I/O driver configuration:
use tokio::runtime;
fn main() -> anyhow::Result<()> {
let rt = runtime::Builder::new_multi_thread()
.worker_threads(4)
.thread_name("worker")
.build()?;
rt.block_on(async {
println!("Running on custom runtime");
});
Ok(())
}Tokio's scheduler is work-stealing: when one worker thread runs out of tasks, it steals from others, keeping load balanced. This efficiency is why services handling millions of concurrent connections use Tokio.
Concurrent tasks—tokio::spawn and join!
Awaiting a single future is useful, but the power of async lies in concurrency. You can launch multiple tasks that progress independently without waiting for one to finish before starting another.
Use tokio::spawn to launch a task on the runtime. It returns a JoinHandle that you can await later:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let handle1 = tokio::spawn(async {
println!("Task 1 starting");
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
println!("Task 1 done");
42
});
let handle2 = tokio::spawn(async {
println!("Task 2 starting");
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!("Task 2 done");
"hello"
});
let (result1, result2) = tokio::join!(handle1, handle2);
println!("Results: {:?}, {:?}", result1?, result2?);
Ok(())
}The tokio::join! macro awaits multiple futures concurrently. Notice that both tasks start immediately; Task 2 finishes first even though we define Task 1 first. Total wall-clock time is ~2 seconds, not 3.
For collecting results from a dynamic number of tasks, use futures::stream::FuturesUnordered or FuturesOrdered:
use futures::stream::{FuturesUnordered, StreamExt};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut handles = FuturesUnordered::new();
for i in 0..5 {
let handle = tokio::spawn(async move {
println!("Processing {}", i);
i * 2
});
handles.push(handle);
}
while let Some(result) = handles.next().await {
println!("Got: {:?}", result?);
}
Ok(())
}This pattern is common in microservices: spawn a task per incoming request, collect results as they complete, and respond to clients without waiting for all tasks. It scales far better than thread-per-request.
Do not block—Use spawn_blocking for CPU or blocking I/O
The golden rule: never call blocking operations on a Tokio task. Blocking functions freeze the entire worker thread, preventing other tasks from making progress. This destroys throughput.
Common blocking operations include:
- Synchronous I/O:
std::fs::read_to_string, database drivers without async support. - CPU-intensive work: hashing, encryption, image processing.
- Thread synchronization:
std::sync::Mutexheld across.awaitpoints.
For these, use tokio::task::spawn_blocking:
use std::fs;
use std::time::Instant;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let start = Instant::now();
// Spawn blocking work on a dedicated thread pool
let file_content = tokio::task::spawn_blocking(|| {
fs::read_to_string("large_file.txt")
}).await??;
println!("Read {} bytes in {:?}", file_content.len(), start.elapsed());
Ok(())
}Tokio maintains a separate thread pool for blocking tasks. This keeps async threads free to service other requests. For a web service handling 10,000 concurrent connections, one blocking operation per request on the main pool would starve others—spawning blocking work elsewhere solves it.
If you're using a database, prefer async drivers: sqlx with the postgres feature, mongodb's async API, or redis-rs with the aio module. They integrate seamlessly with Tokio.
Common mistakes
Mistake 1: Forgetting .await
The compiler will warn you, but it's easy to miss. A future without .await compiles but does nothing, leading to silent logic bugs. Always check that you're actually awaiting.
Mistake 2: Holding a Mutex across .await
// WRONG: Can deadlock or panic
let guard = mutex.lock().unwrap();
some_async_operation().await;
drop(guard);Use tokio::sync::Mutex instead, which is async-aware:
// RIGHT
let guard = async_mutex.lock().await;
some_async_operation().await;
drop(guard);Mistake 3: Spawning unbounded tasks
If you spawn a task for each incoming request without limits, you'll exhaust memory under load. Use a semaphore or bounded channel to control concurrency:
let semaphore = Arc::new(tokio::sync::Semaphore::new(100));
for _ in 0..10000 {
let permit = semaphore.acquire().await?;
tokio::spawn(async move {
// Work here
drop(permit); // Release slot
});
}Key takeaways
- Async functions return futures—they don't run until you
.awaitthem. - Tokio is the standard runtime—use
#[tokio::main]to bootstrap andtokio::runtime::Builderfor tuning. - Launch concurrent tasks with
tokio::spawn—progress independently without threads. - Never block the async executor—use
spawn_blockingfor I/O and CPU work, or async drivers for databases. - Await multiple futures with
tokio::join!—run them concurrently and collect results in one expression. - Be careful with locks and unbounded concurrency—use async-aware primitives and bound spawning with semaphores.






Comments
Loading comments…