Locking is the easy answer to concurrency: a mutex makes parallel code sequential at the critical section. The price is scalability under contention — every locked thread waits. Lock-free algorithms make progress guarantees: at least one thread makes progress in a bounded number of steps, even if others are descheduled. Wait-free algorithms guarantee that every thread makes progress in a bounded number of steps. The cost is code complexity and a deep understanding of the hardware’s memory model.
This topic is the algorithmic frontier — what your compiler and CPU can guarantee, and how to use it to write software that scales where locks cannot.
Memory Ordering
Threads communicate through shared memory, but a CPU core does not necessarily see writes from another core in the order they occurred. The memory-model rules govern what reorderings are permitted, and what the programmer must specify.
C++11 / Rust classify atomic operations with five orderings (Java has equivalents: volatile alone is not enough):
| Ordering | Synchronises with | Cost |
|---|---|---|
memory_order_relaxed | Nothing beyond atomicity itself | Cheapest |
memory_order_acquire | Reads after this point cannot move before it; synchronises with a release from the writer | Cheap on x86; heavy on ARM |
memory_order_release | Reads/writes before this point cannot move after it; pairs with acquire | Cheap on x86; heavy on ARM |
memory_order_acq_rel | Both, for a read-modify-write operation | Both costs |
memory_order_seq_cst | All threads see the same total order; the default | Most fences; costliest |
Two axioms that recur:
- Acquire-release is enough for almost everything. Reads from a struct with
acquire-loaded pointer see the writes that happened before the correspondingrelease. The single pairing covers the cross-thread synchronisation. seq_cstis the “I give up reasoning” mode. Use it when portability matters more than micro-optimisation; pay the cost on every platform; free your brain.
The most common bug in lock-free code: assuming relaxed is enough for reads whose values are read by other threads. If thread A publishes a struct’s pointer with relaxed and thread B reads it with relaxed, B may see the pointer but see stale contents for the pointed-to struct (or even see half-updated contents due to memory tearing). Acquire-release is the minimum safe model.
Compare-and-Swap and the ABA Problem
A compare-and-swap (CAS) is an atomic operation: “if *ptr == expected, set *ptr to desired and return true; otherwise, do nothing and return false”. The lock-free idiom:
do {
expected = *ptr
// compute new_value from expected
} while (!CAS(ptr, &expected, new_value))
A lock-free counter is the simplest possible CAS-loop:
fn increment(ptr) {
loop {
old = *ptr
if CAS(ptr, &old, old + 1) { return }
// else another thread won — retry
}
}
Each thread retry-loops on contention. The progress guarantee is lock-free: at least one thread makes progress in O(1) CAS attempts per concurrent thread.
The ABA Problem
The hazard of CAS-based pointers: between reading old and executing CAS, the pointer can be popped, freed, and a new value coincidentally written to the same address — the pointer looks identical (same address), CAS succeeds, but the value semantically changed.
Thread 1 reads p = A → next = B
Thread 2 pops A, frees A
Thread 2 pushes a new value onto the same memory (B happens to be reused)
Thread 1 CAS(p, A, B) succeeds — but the B it now sees is a different list!
Three solutions:
- Tagged pointers: pack a version counter into the high bits of the pointer; CAS includes both. Memory addresses have unused high bits; reuse them.
- Hazard pointers / epoch-based reclamation: never free a node that might be referenced — see below.
- Hazard-protected atomic primitives (
std::atomic_shared_ptr<T>, Java’sAtomicReferencewithcompareAndSet): the language runtime protects against ABA at the cost of extra atomic operations.
Lock-Free Stack (Treiber)
The Treiber stack (1986) is the canonical lock-free structure: a singly-linked list where top is an atomic pointer. Push pops once; each pushes via CAS on top.
push(node):
loop {
node->next = top
if CAS(&top, &node->next, node) { return }
}
pop():
loop {
old = top
if old == null { return null }
next = old->next
if CAS(&top, &old, next) { return old->value }
// ABA protection: tag top with a counter; CAS compares both
}
A Treiber stack is lock-free: under arbitrary contention, at least one thread completes its operation in O(1) retries. It is not wait-free: a thread can lose CAS races indefinitely while others make progress.
| Property | Lock-free | Wait-free |
|---|---|---|
| Progress guarantee | At least one thread makes progress in bounded steps | Every thread makes progress in bounded steps |
| Implementation cost | Moderate (CAS-loop pattern) | High (helping-pattern or consensus-number argument) |
| Throughput under contention | High | Moderate |
| Common examples | Treiber stack, Michael-Scott queue | Seqlock, RCU reads, FAA counter |
Lock-Free Queue (Michael-Scott)
The Michael-Scott queue (1996) is the standard lock-free FIFO: a head pointer and a tail pointer, both atomic, with CAS-based coordination. The trickiest case: empty-to-non-empty transition (tail is behind head), which the queue handles with a “logically removed” sentinel.
enqueue(node):
loop {
tail = this.tail
next = tail.next
if tail != this.tail { continue } // check tail still current
if next == null { // tail is the actual last
if CAS(&tail.next, &next, node) {
CAS(&this.tail, &tail, node) // try to advance tail — failure OK
return
}
} else { // tail lagged; help advance
CAS(&this.tail, &tail, next)
}
}
The pattern (CAS-and-help) shows up in every lock-free linked structure: if you see inconsistency, help the other thread fix it. The cost: more CAS operations in the contended case, but unbounded threads make progress.
Safe Memory Reclamation
The bug in every lock-free structure: the lock-free algorithm frees a node when another thread might still be reading it. Locks serialize access; lock-free code cannot. Three solutions:
Epoch-Based Reclamation
Each thread declares its “epoch”. Nodes freed in an epoch are held in a tombstone list; they are physically freed when no thread is reading in an earlier epoch. The data structure writes a try_advance_epoch in normal code paths and reclaims retired nodes when all threads are in the current epoch.
- Pros: very low overhead in the read path.
- Cons: a thread that stalls (GC pause, page fault, pre-empted in scheduler) blocks reclamation. Memory may grow until the thread reschedules.
Hazard Pointers
Each thread declares up to K hazard slots pointing to nodes it is currently reading. The reclaim code frees a node only when no hazard pointer is pointing to it.
- Pros: deterministic reclamation; the read path is fast.
- Cons:
Khazard slots per thread limits the structure’s read complexity; reclaim overhead isO(retired_count * thread_count)worst case.
RCU (Read-Copy-Update)
A Linux-kernel favourite: every reader enters an RCU read-side critical section with rcu_read_lock(); writers publish a new version by replacing the pointer atomically (with release semantics); old versions are reclaimed after a quiescent state — a period during which no reader was in a critical section.
- Pros: zero overhead in the read path; proven in the Linux kernel for millions of readers.
- Cons: requires a quiescent-state detection mechanism — in the kernel, it’s “context switch”; in userspace, it requires a library (
userspace-rcuorcrossbeam-epoch).
For “read-mostly” data (config tables, routing tables, the kernel’s dcache) where reads are 10⁶× more frequent than writes, RCU is the gold standard.
Seqlock
A seqlock is the simplest wait-free read pattern: a sequence counter protects a writer; readers read the counter, then the data, then the counter again; if either counter read is odd (write in progress) or the sequence numbers differ (write completed between reads), the reader retries.
- Pros: writes are
O(1), reads are wait-free — they make progress in bounded steps regardless of writers. - Cons: every reader must be retryable (no side-effects during the read); the data must fit in a small, writeable region.
The Linux kernel uses seqlock for jiffies, xtime, the dcache. A std::shared_mutex-equivalent in Java is the closest userland approximation, but true seqlocks are the canonical wait-free pattern.
When Lock-Free Beats Locking
| Workload | Better choice |
|---|---|
| Many writers, low read frequency | Lock with a fine-grained mutex |
| Many readers, occasional writers | RCU or seqlock (wait-free reads, no cache-line bouncing) |
| Counter / increment-only ops | Atomic increment (single FAA) — no CAS loop needed |
| Linked-list stack / queue | Lock-free (Treiber, Michael-Scott) when contention is high |
| Complex transactions on shared state | Locking — the complexity of lock-free transactions outweighs the scalability gain |
| Single-threaded, intra-process | No synchronisation at all |
The honest take: locks are the default; lock-free is the optimisation. A mutex-wrapped critical section of O(50ns) and O(10k) acquisitions per second is 0.0005 seconds per second of contended time — fine on a single core, fine on 8. Lock-free becomes relevant at a hundred cores with millions of operations per second on a hot path, and even then only after measurement shows the lock is the bottleneck.
The deeper discipline: understand the memory model, then reach for primitives that match the workload. The right granularity of “lock” varies from “single atomic increment” to “RCU replacement of a 4MB data structure” — and the engineering decision is the same at every scale.
Practice Trajectory
- Implement a lock-free counter using
std::atomic<int>::fetch_addand a CAS loop. Compare the throughput against astd::mutex-protected counter on a 32-thread stress test. - Build a Treiber stack with tagged pointers to defeat ABA. Test by popping every value concurrently; verify the output sequence matches a known push order.
- Implement a Michael-Scott queue; verify with a stress test that every pushed value is dequeued exactly once and never lost.
- Pick a lock-free structure and apply epoch-based reclamation. Measure the memory-usage plateau under churn; compare with naive
free()(which crashes) and with hazard pointers. - In a system where locks dominate hot-path time, identify a candidate. Sketch the lock-free version. Calculate whether the simplification is worth the memory-ordering reasoning cost.
When It’s the Right Tool
| Situation | Takeaway |
|---|---|
| Many cores contending on a single hot counter | Atomic fetch-add — a single FAA instruction replaces a mutex-protected read-modify-write |
| Hot read-mostly structure | RCU or seqlock — readers make progress without cache-line bouncing |
| Producer-consumer queue with high contention | Michael-Scott lock-free queue — scales linearly with cores where a locked queue does not |
| Linked structure where lock contention is the bottleneck | Treiber / Michael-Scott — but first profile; locks are easier to reason about |
| Memory reclamation under concurrent reads | Epoch-based or hazard pointers — never free() in lock-free code without them |