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!")