Concurrency Best Practices
Rules for safe, maintainable Rust concurrency - prefer message passing, minimize lock scope, and match the parallelism model to the workload.
How to Use This List
- Apply before adding
Arc<Mutex<Everything>>to a service. - Use as a PR checklist for thread-spawning and shared-state changes.
- Revisit after incidents (deadlocks, wrong counts, hung shutdown).
A - Model Selection
- Profile before choosing threads vs Rayon vs async. Labels mislead without timing data.
- Prefer message passing over shared mutation. Channels encode ownership transfer clearly.
- Use Rayon for CPU-bound data parallel maps/reduces. Not for blocking I/O inside closures.
- Reserve OS threads for true parallelism or blocking isolation. Thousands of threads are a smell.
- Document concurrency model in module README or ADR. Future readers need the why.
B - Locks & Shared State
- Keep critical sections short. Clone data out before slow I/O or syscalls.
- Acquire locks in a fixed global order. Document order to prevent deadlocks.
- Prefer
RwLockonly for read-heavy data. Writers dominate -> useMutexor channels. - Use atomics for simple counters and shutdown flags. Pair
Release/Acquirewhen publishing state. - Avoid
Arc<Mutex<LargeAppState>>god objects. Shard state or use actor tasks.
C - Threads & Lifetimes
- Use
thread::scopewhen borrowing stack data. Avoid unnecessaryArcclones for divide-and-conquer. - Join or detach intentionally. Detached threads must not touch parent resources after exit.
- Name threads for observability. Appears in logs, traces, and crash dumps.
- Handle
JoinErrorand mutex poison. Do not silentlyunwrapin production paths. - Cap worker count to CPU, memory, and FD budgets. Math, not defaults-only.
D - Channels & Shutdown
- Prefer bounded channels under load. Unbounded queues hide backpressure until OOM.
- Drop all senders or send shutdown sentinel. Receivers must not block forever.
- Use
recv_timeoutin tests and shutdown. Fail loud instead of hanging CI. - Send
Resultper item in pipelines. Partial failure beats all-or-nothing panic. - Separate control plane from data plane channels. Stop signals vs work messages.
E - Async Boundary
- Do not block the async executor on
mutex.lock()orrecv(). Usetokio::syncprimitives in async code. - Offload CPU work with
spawn_blockingor Rayon from async. Keep latency predictable. - Never hold
std::sync::MutexGuardacross.await. BreaksSendand stalls workers. - Re-evaluate thread pools when libraries go async. Fewer bridges, simpler topology.
- Load-test graceful shutdown. Races appear on deploy and SIGTERM.
FAQs
Top production incident cause?
Shared mutable cache without clear lock ordering plus concurrent writers - prefer channels or sharded locks.
How many threads?
Start near CPU cores for CPU work; for I/O-heavy sync servers, size to connection and pool limits, not num_cpus * 10 blindly.
Mutex or channels?
If each update is an independent event, channels. If many readers need the same live struct, Arc<RwLock<T>>.
When is shared state OK?
When invariants are simple, lock scope is tiny, and contention is low - counters, config snapshots, read-heavy caches.
Testing concurrent code?
Stress tests, inject sleeps, run tests multi-threaded, consider loom for lock-free algorithms.
Rayon in libraries?
Expose sequential API by default; optional parallel path behind feature flag if consumers may be single-threaded.
Global statics?
OnceLock, atomics, or lazy_static/std::sync::OnceLock - avoid unsynchronized static mut.
FFI and threads?
Document which thread calls FFI; Send/Sync on handles must match C library rules.
Observability?
Thread names, tracing spans per worker, metrics on queue depth and lock wait time.
First refactor step?
Replace shared mutation with a single owner thread and channel-based commands.
Related
- Concurrency Basics - getting started
- Channels - message passing
- Deadlocks & Race Conditions - failure modes
- Async Best Practices - async-specific rules
- Tokio Best Practices - runtime tuning
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.