A concurrency primitive is part of a protocol: who owns the state, which operations must happen together, when a worker may proceed, and what happens if a participant stops. Start with those requirements before comparing costs.
Match the tool to the requirement
| Requirement | Consider | Check before using it |
|---|---|---|
| Independent work on borrowed data | thread::scope |
All scoped threads finish before the scope returns |
| Disjoint mutation of a buffer | chunks_mut or split_at_mut, with scoped threads |
Each worker gets a non-overlapping slice |
| A shared counter or flag | An appropriate atomic type | Target support, overflow and memory ordering |
| A producer feeding a consumer | A channel | Capacity, blocking, disconnection and shutdown |
| An invariant spanning mutable fields | Mutex<T> |
Keep related checks and updates in one critical section |
| Concurrent readers with exclusive writers | RwLock<T> |
Measure the workload; do not assume a fairness policy |
| Initialization shared across threads | OnceLock<T> |
Initialization failure and reentrancy behavior |
| Repeated rendezvous between a fixed number of participants | Barrier |
Every phase needs the required number of arrivals |
| Waiting for protected state to change | Condvar with a mutex |
Recheck the predicate after waking |
| State belonging to one operating-system thread | thread_local! |
It is not automatically local to an async task or logical job |
This is not a ranking from safest to fastest. These tools solve different
coordination problems and can be combined. Arc supplies shared ownership
when needed; it does not make the payload's operations synchronized.
Avoid sharing when the work is already separate
Partitioning a buffer gives each worker exclusive access to its own part:
use std::thread;
fn main() {
let mut values = [1, 2, 3, 4];
thread::scope(|scope| {
for chunk in values.chunks_mut(2) {
scope.spawn(move || {
for value in chunk {
*value *= 2;
}
});
}
});
assert_eq!(values, [2, 4, 6, 8]);
}
No mutex is needed because the slices do not overlap and the scope waits for workers before the result is read. Parallel execution is not necessarily faster for such a small input; thread creation and scheduling also cost work.
Atomics protect operations, not whole algorithms
Check your understanding Eight threads increment a counter. Should it use a mutex or an atomic? Show answer Hide answer
If the required operation is an independent integer increment, an atomic
fetch_add is a useful fit. A separate load followed by a store is not an
atomic read-modify-write operation and can lose updates.
Decide what the counter means. If it only counts completed operations and
the result is read after joining all workers, relaxed operations may suffice.
If it publishes access to other data, the protocol needs an appropriate
synchronization relationship. Integer fetch_add wraps on overflow, so the
intended overflow behavior also matters.
A mutex remains reasonable when the counter participates in a larger invariant. Updating several related fields under one guard is often simpler than a multi-atomic protocol. Multi-field atomic designs are possible, but their correctness needs a separate argument. Neither tool guarantees a particular instruction count or universal speed advantage.
Here the counter is the only shared state, its final value fits in usize,
and all workers finish before the final load:
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn main() {
let count = AtomicUsize::new(0);
thread::scope(|scope| {
for _ in 0..8 {
scope.spawn(|| {
for _ in 0..1000 {
count.fetch_add(1, Ordering::Relaxed);
}
});
}
});
assert_eq!(count.load(Ordering::Relaxed), 8000);
}
Atomic widths depend on target support; do not assume every target provides
AtomicU64. Clippy's opt-in mutex_atomic and mutex_integer lints can
suggest reviewing a mutex-wrapped scalar. A suggestion does not establish
that replacing the mutex preserves the program's synchronization behavior.
Locks protect invariants
One Mutex<HashMap<K, V>> serializes access while its guard is held. That can
be a simple, effective design when critical sections are short and contention
is low; work outside the lock can still run in parallel.
If measurements show contention, consider partitioning independent state, sharding the map, or giving the map an owner thread that processes requests. Sharding helps only when operations spread across shards. A hot key can keep one shard busy, and operations needing several shards introduce lock-order questions. There is no guaranteed speedup equal to the shard count.
Keep guards away from blocking work when the invariant permits it. For an I/O-based update, one approach is to copy the needed state, release the lock, do the I/O, then reacquire the lock and validate that the result is still applicable before committing it. Simply moving work outside the lock can introduce a race if it separates a check from the update it protects.
Joining a worker while holding a lock it needs creates a deadlock. More generally, review dependencies between locks, joins, sends and receives. A short critical section can still deadlock if its dependencies form a cycle.
RwLock permits shared read guards or one exclusive write guard. It can help
when concurrent reads do enough useful work to justify its coordination cost,
but it is not automatically better for a read-heavy workload. The standard
library does not guarantee a particular reader/writer priority policy, so do
not base progress on a presumed fairness guarantee. Measure throughput and
latency for both readers and writers.
The trait bounds differ too: Mutex<T>: Sync requires T: Send, while
RwLock<T>: Sync requires T: Send + Sync, because concurrent readers can
hold shared references. Handle poisoning deliberately; unwrapping a poisoned
lock result propagates failure rather than recovering the protected state.
Channels need a shutdown plan
Channels make ownership transfer explicit. A bounded channel can limit queued messages and apply backpressure by blocking a sender when full. That does not bound all application memory: producers may retain other state, and messages themselves can vary in size. Backpressure also creates dependencies that can deadlock if a receiver waits for a sender that cannot finish its send.
This example consumes messages while the producer is active. The last sender is dropped when that producer exits, allowing the receiver loop to finish:
use std::sync::mpsc::sync_channel;
use std::thread;
fn main() {
let (sender, receiver) = sync_channel(2);
let total: i32 = thread::scope(|scope| {
scope.spawn(move || {
for value in 1..=4 {
sender.send(value).unwrap();
}
});
receiver.iter().sum()
});
assert_eq!(total, 10);
}
Retaining an unused sender clone would keep this receiver waiting after the producer finishes. Dropping the receiver makes subsequent sends fail. An owner-thread design centralizes state changes, but requests are still serialized there and callers need an error and shutdown protocol. The channel implementation may use locks internally; message passing does not mean that the entire program is lock-free.
Wait for state, not just a notification
A condition variable is used with a predicate protected by a mutex. Waiting
releases that mutex and reacquires it before returning. Notifications are not
saved as application state, and waking does not prove the predicate is true.
wait_while checks it again, including after spurious wakeups:
use std::sync::{Condvar, Mutex};
use std::thread;
fn main() {
let ready = Mutex::new(false);
let changed = Condvar::new();
thread::scope(|scope| {
scope.spawn(|| {
*ready.lock().unwrap() = true;
changed.notify_one();
});
let guard = ready.lock().unwrap();
let guard = changed.wait_while(guard, |ready| !*ready).unwrap();
assert!(*guard);
});
}
For reusable phases, a Barrier instead waits for a configured number of
arrivals. It does not track participant identity or automatically compensate
for a worker that panics or exits early. Plan how every phase reaches the
barrier and what happens on failure; a missing arrival can leave others
blocked. A barrier also does not grant permission to mutate shared data
without an appropriate ownership or synchronization mechanism.
Choose a worker count separately from the result contract
Check your understanding Can available_parallelism() affect a function's output? Show answer Hide answer
It can legitimately affect output such as a resource report or a chosen pool configuration. For a computation promised to return the same result for any worker count, however, use it only to choose the execution strategy.
available_parallelism() returns a fallible estimate, not a fixed machine
constant. It is recomputed on each call, can change as resource limits change,
and has platform limitations involving affinity, quotas and virtual machines.
Query it outside hot paths and provide an appropriate fallback. Its query
mechanism and cost vary by platform; it is not specified as exactly one syscall.
Test whatever worker-count independence the API promises. Floating-point reductions may change when partitioning changes the addition order, so a reproducibility requirement needs an explicit reduction strategy or tolerance. Do not assume every concurrency exercise or every real program has the same result contract.
Choose a design whose invariants and termination conditions you can explain. Then measure the actual workload, including contention, scheduling, allocation and communication. Partitioning can simplify reasoning, but copying data or merging results can also add costs; no fixed ranking replaces that analysis.