Skip to content
← All articles

Choosing your concurrency primitive

You now know threads, scopes, channels, Arc, Mutex, RwLock, Condvar and Barrier. The hard part was never any one of them — it is knowing which one the problem in front of you actually wants, and which reflex to distrust.

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 by hash(key) % n. Now contention drops by roughly n. (This is what dashmap does 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

  1. First try to need no sharing at all — partition, and thread::scope.
  2. If the work has a direction, use a bounded channel.
  3. If you need shared mutable state, start with Mutex and keep the critical section small and bounded.
  4. Use an atomic when the state is a single machine word with a single read-modify-write operation.
  5. Reach for RwLock, Condvar or Barrier when the shape demands it — read-mostly measured, wait-for-predicate, or lock-step phases.
  6. Never let a lock outlive the invariant it protects.