Asynchronous Rust
When to Use Async Rust
Choosing between asynchronous Rust (async/.await), native OS threads (std::thread), and data-parallel work-stealing pools (rayon) depends on whether your workload is I/O-bound with high concurrency, I/O-bound with low concurrency, or CPU-bound.
Workload Decision Guide
What is the primary bottleneck of your workload?
|
+-- 1. High-Concurrency I/O (100s to 100,000s of sockets, HTTP requests, timers, websockets)
| +---> Use Async Rust (`async` / `.await` + `tokio` / `smol`)
|
+-- 2. CPU-Bound Data Parallelism (Number crunching, image/audio processing, raytracing, sorting)
| +---> Use `std::thread::scope` or `rayon` (`par_iter()`)
|
+-- 3. Low-Concurrency I/O or CLI Tools (< 20–50 concurrent tasks, file utilities, compilers)
| +---> Use Synchronous Rust (`std::fs`, `std::net`, `std::thread`)
|
+-- 4. Mixed Workload (HTTP server that also performs heavy CPU encryption / image resizing)
+---> Use Async Rust for network I/O + offload CPU work to `spawn_blocking` / `rayon`!
Comprehensive Comparison Across Concurrency Models
| Criterion | Synchronous OS Threads (std::thread) |
Data Parallelism (rayon / thread::scope) |
Asynchronous Rust (async / .await) |
|---|---|---|---|
| Ideal task count | 1 to ~100 threads |
1 thread per CPU core |
100 to 1,000,000+ concurrent tasks |
| Primary bottleneck | Moderate blocking I/O or background actors | CPU cores & memory bandwidth | Network / timer / IPC wait time |
| Per-task memory overhead | 2 MiB virtual stack |
Tiny stack frames on fixed worker threads | ~64–512 bytes per Future state machine |
| Timeout & Cancellation | Hard (OS threads cannot be forcibly cancelled safely) | Cooperative or atomic flag | Built-in (select! / timeout drops the Future) |
| API & Type Complexity | Lowest (plain fn, &T, std::io::Read) |
Low (.par_iter().map(...)) |
Higher (Pin, Send bounds across .await, async traits) |
| Binary size & Compile time | Smallest and fastest to compile | Small | Larger when pulling in a full runtime |
Why Async Rust Excels at Timeouts, Hedged Requests, and Multiplexing
Even when an application only makes a handful of concurrent network requests, async Rust provides a capability that synchronous OS threads struggle to match: composable cancellation and timeouts.
If you race a network request against a 100ms timeout (select! { res = fetch() => ..., _ = sleep(100ms) => ... }):
- In Async Rust, if the 100ms timer wins, the
fetch()future is dropped immediately on the spot, closing the socket and freeing its memory with zero leaked background threads. - In Synchronous OS Threads, a thread blocked inside
socket.read()cannot be aborted from the outside without closing the raw file descriptor or waiting for the socket's own timeout to expire.
use std::future::Future;
use std::pin::{Pin, pin};
use std::task::{Context, Poll, Waker};
// A zero-allocation `Race2` (`select`) combinator that polls two futures and
// immediately drops/cancels the loser as soon as the winner returns `Poll::Ready`:
struct Race2<A, B> {
first: A,
second: B,
}
impl<T, A, B> Future for Race2<A, B>
where
A: Future<Output = T>,
B: Future<Output = T>,
{
type Output = T;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<T> {
let this = unsafe { self.get_unchecked_mut() };
if let Poll::Ready(val) = unsafe { Pin::new_unchecked(&mut this.first) }.poll(cx) {
return Poll::Ready(val);
}
if let Poll::Ready(val) = unsafe { Pin::new_unchecked(&mut this.second) }.poll(cx) {
return Poll::Ready(val);
}
Poll::Pending
}
}
fn main() {
let raced = Race2 {
first: std::future::pending::<&'static str>(),
second: std::future::ready("fallback timeout won; slow primary cancelled!"),
};
let mut pinned = pin!(raced);
let mut cx = Context::from_waker(Waker::noop());
println!("race outcome: {:?}", pinned.as_mut().poll(&mut cx));
}
race outcome: Ready("fallback timeout won; slow primary cancelled!")