Every primitive in this track is simple on its own. The difficulty is that most problems can be solved with any of them, and the difference between choices shows up as maintainability, contention and 3am pages rather than as a compile error.
So here is a decision table, and then — more usefully — the anti-patterns.
The table
| the shape of your problem | reach for |
|---|---|
| a counter, a flag, a sequence number | an atomic |
| independent chunks of read-only input |
thread::scope + join |
| a producer feeding a consumer, stages | channels, bounded for backpressure |
| one mutable structure, many writers |
Mutex |
| read-mostly, and you have measured it |
RwLock |
| one-time initialisation of a global |
OnceLock |
| phases that must not overlap |
Barrier |
| wait until a predicate becomes true |
Condvar |
| per-thread scratch state |
thread_local! |
| disjoint mutation of one buffer |
chunks_mut / split_at_mut |
Read that top to bottom and notice the ordering is roughly “cheapest and hardest to misuse” to “most powerful and easiest to misuse”. That is deliberate: prefer the least expressive thing that solves your problem.
💡You need a counter incremented by eight threads. Arc<Mutex<u64>> or Arc<AtomicU64>?
click to reveal
AtomicU64, and it is not primarily about speed.
It is about the API surface you have to keep correct. A mutex-wrapped
counter admits a whole family of mistakes: holding the guard too long,
acquiring it in the wrong order relative to another lock, forgetting that
read-modify-write under two separate lock acquisitions is not atomic. An
atomic counter admits almost none — fetch_add cannot be split, cannot
deadlock, and cannot be held.
The speed difference is real (one instruction versus a lock acquisition), but the correctness surface is the better argument.
The honest caveat is that clippy::mutex_atomic and clippy::mutex_integer,
which would tell you this automatically, are in the restriction group and
off by default — and their own documentation concedes that a mutex is “easier
to verify correctness” and that “an atomic does not behave the same as an
equivalent mutex”. This is a genuine community disagreement, not settled
practice. The moment you need to update two related values together, the
mutex is right again and the atomics were never a real option.
Anti-pattern 1: Arc<Mutex<HashMap<K, V>>> as a god object
This is the reflex, and it is very often the worst available choice.
One lock for the whole map means every reader and every writer serialises through it — you have added threads and removed parallelism. Worse, the map becomes a place where unrelated pieces of state accumulate, so the lock’s scope grows until “hold the lock” means “stop the program”.
What to do instead, in order of preference:
- Partition the state. If each worker owns a disjoint key range, no shared map is needed at all. Merge at the end.
-
Shard it.
Vec<Mutex<HashMap<K, V>>>indexed byhash(key) % n. Now contention drops by roughlyn. (This is whatdashmapdoes for you. Note it multiplies the lock-ordering hazard if you ever need two shards at once.) - Give the state an owner. One thread owns the map, everyone else sends it messages down a channel. No lock anywhere, and the invariants live in one place.
Anti-pattern 2: holding a lock across something slow
Two specific versions worth naming:
Across I/O. A file read or a network call can take milliseconds to seconds. Every other thread that wants that lock is stopped for the duration. Read first, then lock to publish the result.
Across join(). If the thread you are waiting for needs the same lock,
you have deadlocked. If it does not, you have still serialised the two.
The general rule is the useful one: a critical section should contain only operations you can bound. Anything that can block belongs outside.
Anti-pattern 3: RwLock chosen for “reads are cheap”
RwLock looks strictly better than Mutex — many readers at once! — and it
frequently is not.
It costs more per acquisition, because it maintains more state. Under a heavy
read load a writer can be starved indefinitely, depending on the platform’s
policy, which std deliberately does not specify. And it demands more of the
contained type: RwLock<T>: Sync requires T: Send + Sync, while
Mutex<T>: Sync needs only T: Send, because several readers hold &T
simultaneously and one mutex holder does not.
If the critical section is a few instructions, the RwLock bookkeeping can
cost more than the exclusion it is avoiding. Start with Mutex. Move to
RwLock when a measurement says to, not when your intuition about the
read/write mix says to.
💡You are sizing a thread pool. Why should available_parallelism() never appear inside a function whose *output* depends on the worker count?
click to reveal
Because it is not a constant, and it does not claim to be.
std::thread::available_parallelism() (1.59) is a good default for pool
sizing, and its documented caveats are worth knowing:
- it may overcount under cgroup CPU quotas or affinity masks it cannot query — a container limited to 2 CPUs on a 64-core host may still be told 64;
- it may undercount on Windows above 64 logical CPUs;
- it is not cached, so it costs a syscall;
- and it is explicitly not stable across calls — the answer can change while the program runs.
So it is fine for “how many workers should I start”, and never acceptable as
an input to a result. Every problem in this track takes workers as a
parameter and asserts that the answer is identical for 1, 4 and 16 — that is
the property you want in real code too.
Message passing versus shared state, without the slogan
“Do not communicate by sharing memory; share memory by communicating” is a good heuristic and a bad law.
Channels win when the work has a direction — a pipeline, a job queue, a
request/response — because ownership transfer removes the lock discipline
entirely and shutdown falls out of who holds a Sender.
Shared state wins when the data has no direction: a cache, a live index, a counter that everyone reads. Routing every read through a channel to an owner thread means a round trip for something that could be a load instruction.
A useful reframing: a Mutex is a channel with a queue depth of one and no
ownership transfer. Choosing between them is choosing where you want the
queue to be.
Resist the performance table
You may want a chart here showing atomics beating mutexes beating channels by some factor. Any such number is a lie without its workload: results depend on contention level, critical-section length, core count, NUMA topology, scheduler and the size of the values being moved. The ratios reverse under different mixes.
What is dependable is the reasoning: less shared state is faster and safer than more; fewer possible interleavings are easier to review than more; the primitive that cannot express your bug is better than the one that can.
The short version
-
First try to need no sharing at all — partition, and
thread::scope. - If the work has a direction, use a bounded channel.
-
If you need shared mutable state, start with
Mutexand keep the critical section small and bounded. - Use an atomic when the state is a single machine word with a single read-modify-write operation.
-
Reach for
RwLock,CondvarorBarrierwhen the shape demands it — read-mostly measured, wait-for-predicate, or lock-step phases. - Never let a lock outlive the invariant it protects.