Rust Concurrency Patterns
Rust Concurrency Patterns
Arc<Mutex<T>> is the beginner default for shared mutable state, but experts reach for it last. The decision framework moves from least to most sharing:
The decision framework
- Eliminate sharing entirely — use ownership transfer. Move data into the task that needs it. This is the fastest and simplest option.
- Read-heavy access? — use
RwLock. Multiple readers proceed concurrently; only writers block. - Message passing — use channels (kanal, crossbeam-channel,
tokio::sync::mpsc). See rust-concurrent-data-structures for the full channel comparison. - Concurrent map needed? — use dashmap (sharded locking), papaya (lock-free), or scc (extreme write contention).
- Simple counters? — use
AtomicU64or other atomics. No lock, no contention. - Last resort —
Arc<Mutex<T>>when nothing above fits.
Data-oriented state splitting
Split state into frequently-mutated and rarely-mutated components to minimize lock scope:
struct User {
profile: UserProfile, // Immutable after creation — no lock
stats: Arc<Mutex<UserStats>>, // Frequently updated — small lock scope
}
This is data-oriented design applied to concurrency: separate hot mutable state from cold immutable state so locks protect only what changes.
Async task patterns
For async code (OneSignal's production patterns):
| Pattern | Use case |
|---|---|
tokio::join! |
Fixed number of independent futures |
FuturesUnordered |
Dynamic batch of futures |
tokio::spawn |
Background tasks |
Semaphore |
Concurrency limiting |
JoinSet |
Task groups with drop-cancellation |
Cancellation safety is critical in async Rust: when a future is dropped at an .await point, any partial work must be handled correctly. tokio's JoinSet provides structured concurrency guarantees — no leaked tasks.
The high-level alternative: services behind Arc<dyn Trait>
For application-level code where perf is bounded by I/O rather than CPU, high-level-rust explicitly inverts the decision framework above. Each domain gets a service trait; concrete implementations are built at the app root and shared via Arc<dyn Trait>. Sharing is the default — every handler receives the services it needs as injected clones of an Arc, and the framework absorbs the trait-object dispatch cost (~5× the inline path) as the price of DI, swappable test implementations, and an architecture engineers from other ecosystems immediately recognize.
This is correct for CRUD APIs and most web services, and wrong for hot paths and high-frequency systems. The two positions are not in conflict — they answer different questions. See rust-abstraction-boundaries for the underlying enum/generic/trait-object trade and enum-dispatch for the perf-first counterpart.
Related pages
See rust-concurrent-data-structures for the map and channel landscape, tokio for runtime-specific patterns, and thread-per-core for the shard-per-core architecture that eliminates shared state entirely. For the high-level inversion of the decision framework, see high-level-rust.
Linked from
Sources
- Raw/Rust/What A+ Rust design actually looks like.md
- Raw/Rust/High-Level Rust: Getting 80% of the Benefits with 20% of the Pain.md