Concurrency Primitives Primer
Two threads in the same address space sharing data is the situation every primitive in this primer is designed for — and where almost every multi-threading bug comes from. Five sections build the picture: why concurrency is hard(race conditions, visibility); locks — mutex, semaphore, condvar; atomics and the futex that lock implementations sit on; memory orderingwith an interactive walkthrough of acquire / release / seq_cst; and a quick reference.
Why concurrency is hard
Two threads on shared data can produce results no sequential interleaving would. The primitives in this primer exist to force the observable behaviour back into something we can reason about.
Concurrency bugs share three root causes:
- Race conditions — the outcome depends on the interleaving of thread schedules. Classic:
x++on a shared counter is load → add → store, and two threads racing produce a counter that's less than the number of increments because their reads / writes interleaved. - Atomicity violations — an operation that looks like one step but actually isn't. A check-then-act ("if (cache.contains(k)) cache.get(k)") can have an eviction happen between the check and the act. Most concurrency bugs are atomicity bugs hiding behind the language's expressive abstractions.
- Memory-visibility issues — one thread writes a value, another thread reads it later and sees the old value (or a torn intermediate). The cache-coherence primer covers why this can happen at the hardware level; the fix is the memory-ordering mechanism in section 4.
The shared-state problem
Two threads in the same address space (process-thread primer) share every byte of heap and global memory. Without explicit synchronisation, the kernel is free to schedule them in any interleaving — and the CPU is free to reorder their instructions within each thread, as long as the single-threaded program-order is preserved.
Three coping strategies have emerged:
- Avoid sharing. The cleanest approach. Per-thread state, channels for cross-thread communication, immutable data structures. Erlang, Rust (without unsafe), Go (with channels) push this as the default.
- Lock the shared state. Wrap every read and write in a mutex. Easy to write, hard to scale (lock contention), and a different kind of bug emerges (deadlock).
- Lock-free atomics. Use hardware atomic primitives (CAS, fetch-add) to update shared state without locks. Highest throughput, hardest to write correctly. The memory-ordering rules in section 4 are unavoidable here.
Why "just use a mutex" is sometimes wrong
Locks are easy to reason about — one thread at a time inside the critical section. Three failure modes hit them at scale: contention (many threads waiting on the same lock, scheduler thrashing), deadlock (two threads each holding one lock and waiting for the other), priority inversion (low-priority thread holds a lock that a high-priority thread needs). Each has its own mitigation (lock-free, lock ordering, priority-inheritance mutexes), but the fundamental constraint stays: a global lock caps throughput at one critical-section completion per single-CPU latency.
What the kernel can and can't help with
The kernel can suspend a thread (via mutex / condvar / sleep), the kernel can deliver wakeups, the kernel can schedule priorities. The kernel cannot enforce that your code releases locks in the right order, doesn't double-free shared memory, doesn't read a value before another thread writes it. Those are correctness contracts the application owns — and the language runtime can only help so much (Rust's borrow checker is the most aggressive attempt to make some of these compile-time errors).
The takeaway. "Concurrency bugs come from race conditions, atomicity violations, and memory-visibility surprises. Three strategies cope: avoid sharing, lock the shared state, lock-free atomics. Each trades safety for throughput differently. The kernel provides primitives but can't enforce correctness — that's the application's job, increasingly assisted by language-level checks (Rust ownership, Java thread-safety annotations)."
Locks — mutex, semaphore, condvar
The classical kit. Three primitives are enough to build every higher-level concurrency abstraction. Knowing what each guarantees and when each is the right answer is interview-rare but production-frequent.
Mutex
A mutex (mutual exclusion lock) ensures only one thread at a time is inside its critical section. Operations:lock (acquire; block if held by someone else) andunlock (release). Implementations:
- Spinlock — busy-loop until the lock is free. Burns CPU but avoids the syscall cost of going to sleep. Used inside the kernel for short critical sections, or in lock-free libraries for very brief contention.
- Sleeping mutex (pthread_mutex) — try to acquire (often via a spin first); if contended, syscall into the kernel to sleep on a futex (next section). The standard userspace mutex.
- Adaptive mutex — spin a few thousand cycles first, then sleep. Cheap if contention is brief; correct if it isn't.
Mutex contention is the single most common scaling bottleneck. Tools like perf lock, jstack thread-dump analysis, or pprof's mutex profiles identify which lock is hot.
Semaphore
A semaphore is a mutex generalised to N — at most N threads can be inside the critical section at once. Operations:wait (decrement count; block if 0) and signal (increment count; wake a waiter if any). Used for rate-limiting (e.g. only N concurrent connections to a slow service), bounded buffers, and resource pools.
Condition variable
A condvar lets one thread wait for a condition to become true, signalled by another thread. Critically, it always pairs with a mutex — the condvar protocol is:
// waiter
lock(mtx);
while (!condition) {
cond_wait(cv, mtx); // atomically releases mtx and sleeps;
// reacquires mtx on wake
}
// condition is true here
unlock(mtx);
// signaller
lock(mtx);
make_condition_true();
cond_signal(cv); // wake one waiter (or cond_broadcast for all)
unlock(mtx);The while loop on the waiter side is mandatory — spurious wakeups (the waiter wakes without a corresponding signal) are allowed by POSIX, and on multi-waiter cases a different thread may consume the signal's state change before this one gets to check. The classical "wait, check, sleep, recheck" loop is the pattern.
Condvars are how higher-level constructs like Java's BlockingQueue, Go's sync.Cond, or Rust's channels work under the hood.
RWLock
A reader-writer lock allows many concurrent readers OR one exclusive writer. Useful when reads vastly outnumber writes (caches, configuration), but the bookkeeping is more expensive than a plain mutex — and a single writer can be starved by a constant stream of readers (most implementations prefer writers as a fairness measure).
Deadlock
Lock acquisition can deadlock when two threads each hold a lock and need the other's. Classic four conditions (Coffman): mutual exclusion, hold-and-wait, no preemption, circular wait. The standard fix is lock ordering — always acquire locks in a fixed global order. Static analysis tools (clang-tidy, Java's @GuardedBy annotations) catch some cases; runtime detection (gdb's mutex tracker, Java ThreadMXBean) catches others. The cleanest fix is to not nest locks at all.
The takeaway. "Mutex: one thread at a time in the critical section. Semaphore: up to N threads. Condvar: wait for a condition another thread will signal (always paired with a mutex; always loop on a predicate). Deadlock arises from nested locks acquired in different orders; fix with global lock order or avoid nesting altogether."
Atomics and the futex underneath
Modern locks are built on top of two layers: hardware atomic instructions (CAS, fetch-add) and the kernel's futex syscall for the "wait if contended" case. Knowing both demystifies what your std::mutex actually does.
Hardware atomic operations
CPUs expose a small set of operations that read-modify-write a memory location atomically with respect to other cores. On x86, most are encoded as a normal instruction with a LOCKprefix:
- LOCK XADD — atomic fetch-and-add. Returns the old value, adds. Used for atomic counters.
- LOCK CMPXCHG — atomic compare-and-swap. If memory equals expected, set to new value; return whether the swap happened. The foundation of every lock-free algorithm.
- LOCK BTS / BTR — atomic bit-set / bit-reset. Used in some lock implementations as a lightweight signal.
- MOV with appropriate alignment — naturally aligned 1/2/4/8-byte stores are already atomic on x86 (no LOCK needed), but you still need memory-ordering annotations for visibility to other cores.
ARM uses a different mechanism: LDREX (load-exclusive) reserves a cache line and STREX attempts to write — succeeds only if no other core has touched the line since the LDREX. CAS becomes a small retry loop. ARMv8.1 added direct CASAL / SWPAL instructions as a faster alternative for simple cases.
Cost (cache-coherence primer): uncontended atomic ~10-25 cycles, contended ~100 ns per bounce. Lock-free isn't free.
Futex — the fast userspace mutex
A futex (Fast Userspace muTEX) is a Linux kernel primitive that lets userspace implement locks with minimal kernel involvement. The key insight: if the lock isn't contended, no syscall is needed — just do an atomic compare-and-swap in userspace. Only on contention does the kernel need to put the waiter to sleep.
Futex syscall (futex(addr, op, val, ...)) operations:
- FUTEX_WAIT — "atomically check *addr == val and if so, sleep until woken on this addr." The atomic check prevents the lost-wakeup race.
- FUTEX_WAKE — wake N (typically 1) waiters waiting on this addr.
- FUTEX_REQUEUE — wake some and move others to a different futex. Used by pthread_cond_broadcast to avoid thundering-herd issues.
How pthread_mutex uses futex
A modern pthread_mutex_lock implementation roughly:
int* state; // 0 = unlocked, 1 = locked, 2 = locked-with-waiters
// fast path: compare-and-swap 0 → 1
if (CAS(state, 0, 1) == 0) return; // uncontested, done — no syscall
// contended path: mark as locked-with-waiters and sleep
while (true) {
int v = atomic_exchange(state, 2); // mark as having waiters
if (v == 0) return; // got it
futex_wait(state, 2); // sleep until woken
}
// unlock
if (atomic_exchange(state, 0) == 2) { // was locked-with-waiters?
futex_wake(state, 1); // wake one waiter
}The fast path is one atomic instruction — no syscall, ~10 ns. The slow path is one futex_wait syscall — ~1 μs. So mutexes are very cheap when uncontended and reasonably cheap when contended; the worst case (lots of threads constantly contending) is dominated by the cost of bouncing the state cache line.
Lock-free data structures
Build a queue / stack / hash table using only atomic primitives instead of locks. Benefits: no thread can be blocked by a slow peer (no deadlock, no priority inversion). Costs: implementing them correctly is famously hard, the ABA problem makes naive CAS loops wrong, and reclaiming memory of removed nodes is its own problem (hazard pointers, epoch-based reclamation, RCU). Java's ConcurrentLinkedQueue, C++ Boost.lockfree, Rust's crossbeam are the production-ready implementations.
The takeaway. "Atomics (CAS, fetch-add) are hardware operations that update a memory location atomically across cores. Modern mutexes use the futex syscall: the fast path is one atomic CAS in userspace; only contention triggers a kernel sleep. Lock-free data structures avoid the kernel entirely but are notoriously hard to write correctly — use library implementations."
Memory ordering — the one almost everyone gets wrong
The CPU and compiler reorder loads and stores for performance. Memory-ordering annotations are how you tell them "don't reorder past here." This is the highest-impact, least-known part of concurrent programming.
Within a single thread, the as-if rule guarantees the program sees its own operations in program order — the compiler can reorder them internally as long as the observable result is the same. Across threads, no such guarantee exists. Thread B reading two values that Thread A wrote may see them in either order, in any interleaving — unless the language and CPU agreed to enforce a specific order.
The four memory orders you need to know
- relaxed — atomicity only, no ordering guarantees. The atomic operation completes as a single step (no torn reads), but the CPU and compiler are free to reorder surrounding operations across it. Used for counters where you only care about the final total, not the order.
- acquire — load barrier. After this load returns, all later operations in this thread are guaranteed to happen after — none are reordered before. Paired with release on another thread.
- release — store barrier. All operations in this thread before the release are guaranteed to be visible to any other thread that acquires the same atomic. None are reordered past the release.
- seq_cst (sequentially consistent) — the strongest and the C++ default. Adds: all seq_cst operations across all threads agree on a single global total order. Stronger than acquire-release for the rare cases where you need it (Peterson's algorithm, some lock-free constructions).
memory_order is how you tell the compiler and CPU "don't reorder past this point." Get it wrong on ARM/POWER, code that worked on x86 silently corrupts data.The acquire-release pattern
The producer-consumer scenario is the canonical use:
// producer
data = compute(); // can be relaxed or non-atomic
flag.store(true, memory_order_release);
// consumer
while (!flag.load(memory_order_acquire)) {}
use(data); // sees the data the producer wroteThe release on the producer's store + the acquire on the consumer's load establishes a happens-before relationship: everything the producer did before the release is visible to the consumer after the acquire. This is what every reference-counting, every wait-free signalling, every spsc queue is built on.
Why this varies by architecture
x86 is TSO (Total Store Order): for free, every load is acquire-like and every store is release-like — only store-load reordering across different addresses is allowed. This means "works on x86" lock-free code may break on ARM, which is much weaker. Real example: data; flag = true;in plain C++ works on x86 even without atomics, but on ARM the compiler/CPU can reorder freely, and the consumer may see flag before data.
seq_cst costs extra on x86 (an MFENCE per seq_cst store) and more on ARM (a DMB ISH for both loads and stores). The performance-aware choice is to use the weakest ordering that gives the guarantee you need: relaxed for counters, acquire-release for signalling, seq_cst only when you actually need cross-address global ordering.
The classic mistake
Defaulting to relaxed because "it's faster." Relaxed ordering doesn't establish any happens-before relationship with other operations. Code like counter.fetch_add(1, relaxed) for a per-thread statistic is fine; flag.store(true, relaxed) to signal that data is ready will silently corrupt the consumer's view of data on weakly-ordered hardware. The rule of thumb: if other code depends on what was done before the atomic, you need release; if it depends on what comes after, you need acquire.
The takeaway. "CPUs and compilers reorder for performance; cross-thread ordering must be explicit. relaxed: atomicity only. acquire: load barrier (nothing later moves before). release: store barrier (nothing earlier moves after). seq_cst: global total order across all seq_cst ops. x86 is forgiving (TSO); ARM is strict. The classic mistake is using relaxed when the algorithm needs acquire-release; it works on x86 and breaks on ARM."
Quick reference
Six questions worth being able to reason about cold, and five red flags to spot in a code review.
What's the difference between mutex, semaphore, and condvar?
Mutex: one thread at a time in the critical section. Semaphore: up to N threads (counted resource). Condvar: wait for a condition another thread will signal; always paired with a mutex; always called inside a while loop on the predicate to handle spurious wakeups.
How does pthread_mutex_lock actually work?
Fast path: an atomic compare-and-swap on a state word in userspace (~10 ns, no syscall). If contended: the implementation marks the mutex as locked-with-waiters and calls futex_wait to sleep until released. Unlock atomically clears the state and, if there were waiters, calls futex_wake. Most mutex acquisitions in practice never touch the kernel.
What's the difference between acquire and release ordering?
Acquire is a load barrier: nothing in the program after the acquire load can move before it (in observable behaviour). Release is a store barrier: nothing before the release store can move after it. They pair across threads: a release on one thread + acquire on another establishes a happens-before relationship between operations before the release and operations after the acquire.
Why might lock-free code that works on x86 break on ARM?
x86 has Total Store Order (TSO): loads are acquire-like and stores are release-like by default. ARM is weakly ordered: without explicit acquire/release annotations, the compiler and CPU can freely reorder operations across atomics. Naive synchronisation patterns that "happen" to work on x86 because of TSO silently corrupt on ARM. The fix is to use explicit memory_order annotations on every atomic.
What's a deadlock and how do you prevent one?
Two (or more) threads each holding a lock and waiting for the other's. Standard prevention: lock ordering— every thread must acquire locks in a fixed global order. If you only ever acquire lock A before lock B, you can't have one thread holding A waiting for B while another holds B waiting for A. Alternative: don't nest locks at all (the cleanest fix). Detection at runtime via gdb's mutex tracker, Java's ThreadMXBean.findDeadlockedThreads, or tools like ThreadSanitizer.
When is a spinlock the right primitive vs a sleeping mutex?
Spinlock when: the critical section is very short (a few instructions), contention is rare, and the syscall overhead of sleeping would dominate the actual work. Used inside the kernel and in some lock-free libraries. Sleeping mutex when: the critical section may take meaningful time, or there's contention, or you don't want one thread to burn CPU while waiting. Almost all userspace code wants a sleeping mutex (which is what pthread_mutex is); modern adaptive mutexes spin briefly first to get the spinlock benefit when contention is brief.
Red flags in code review
- Condvar wait without a
whileloop. POSIX permits spurious wakeups; without the loop, you wake up and act on a state that may not be true. - memory_order_relaxed used for inter-thread signalling. Works on x86, broken on ARM. Use acquire-release at minimum for anything that establishes a visibility relationship.
- Multiple locks acquired in different orders in different paths. Deadlock waiting to happen. Establish and document a global lock order; assert it with static analysis.
- Holding a mutex across a syscall. If the syscall blocks (read, write, file open, etc.), every other thread wanting the same mutex sits idle. Restructure to do the syscall outside the critical section.
- Hand-rolled lock-free data structures. Always a bug. Use a library (Java's java.util.concurrent, C++ Boost.lockfree, Rust crossbeam) or fall back to a mutex.