- traits.md: iterator.rs anchor L76→L42 (pub const trait Iterator) - traits.md: deref.rs anchor L60→L139 (pub const trait Deref) - error-handling.md: fix variant names CliError::Io→IoError, Parse→ParseError to match actual stdlib doc example at convert/mod.rs:557 - concurrency.md: mutex.rs and rwlock.rs moved to sync/poison/ subtree (3 link updates: mutex×2, rwlock×1)
20 KiB
Rust Concurrency Patterns
Patterns for concurrent programming in Rust, extracted from the standard library source.
Source: rust-lang/rust at commit
f53b654
Stats: 783 Atomic type references, 721 Ordering usages, 274 Send/Sync markers, 135 Mutex/RwLock, 125 channel references, 254 OnceLock/LazyLock usages.
1. Send + Sync (Compiler-Enforced Thread Safety)
Source:
library/core/src/marker.rs#L92 (Send), library/core/src/marker.rs#L657 (Sync)
pub unsafe auto trait Send { }
pub unsafe auto trait Sync { }
Why
Send and Sync are auto traits — the compiler implements them automatically when safe. Send means "can be moved to another thread." Sync means "can be shared between threads via &T." These are the foundation of Rust's fearless concurrency: data races are compile-time errors, not runtime bugs.
Rc<T>is !Send (not thread-safe reference counting)Arc<T>is Send + Sync (atomic reference counting)Cell<T>is !Sync (interior mutability without synchronization)Mutex<T>is Sync (provides synchronized access)
When to Use
Triggers:
- When your type contains raw pointers that ARE safe to send
(implement
unsafe impl Send) - When you need to prove thread safety at compile time (bound with
T: Send + Sync) - When designing concurrent APIs (require Send on closures for spawn)
Example — before:
// Compiles but has a data race (other languages):
let data = Rc::new(RefCell::new(vec![1, 2, 3]));
let data_clone = data.clone();
thread::spawn(move || {
data_clone.borrow_mut().push(4); // RACE CONDITION
});
data.borrow_mut().push(5);
Example — in Rust:
// COMPILE ERROR: `Rc<RefCell<Vec<i32>>>` cannot be sent between threads safely
let data = Rc::new(RefCell::new(vec![1, 2, 3]));
thread::spawn(move || {
data.borrow_mut().push(4); // ERROR: Rc is !Send
});
// FIX: Use Arc + Mutex for thread-safe shared mutation
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
let data_clone = Arc::clone(&data);
thread::spawn(move || {
data_clone.lock().unwrap().push(4); // safe: Mutex provides synchronization
});
When NOT to Use
Don't use this when:
- Your type is inherently single-threaded (let the auto trait do its job — it will be !Send automatically if it contains !Send fields)
- You're unsure whether your type is safe to send (DON'T blindly
add
unsafe impl Send— prove it first)
2. Arc<Mutex> (Shared Mutable State)
Source:
library/std/src/sync/poison/mutex.rs, library/alloc/src/sync.rs
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
let counter_clone = Arc::clone(&counter);
thread::spawn(move || {
let mut num = counter_clone.lock().unwrap();
*num += 1;
});
Why
Arc provides shared ownership across threads. Mutex provides exclusive access (one thread at a time). Together: multiple threads can share mutable state safely. The lock guard (MutexGuard) drops automatically, releasing the lock — you can't forget to unlock.
When to Use
Triggers:
- Multiple threads need to read AND write the same data
- Writes are short-lived (don't hold the lock long)
- You need strong consistency (every read sees the latest write)
Example — before:
// Single-threaded: RefCell is fine
let state = RefCell::new(HashMap::new());
state.borrow_mut().insert("key", "value");
Example — after (multi-threaded):
use std::sync::{Arc, Mutex};
let state = Arc::new(Mutex::new(HashMap::new()));
// Thread 1:
let state1 = Arc::clone(&state);
thread::spawn(move || {
state1.lock().unwrap().insert("key", "value");
});
// Thread 2:
let state2 = Arc::clone(&state);
thread::spawn(move || {
let map = state2.lock().unwrap();
println!("{:?}", map.get("key"));
});
When NOT to Use
Don't use this when:
- Only one thread needs access (just own it directly)
- Reads vastly outnumber writes (use
RwLockinstead) - Lock contention is high (consider lock-free data structures or message passing)
- The protected data is a simple counter (use
AtomicUsize)
Anti-pattern
// DON'T: Hold the lock across await points
let data = Arc::new(Mutex::new(vec![]));
async fn bad(data: Arc<Mutex<Vec<i32>>>) {
let mut guard = data.lock().unwrap();
some_async_operation().await; // DEADLOCK RISK: lock held across await
guard.push(1);
}
// DO: Lock, extract, drop, then await
async fn good(data: Arc<Mutex<Vec<i32>>>) {
let value = {
let guard = data.lock().unwrap();
guard.last().copied()
}; // lock dropped here
some_async_operation().await; // safe: no lock held
}
3. RwLock for Read-Heavy Workloads
Source:
library/std/src/sync/poison/rwlock.rs
use std::sync::RwLock;
let lock = RwLock::new(5);
// Multiple readers simultaneously:
let r1 = lock.read().unwrap();
let r2 = lock.read().unwrap();
assert_eq!(*r1, 5);
assert_eq!(*r2, 5);
drop((r1, r2));
// Exclusive writer:
let mut w = lock.write().unwrap();
*w += 1;
Why
RwLock allows multiple simultaneous readers OR one exclusive writer. For read-heavy workloads, this dramatically reduces contention compared to Mutex (which blocks all access during any operation).
When to Use
Triggers:
- Reads vastly outnumber writes (>10:1 ratio)
- Read operations are not trivially fast (worth avoiding the lock)
- Multiple threads frequently read simultaneously
When NOT to Use
Don't use this when:
- Writes are frequent (RwLock has higher overhead than Mutex)
- You're on a single-threaded runtime
- The protected operation is so fast that contention doesn't matter
4. Atomic Types for Lock-Free Operations
Source:
library/core/src/sync/atomic.rs
783 Atomic type references, 721 Ordering usages.
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn increment() {
COUNTER.fetch_add(1, Ordering::Relaxed);
}
Why
Atomics provide thread-safe operations WITHOUT locks. They're faster than Mutex for simple values (counters, flags, pointers) because they use CPU hardware instructions directly.
When to Use
Triggers:
- Simple values: counters, flags, booleans
- High contention where lock overhead matters
- Statistics/metrics that don't need precise ordering
- Implementing lock-free data structures
Example — before:
// Mutex for a simple counter — overkill
let counter = Arc::new(Mutex::new(0usize));
let c = Arc::clone(&counter);
thread::spawn(move || {
*c.lock().unwrap() += 1; // lock overhead for one add
});
Example — after:
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let counter = Arc::new(AtomicUsize::new(0));
let c = Arc::clone(&counter);
thread::spawn(move || {
c.fetch_add(1, Ordering::Relaxed); // no lock, hardware instruction
});
When NOT to Use
Don't use this when:
- You need to update multiple values atomically (use Mutex)
- The operation is complex (compare-and-swap loops get tricky)
- You don't understand memory ordering (use Mutex instead — wrong Ordering is UB in theory, bugs in practice)
Ordering Guide
// Simple counter — order doesn't matter:
counter.fetch_add(1, Ordering::Relaxed);
// Flag that another thread checks:
flag.store(true, Ordering::Release); // writer
if flag.load(Ordering::Acquire) { ... } // reader
// When in doubt (and you can afford it):
value.load(Ordering::SeqCst); // strongest, safest, slowest
5. Channels (mpsc) for Message Passing
Source:
125 channel references in library/.
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send("hello").unwrap();
});
let msg = rx.recv().unwrap();
Why
Channels move data between threads without shared state. "Do not communicate by sharing memory; share memory by communicating." The sender and receiver are separate types — the type system ensures correct usage.
When to Use
Triggers:
- Producer-consumer patterns
- Work distribution (fan-out)
- Event notification between threads
- You want to avoid shared mutable state entirely
Example — before:
// Shared state approach — complex and error-prone
let results = Arc::new(Mutex::new(Vec::new()));
for i in 0..10 {
let results = Arc::clone(&results);
thread::spawn(move || {
let answer = compute(i);
results.lock().unwrap().push(answer);
});
}
// How do we know when all threads are done?
Example — after:
let (tx, rx) = mpsc::channel();
for i in 0..10 {
let tx = tx.clone();
thread::spawn(move || {
let answer = compute(i);
tx.send(answer).unwrap();
});
}
drop(tx); // close the sender so rx.iter() terminates
let results: Vec<_> = rx.iter().collect();
// Clean, simple, no shared state
When NOT to Use
Don't use this when:
- You need multiple consumers (mpsc = multi-producer, SINGLE consumer)
- You need bidirectional communication (use two channels or shared state)
- The "message" is just a counter increment (use atomics)
6. OnceLock / LazyLock for One-Time Initialization
Source:
library/std/src/sync/once_lock.rs
254 OnceLock/LazyLock references.
use std::sync::OnceLock;
static CONFIG: OnceLock<Config> = OnceLock::new();
fn get_config() -> &'static Config {
CONFIG.get_or_init(|| {
load_config_from_disk() // runs exactly once, thread-safe
})
}
Why
OnceLock guarantees initialization happens exactly once, even with
concurrent access. Multiple threads calling get_or_init simultaneously
will block until the first one finishes, then all get the same value.
When to Use
Triggers:
- Global/static configuration loaded once at startup
- Expensive computation cached forever
- Regex compilation (compile once, use many times)
- Database connection pools initialized on first use
Example — before:
// Mutex just for initialization — unnecessary lock on every access
static CONFIG: Mutex<Option<Config>> = Mutex::new(None);
fn get_config() -> Config {
let mut config = CONFIG.lock().unwrap();
if config.is_none() {
*config = Some(load_config());
}
config.as_ref().unwrap().clone() // clone on EVERY access!
}
Example — after:
use std::sync::LazyLock;
static CONFIG: LazyLock<Config> = LazyLock::new(|| load_config());
fn get_config() -> &'static Config {
&CONFIG // no lock after initialization, just a pointer read
}
When NOT to Use
Don't use this when:
- The value needs to change after initialization (use Mutex/RwLock)
- Initialization failure should be retried (OnceLock panics in init leave it permanently empty)
- You need non-static lifetime (use regular lazy initialization)
7. Scoped Threads (thread::scope)
Source:
library/std/src/thread/scoped.rs
let mut data = vec![1, 2, 3];
thread::scope(|s| {
s.spawn(|| {
println!("{data:?}"); // can borrow from parent scope!
});
s.spawn(|| {
println!("{}", data.len());
});
});
// All threads joined here — data is safe to use again
Why
Regular thread::spawn requires 'static data (because the thread
might outlive the caller). thread::scope guarantees all spawned
threads finish before the scope ends, so they can BORROW local data.
No Arc needed.
When to Use
Triggers:
- Parallel processing of local data
- Fork-join parallelism
- You want multiple threads to borrow the same data without Arc
Example — before:
// thread::spawn requires ownership or 'static — must Arc everything
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let handles: Vec<_> = data.chunks(2).map(|chunk| {
// Can't do this — chunks borrows data which isn't 'static
thread::spawn(move || chunk.iter().sum::<i32>())
}).collect();
Example — after:
let data = vec![1, 2, 3, 4, 5];
let sums: Vec<i32> = thread::scope(|s| {
let handles: Vec<_> = data.chunks(2)
.map(|chunk| s.spawn(|| chunk.iter().sum::<i32>()))
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
// No Arc, no clone, just direct borrows
When NOT to Use
Don't use this when:
- Threads need to outlive the current function (use regular spawn)
- You're in an async context (use async tasks instead)
- The work is too small to benefit from parallelism
8. Interior Mutability (Cell/RefCell)
Source:
410 Cell/RefCell usages in library/.
use std::cell::Cell;
struct Counter {
count: Cell<u32>,
}
impl Counter {
fn increment(&self) { // note: &self, not &mut self
self.count.set(self.count.get() + 1);
}
}
Why
Sometimes you need mutation through a shared reference (&T). Cell provides this for Copy types (get/set), RefCell for any type (runtime borrow checking). This is !Sync — single-thread only.
When to Use
Triggers:
- You need mutation through &self (caching, counters, lazy init)
- Multiple parts of code hold &T but one needs to mutate
- Single-threaded context only
- Cell: Value is Copy (integers, booleans)
- RefCell: Value isn't Copy or you need a reference into it
Example — before:
// Can't cache in &self — need &mut self for mutation
impl Graph {
fn expensive_traversal(&mut self) -> &[Node] {
if self.cache.is_none() {
self.cache = Some(self.compute_traversal());
}
self.cache.as_ref().unwrap()
}
}
// Every caller needs &mut Graph just to read cached data
Example — after:
use std::cell::RefCell;
impl Graph {
fn expensive_traversal(&self) -> Ref<'_, [Node]> {
if self.cache.borrow().is_none() {
*self.cache.borrow_mut() = Some(self.compute_traversal());
}
Ref::map(self.cache.borrow(), |c| c.as_ref().unwrap().as_slice())
}
}
// Callers only need &Graph — mutation is interior
When NOT to Use
Don't use this when:
- You can restructure to use &mut self (preferred)
- Multi-threaded (use Mutex/RwLock instead — Cell/RefCell are !Sync)
- RefCell borrow violations would be common (panic at runtime)
9. thread_local! for Per-Thread State
Source:
library/std/src/thread/local.rs
thread_local! {
static BUFFER: RefCell<Vec<u8>> = RefCell::new(Vec::new());
}
fn process(data: &[u8]) {
BUFFER.with(|buf| {
let mut buf = buf.borrow_mut();
buf.clear();
buf.extend_from_slice(data);
// reuses allocation across calls within same thread
});
}
Why
Thread-local storage gives each thread its own copy. No synchronization needed because there's no sharing. Useful for per-thread caches, buffers, and RNG state.
When to Use
Triggers:
- Per-thread caches or buffers (avoid allocation per call)
- Thread-specific state (random number generators)
- Avoiding synchronization overhead for thread-independent data
When NOT to Use
Don't use this when:
- State needs to be shared between threads (use Arc<Mutex>)
- You're in an async context (tasks migrate between threads — thread-local doesn't follow the task)
- The state is small and cheap to create per-call
10. Poisoning (Mutex Recovery After Panic)
Source:
library/std/src/sync/poison/mutex.rs
let mutex = Arc::new(Mutex::new(0));
// Thread panics while holding the lock:
let m = Arc::clone(&mutex);
let _ = thread::spawn(move || {
let _guard = m.lock().unwrap();
panic!("oops");
}).join();
// The mutex is now "poisoned" — lock() returns Err:
match mutex.lock() {
Ok(guard) => println!("value: {}", *guard),
Err(poisoned) => {
// Can still access the data if you choose:
let guard = poisoned.into_inner();
println!("recovered: {}", *guard);
}
}
Why
If a thread panics while holding a Mutex, the protected data may be in an inconsistent state. Rust "poisons" the mutex: future lock() calls return Err, alerting other threads that something went wrong. You can choose to recover or propagate the panic.
When to Use
Triggers:
- You need to handle the case where a sibling thread panicked
- You want to "unwrap" the poisoned lock when you know the data is still valid
- Robust services that shouldn't crash because one request panicked
When NOT to Use
Don't use this when:
- A panic in one thread should crash everything (just
.unwrap()) - You're using
catch_unwindto prevent panics from poisoning - The data is always valid regardless of panic point
Summary: Concurrency Decision Tree
Do you need shared state across threads?
├── NO → Use owned data + message passing (channels)
└── YES → What kind of access?
├── Read-only → Arc<T> (no Mutex needed)
├── Simple counter/flag → Arc<AtomicUsize> / AtomicBool
├── Read-heavy, rare writes → Arc<RwLock<T>>
└── Frequent reads and writes → Arc<Mutex<T>>
Do threads need to borrow local data?
├── YES → thread::scope (no Arc needed)
└── NO → thread::spawn (requires Send + 'static)
Need one-time initialization?
├── Static, known at compile time → const
├── Static, computed at runtime → LazyLock / OnceLock
└── Per-instance → Option + init-on-first-use
Single-threaded interior mutability?
├── Copy type → Cell<T>
├── Non-Copy → RefCell<T>
└── Multi-threaded → Mutex<T> or RwLock<T>
| Pattern | Use when |
|---|---|
Send + Sync |
Compiler proves thread safety |
Arc<Mutex<T>> |
Shared mutable state across threads |
Arc<RwLock<T>> |
Read-heavy shared state |
AtomicUsize etc. |
Lock-free counters/flags |
mpsc::channel |
Message passing between threads |
OnceLock/LazyLock |
One-time initialization |
thread::scope |
Parallel work on local data |
Cell/RefCell |
Interior mutability (single-thread) |
thread_local! |
Per-thread state |
| Poisoning | Recovery after panic |
See also:
- ownership.md — Arc vs Rc, Box
- unsafe-patterns.md — unsafe impl Send/Sync
- error-handling.md — Handling poisoned locks