Async & Concurrency
Core Question
Is this I/O-bound (async) or CPU-bound (threads/spawn_blocking)?
Async is for waiting on external things (network, disk,
timers). CPU-heavy work blocks the runtime — move it to
spawn_blocking or a dedicated thread pool.
Error → Design Question
| Symptom |
Don't Just Say |
Ask Instead |
Future is not Send |
"Add Send bound" |
Why does data cross a thread boundary? Can you restructure? |
| Deadlock with Mutex |
"Use tokio::sync::Mutex" |
Should you hold a lock across await at all? |
| Task hangs forever |
"Add timeout" |
Is there a cancellation path? |
| Channel fills up |
"Make it unbounded" |
What's the backpressure strategy? |
Quick Decisions
| Situation |
Reach For |
Why |
| Run independent futures concurrently |
tokio::join! |
Runs all, returns all results |
| Run fallible futures, fail fast |
tokio::try_join! |
Returns first error, drops rest |
| Race futures, handle first completion |
tokio::select! |
Cancel losers automatically |
| Dynamic number of spawned tasks |
JoinSet |
Add/remove tasks, collect results |
| CPU-intensive work in async context |
spawn_blocking |
Moves to blocking thread pool |
| File I/O in async code |
tokio::fs |
Non-blocking file operations |
| Graceful shutdown |
CancellationToken |
Hierarchical cancellation |
| One producer, one consumer, one message |
oneshot |
Request-response pattern |
| Work queue (multiple producers) |
mpsc (bounded) |
Backpressure built in |
| All subscribers get all messages |
broadcast |
Pub/sub pattern |
| Share latest value, skip intermediate |
watch |
Config updates, state sharing |
| Shared read-only data across tasks |
Arc<T> |
Clone Arc, not the data |
| Shared mutable state across tasks |
Arc<Mutex<T>> |
Or Arc<RwLock<T>> if reads dominate |
| Stream of async values |
tokio_stream + StreamExt |
Async equivalent of Iterator |
| Paginated API consumption |
stream::unfold |
Lazy, backpressure-aware page fetching |
| CPU-bound data parallelism |
rayon::par_iter |
Automatic work-stealing across cores |
| CPU-bound work in async context |
spawn_blocking + rayon |
Keep the async runtime unblocked |
| Scoped parallel work (no Arc) |
std::thread::scope |
Borrows stack data safely across threads |
| Atomic flag or counter |
AtomicBool / AtomicUsize |
Lock-free, single-word synchronization |
async fn in trait definition |
native async fn in traits |
No #[async_trait] needed since Rust 1.75 |
Channel Selection
| Channel |
Pattern |
Capacity |
Receivers |
oneshot |
Request → Response |
1 message |
1 |
mpsc |
Work queue |
Bounded (set capacity) |
1 |
broadcast |
Pub/sub (all get all) |
Bounded (ring buffer) |
N (all messages) |
watch |
Latest value |
1 (latest only) |
N (skip to newest) |
Always use bounded channels unless you have a specific
reason not to. Unbounded channels grow without limit when
producer outpaces consumer.
Buffer sizing: start with num_producers * 2 or expected
burst size. Monitor with capacity() and len().
The Lock-Across-Await Problem
Never hold a std::sync::Mutex guard across an .await:
// Bad: guard held across await — can deadlock
let mut guard = data.lock().unwrap();
*guard = fetch().await;
// Good: extract, await, then lock again
let current = data.lock().unwrap().clone();
let new_data = process(current).await;
*data.lock().unwrap() = new_data;
tokio::sync::Mutex is await-safe but has higher overhead.
Prefer restructuring to avoid holding locks across await
entirely.
Usage Scenarios
Scenario 1: "I need to make 5 HTTP requests and combine the results"
→ Use tokio::try_join! for a fixed number, or
JoinSet for a dynamic number.
Don't await them sequentially — that's 5x slower.
Scenario 2: "My async task needs to do JSON parsing on large payloads"
→ JSON parsing is CPU-bound. Use
spawn_blocking(move || serde_json::from_str(&data))
to avoid blocking the runtime.
Scenario 3: "I need to shut down gracefully when Ctrl+C is pressed"
→ Create a CancellationToken, pass child tokens to tasks,
and use tokio::select! to race work against
token.cancelled(). On signal, cancel the root token.
Reference Files
| File |
Read When |
| references/tokio-patterns.md |
Runtime setup, spawn_blocking, join/select patterns, JoinSet, cancellation |
| references/channels.md |
Choosing and using mpsc/broadcast/watch/oneshot, backpressure, message patterns |
| references/safety.md |
Lock safety across await, Send/Sync issues, clone-before-await patterns |
| references/streams.md |
Stream trait, StreamExt, Pin, async fn in traits, paginated/WebSocket patterns |
| references/threads-and-parallelism.md |
std::thread, rayon, crossbeam, atomics, async-vs-threads decision |
Cross-References
| When |
Check |
| Smart pointers for shared state (Arc, Mutex) |
rust-ownership → Quick Decisions |
| Error handling in async (try_join, ?) |
rust-errors → Quick Decisions |
| Async trait design and Send bounds |
rust-types → Quick Decisions |
| Tokio runtime profile settings |
rust-perf → Quick Decisions |
| Tracing spans in async code, .instrument() |
rust-tracing → Quick Decisions |
| Rayon, parallel iterators |
rust-perf → Quick Decisions |
1---2name: rust-async3description: Async Rust and concurrency with Tokio. Use when writing async code, choosing channel types (mpsc/broadcast/watch/oneshot), dealing with Send/Sync bounds, spawn_blocking, JoinSet, CancellationToken, or fixing issues with locks held across .await points. Also use for tokio::select!, graceful shutdown, and structured concurrency patterns.4---56# Async & Concurrency78## Core Question910**Is this I/O-bound (async) or CPU-bound (threads/spawn_blocking)?**1112Async is for waiting on external things (network, disk,13timers). CPU-heavy work blocks the runtime — move it to14`spawn_blocking` or a dedicated thread pool.1516---1718## Error → Design Question1920| Symptom | Don't Just Say | Ask Instead |21| -------------------- | ------------------------ | ----------------------------------------------------------- |22| Future is not `Send` | "Add Send bound" | Why does data cross a thread boundary? Can you restructure? |23| Deadlock with Mutex | "Use tokio::sync::Mutex" | Should you hold a lock across await at all? |24| Task hangs forever | "Add timeout" | Is there a cancellation path? |25| Channel fills up | "Make it unbounded" | What's the backpressure strategy? |2627---2829## Quick Decisions3031| Situation | Reach For | Why |32| --------------------------------------- | ------------------- | ------------------------------------- |33| Run independent futures concurrently | `tokio::join!` | Runs all, returns all results |34| Run fallible futures, fail fast | `tokio::try_join!` | Returns first error, drops rest |35| Race futures, handle first completion | `tokio::select!` | Cancel losers automatically |36| Dynamic number of spawned tasks | `JoinSet` | Add/remove tasks, collect results |37| CPU-intensive work in async context | `spawn_blocking` | Moves to blocking thread pool |38| File I/O in async code | `tokio::fs` | Non-blocking file operations |39| Graceful shutdown | `CancellationToken` | Hierarchical cancellation |40| One producer, one consumer, one message | `oneshot` | Request-response pattern |41| Work queue (multiple producers) | `mpsc` (bounded) | Backpressure built in |42| All subscribers get all messages | `broadcast` | Pub/sub pattern |43| Share latest value, skip intermediate | `watch` | Config updates, state sharing |44| Shared read-only data across tasks | `Arc<T>` | Clone Arc, not the data |45| Shared mutable state across tasks | `Arc<Mutex<T>>` | Or `Arc<RwLock<T>>` if reads dominate |46| Stream of async values | `tokio_stream` + `StreamExt` | Async equivalent of Iterator |47| Paginated API consumption | `stream::unfold` | Lazy, backpressure-aware page fetching |48| CPU-bound data parallelism | `rayon::par_iter` | Automatic work-stealing across cores |49| CPU-bound work in async context | `spawn_blocking` + `rayon` | Keep the async runtime unblocked |50| Scoped parallel work (no Arc) | `std::thread::scope` | Borrows stack data safely across threads |51| Atomic flag or counter | `AtomicBool` / `AtomicUsize` | Lock-free, single-word synchronization |52| `async fn` in trait definition | native async fn in traits | No `#[async_trait]` needed since Rust 1.75 |5354---5556## Channel Selection5758| Channel | Pattern | Capacity | Receivers |59| ----------- | --------------------- | ---------------------- | ------------------ |60| `oneshot` | Request → Response | 1 message | 1 |61| `mpsc` | Work queue | Bounded (set capacity) | 1 |62| `broadcast` | Pub/sub (all get all) | Bounded (ring buffer) | N (all messages) |63| `watch` | Latest value | 1 (latest only) | N (skip to newest) |6465**Always use bounded channels** unless you have a specific66reason not to. Unbounded channels grow without limit when67producer outpaces consumer.6869Buffer sizing: start with `num_producers * 2` or expected70burst size. Monitor with `capacity()` and `len()`.7172---7374## The Lock-Across-Await Problem7576Never hold a `std::sync::Mutex` guard across an `.await`:7778```rust79// Bad: guard held across await — can deadlock80let mut guard = data.lock().unwrap();81*guard = fetch().await;8283// Good: extract, await, then lock again84let current = data.lock().unwrap().clone();85let new_data = process(current).await;86*data.lock().unwrap() = new_data;87```8889`tokio::sync::Mutex` is await-safe but has higher overhead.90Prefer restructuring to avoid holding locks across await91entirely.9293---9495## Usage Scenarios9697**Scenario 1:** "I need to make 5 HTTP requests and combine the results"98→ Use `tokio::try_join!` for a fixed number, or99`JoinSet` for a dynamic number.100Don't await them sequentially — that's 5x slower.101102**Scenario 2:** "My async task needs to do JSON parsing on large payloads"103→ JSON parsing is CPU-bound. Use104`spawn_blocking(move || serde_json::from_str(&data))`105to avoid blocking the runtime.106107**Scenario 3:** "I need to shut down gracefully when Ctrl+C is pressed"108→ Create a `CancellationToken`, pass child tokens to tasks,109and use `tokio::select!` to race work against110`token.cancelled()`. On signal, cancel the root token.111112---113114## Reference Files115116| File | Read When |117| ------------------------------------------------------------ | ------------------------------------------------------------------------------- |118| [references/tokio-patterns.md](references/tokio-patterns.md) | Runtime setup, spawn_blocking, join/select patterns, JoinSet, cancellation |119| [references/channels.md](references/channels.md) | Choosing and using mpsc/broadcast/watch/oneshot, backpressure, message patterns |120| [references/safety.md](references/safety.md) | Lock safety across await, Send/Sync issues, clone-before-await patterns |121| [references/streams.md](references/streams.md) | Stream trait, StreamExt, Pin, async fn in traits, paginated/WebSocket patterns |122| [references/threads-and-parallelism.md](references/threads-and-parallelism.md) | std::thread, rayon, crossbeam, atomics, async-vs-threads decision |123124---125126## Cross-References127128| When | Check |129| -------------------------------------------- | -------------------------------- |130| Smart pointers for shared state (Arc, Mutex) | rust-ownership → Quick Decisions |131| Error handling in async (try_join, ?) | rust-errors → Quick Decisions |132| Async trait design and Send bounds | rust-types → Quick Decisions |133| Tokio runtime profile settings | rust-perf → Quick Decisions |134| Tracing spans in async code, .instrument() | rust-tracing → Quick Decisions |135| Rayon, parallel iterators | rust-perf → Quick Decisions |