Async Rust

Async Rust lets one thread juggle thousands of concurrent I/O operations. An async fn returns a future, a value representing work that will finish later. .await suspends the current task until that future is ready, freeing the thread to run other tasks in the meantime. It's how Rust web servers such as Axum, database drivers, and network proxies handle huge numbers of connections with small memory footprints.

Rust's design differs from JavaScript's or Python's asyncio in two ways. Futures are lazy (nothing happens until they're polled), and the language ships no built-in runtime: you choose an executor, almost always Tokio. Combined with ownership rules, this gives zero-cost abstractions and data-race freedom, and some of Rust's steepest learning curves.

TL;DR

Quick Example

Fetch several URLs concurrently with a timeout, bounded by a semaphore:

Core Concepts

Futures Are Lazy State Machines

The compiler turns each async fn into a state machine implementing Future. Calling it only builds that state machine. The work happens when an executor polls it. At each .await whose result isn't ready, the future returns Poll::Pending and registers a waker; when the I/O completes, the waker tells the executor to poll again.

Because there's no hidden allocation or scheduler, async Rust is "zero-cost": a future is just a struct sized to hold its state across await points.

Runtimes

Libraries tend to target Tokio, so mixing runtimes causes friction. Pick Tokio unless you have a specific reason not to.

Concurrency Within a Task vs Parallel Tasks

Send and 'static

Tokio's multi-threaded runtime may move a task between threads at any .await, so a spawned future must be Send: everything it holds across an await must be safe to send between threads. It must also be 'static: it can't borrow local variables that might be dropped before it finishes. In practice:

Channels and Sync Primitives

Tokio provides async-aware mpsc (many producers, one consumer), oneshot (single reply), broadcast, and watch (latest value) channels, plus Mutex, RwLock, Semaphore, and Notify. Message passing with channels often avoids shared-state locking entirely. See concurrency patterns.

Cancellation

Dropping a future cancels it: execution simply stops at whatever .await it was suspended on. This happens with select! losers, timeouts, and aborted tasks. It's cheap and powerful, but it means any .await is a potential exit point:

Best Practices

Keep Blocking Work Off the Runtime

CPU-heavy work and blocking calls stall a worker thread and starve every task scheduled on it. Use tokio::task::spawn_blocking for blocking I/O and short CPU bursts, and a dedicated thread pool (such as rayon) for heavy computation.

Bound Concurrency and Add Timeouts

Unbounded spawn in a loop can exhaust memory, sockets, or downstream services. Use a Semaphore, bounded channels, or buffer_unordered(n) on streams. Wrap external calls in tokio::time::timeout.

Use Structured Task Management

Prefer JoinSet or join! so tasks have an owner that observes their results and errors. Detached spawn calls whose JoinHandle is dropped can fail silently.

Instrument With tracing

Use the tracing crate with spans per request or task, and tokio-console to inspect live tasks: which are busy, which are stuck waiting, and which hold resources.

Common Mistakes

Blocking Inside Async Code

Holding a Std Mutex Guard Across .await

Forgetting That Futures Are Lazy

FAQ

Why doesn't Rust include an async runtime?

Different environments need different runtimes: a multi-threaded server, a single-threaded CLI, an embedded microcontroller with no OS. Keeping the runtime out of the language lets each domain choose, and keeps the core language small. The cost is ecosystem fragmentation, largely resolved in practice by Tokio's dominance.

When should I use async instead of threads?

Async shines for many concurrent I/O-bound operations, such as network servers, proxies, and crawlers, where thousands of OS threads would be wasteful. For a handful of concurrent jobs, or CPU-bound work, plain threads (or rayon) are simpler and just as fast.

Can I use async functions in traits?

Yes. Since Rust 1.75, async fn works in traits for static dispatch. Using such traits as dyn Trait or requiring Send futures from them still needs workarounds, such as the async-trait crate or trait-variant, which the ecosystem is steadily reducing.

What's the difference between join! and spawn?

join! runs futures concurrently within the current task, on the same thread, and can borrow local data. spawn hands a future to the runtime as a separate task that can run in parallel on another thread, and therefore requires Send + 'static.

Related Topics

References