Atomics & Lock-Free

Wait-Free vs Lock-Free: Whose Progress Is Guaranteed

Lock-free guarantees that some thread completes. Wait-free guarantees that every thread completes, in a bounded number of its own steps. The gap between "some" and "every" is where one unlucky thread starves while every dashboard says the system is healthy.

▶ Run the lab

The question this answers

The question

If lock-free guarantees progress, why can one particular thread still make none?

The work

Eight threads CAS-ing the same shared word, one of which is an audio callback that must complete before its next buffer deadline.

What is shared

One atomic word that every thread contends for on every operation.

The invariant — what must stay true under every interleaving

Wait-freedom: every thread completes its operation within a bounded number of its own steps, regardless of what any other thread does.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The thread that never wins

Take the CAS loop from Compare-and-Swap and the Retry Loop and run three threads through it, where one of them recomputes more slowly than the others — because its loop body is longer, because it is on a busier core, because it took a cache miss. Every round, one of the fast threads commits and the slow thread's CAS fails. System-wide progress is continuous. The slow thread completes nothing.

Nothing in the lock-free definition is violated by this. Lock-free says at least one thread completes in a bounded number of system-wide steps, and one always does. The thread that starves is unlucky, not incorrect. The schedule below marks the step where wait-freedom dies while lock-freedom stays intact — the only schedule in this module where violates refers to a guarantee the system was never claiming to give.

This is why "lock-free" is a poor answer to a latency question. It is a liveness property of the structure, and liveness of the structure is not liveness of your thread. If your requirement is "this callback must finish within 5ms, every time", lock-free does not supply it and a benchmark of the mean will not reveal the gap.

Three threads in a CAS loop. Thread C is slower to recompute. Lock-freedom holds throughout.SIMULATED
Invariant · Wait-freedom: every thread completes within a bounded number of its own steps
#Thread A (fast)Thread B (fast)Thread C (slow recompute)State
1··load v -> 10; begin recompute (long)v=10 C.expected=10
2load v -> 10; CAS(10 -> 11) success··v=11
3··CAS(10 -> 15) FAILS; expected now 11; restart recomputev=11 C.attempts=1
4·load v -> 11; CAS(11 -> 12) success·v=12
5··CAS(11 -> 16) FAILS; expected now 12; restart recomputev=12 C.attempts=2
6load v -> 12; CAS(12 -> 13) success··v=13
7··CAS(12 -> 17) FAILS; expected now 13; restart recomputev=13 C.attempts=3
✕ C has taken three full rounds and completed nothing, with no bound on how many more it will take. Wait-freedom is violated. Lock-freedom is NOT — a thread completed in every round.
Throughput is healthy, the structure is correct, and one thread has made zero progress. A p99 latency metric for C would be climbing while the system-wide operations-per-second metric looks fine — which is why this is diagnosed late, if at all.

What buying wait-freedom actually costs

The technique that closes the gap is *helping*. Instead of each thread retrying its own operation until it wins, a thread announces what it wants to do in a slot other threads can see, and any thread that finds an outstanding announcement completes that operation on the announcer's behalf before proceeding with its own. Now no thread can be left behind, because the threads that are winning are obliged to carry it.

The costs are structural, not incidental. There must be per-thread state proportional to the number of participants, which means the thread count must be known or bounded. Every operation now scans for announcements, so the *uncontended* fast path gets slower — you pay on every operation for a guarantee you need only in the worst case. And the operation description must be something another thread can execute, which constrains what operations you can offer at all.

The pseudocode below is a sketch of the shape, not a working construction. Real wait-free algorithms are considerably subtler, particularly around a helper and the owner both completing the same announcement. Herlihy's universal construction proves that any sequential object has a wait-free implementation given CAS — an existence result worth knowing and a poor guide to what to build.

1announce[NUM_THREADS] // one slot per participating thread
2 // wait-freedom needs a KNOWN, BOUNDED thread count
3
4operation(myId, desiredChange):
5 announce[myId] = { change: desiredChange, done: false }
6
7 # Before doing my own work, help anyone who is outstanding.
8 # This is the whole idea: winners are obliged to carry losers.
9 for each t in 0..NUM_THREADS-1:
10 if announce[t] is pending:
11 try_to_complete(announce[t]) # idempotent; owner may also
12 # be doing this concurrently
13
14 # By the time every other thread has run its helping loop once,
15 # my announcement is complete whether I ever won a CAS or not.
16 wait_until(announce[myId].done)
17
18# COST, stated plainly:
19# - scan of NUM_THREADS slots on EVERY operation, contended or not
20# - per-thread state; thread count must be bounded in advance
21# - try_to_complete must be idempotent and safe to run concurrently
22# - the uncontended fast path is now slower than the lock-free version
The helping pattern, in shape only. SIMPLIFIED — not a correct construction.

Which guarantee does this workload actually need

Almost no server code needs wait-freedom. Servers have soft deadlines, retry budgets and load balancers; a thread that loses a few CAS rounds is invisible against network variance. Spending the fast-path cost of a helping construction to fix a problem that never manifests is a straightforwardly bad trade.

Wait-freedom is bought where a missed deadline is a defect rather than a slow response: an audio callback that must fill a buffer before the DAC drains it, a control loop with a fixed period, a flight or medical system with a certified worst-case execution time. In those settings a bound on your own steps is the requirement, and no amount of average-case throughput substitutes.

Between the two sits the honest majority answer, which is a mutex. Blocking gives no non-blocking guarantee at all, but a fair mutex does bound waiting in a practical sense — the OS scheduler eventually runs everyone — and it is far easier to reason about. "We used a lock and measured the tail" beats "we used lock-free and assumed the tail" in every case where you have not written down which participant can stall.

SettingGuarantee actually neededWhyReasonable choice
HTTP request handler updating a counterNone beyond correctnessDeadlines are soft; scheduler variance dwarfs CAS retriesMutex or a plain atomic
Shared ring between two processes, either may crashLock-freeA dead process holding a lock is unrecoverable; a dead process mid-CAS is harmlessLock-free ring over shared memory
Signal handler or interrupt contextLock-free at minimumBlocking is not permitted here at all; the handler may run on a thread already holding the lockLock-free structure over preallocated storage
Audio callback with a per-buffer deadlineWait-freeA missed buffer is an audible defect, and the bound must be on THIS thread's stepsWait-free SPSC queue, or single-producer single-consumer by design
Hard real-time control loop, certified WCETWait-freeA worst-case bound must be provable, not measuredWait-free construction or no sharing at all
Batch job aggregating resultsNoneThroughput is the only metric; individual thread latency is irrelevantPer-thread accumulation, combined at the end
Match the guarantee to the requirement, not to the reputation.

Key points

  • Lock-free: some thread completes in a bounded number of system-wide steps. Wait-free: every thread completes in a bounded number of its own steps.
  • Lock-free explicitly permits starvation of an individual thread. That is not a flaw in an implementation; it is the definition.
  • Both are progress guarantees. Neither is a performance claim, and wait-free is usually the slower of the two on the uncontended path.
  • Wait-freedom is bought with helping: winners complete losers' announced operations, at the cost of per-thread state and a bounded thread count.
  • Match the guarantee to the requirement. A missed hard deadline needs wait-freedom; a slow HTTP response does not need either.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • Lock-free: each thread retries its own operation; a failed attempt implies another thread succeeded, so the system advanced.
  • That implication is exactly why nothing bounds an individual thread — the progress it observes may always belong to someone else.
  • Wait-free: a thread first publishes its intended operation where others can see it.
  • Every thread, before or while doing its own work, completes any outstanding published operations it finds.
  • Because the number of participants is bounded and every operation is helped, each thread's operation completes within a bound derived from that count — which is why the count must be known in advance.
Interleavings that matter
  • A commits, C fails; B commits, C fails; A commits, C fails. Lock-freedom holds in every round; C has completed nothing and no bound says when it will.
  • With helping: C announces; A runs its helping loop and completes C's operation before its own; C observes done without ever winning a CAS. Bounded by the participant count.
  • A holds a mutex and is descheduled: neither lock-free nor wait-free holds, and B and C are blocked for as long as A is off-CPU.
  • Single producer, single consumer over a ring buffer with separate head and tail indices: there is no contention at all, so the operation is wait-free trivially — by design rather than by construction. This is the cheapest wait-freedom available and it is why SPSC rings are the standard answer in audio.
What it guarantees — and does not
  • Lock-free promises: system-wide progress; no deadlock; immunity to a suspended participant.
  • Lock-free does NOT promise: that any particular thread ever completes. Starvation is permitted by the definition.
  • Wait-free promises: a bound on each thread's own steps, independent of every other thread's speed or suspension.
  • Wait-free does NOT promise: a small bound, a fast average case, or a fast uncontended path. The bound is usually proportional to the participant count.
  • Neither promises: higher throughput than a mutex, a simpler implementation, or freedom from memory-reclamation obligations.
  • Neither survives an unbounded participant count: a thread pool that grows without limit invalidates the wait-free bound it was proved against.
Where contention appears
  • Under lock-free, contention converts directly into retries, and retries are unevenly distributed — the slowest recomputer absorbs almost all of them.
  • Under wait-free helping, contention converts into redundant work: several threads may execute the same announced operation, and only the idempotence of try_to_complete keeps that correct.
  • The helping scan makes the uncontended path proportional to participant count, so wait-free structures are relatively worse the *less* contended the workload is.
  • The cheapest escape from both is to remove the contention: single-producer single-consumer rings, per-thread state, or sharding. See What Contention Actually Costs.
How it fails
  • Starvation — the defining failure of lock-free, and invisible in throughput metrics.
  • Livelock — threads retrying against each other with system throughput collapsing toward zero useful work. See Livelock.
  • Deadline miss — the concrete consequence in the settings where wait-freedom is required: a dropped audio buffer, a late control-loop tick.
  • Bound invalidation — a wait-free construction proved for N threads, deployed on a pool that scales past N.
  • Priority inversion under a mutex, which is the failure the non-blocking guarantees exist to avoid. See Priority Inversion.
  • Helper/owner double-completion when try_to_complete is not genuinely idempotent — a wait-free-specific bug with no lock-free analogue.
When it helps
  • Wait-free helps where the requirement is a bound on this thread's worst case, stated as a deadline someone will notice missing.
  • Lock-free helps where a participant can stall or die and the others must continue: cross-process shared memory, signal handlers, kernel paths.
  • Both help as vocabulary in design review: "which threads are guaranteed to finish, and within what bound?" is a much better question than "is it lock-free?".
  • Naming the guarantee also names the metric — if you claim wait-freedom you must measure per-thread worst case, not throughput.
When it hurts
  • Wait-free hurts in throughput-oriented code: you pay the helping scan on every operation to fix a tail nobody was measuring.
  • Lock-free hurts when chosen for speed, since it does not promise speed and often does not deliver it.
  • Both hurt when the participant count is unbounded, which invalidates the reasoning the guarantee rests on.
  • Both hurt when the simpler restructuring — one producer, one consumer, no sharing — was available and skipped.
How you would know
  • Per-thread completion latency, not aggregate throughput. Starvation is invisible in operations-per-second by construction, and only shows up in a per-thread p99 or max.
  • Retry counts per thread, exported separately. A distribution with one long tail is a starving thread; a uniformly high distribution is contention.
  • For deadline work, count missed deadlines directly — dropped buffers, late ticks. That is the only metric that measures the property you bought.
  • Test with deliberately heterogeneous threads: pin one to a busy core or give it extra work, because a benchmark of identical threads cannot produce the failure. See Stress Testing: A Test That Passed Once Proves Nothing.
  • For a wait-free construction, verify the bound against the actual maximum thread count in production, not the one it was designed for. See Sizing a Thread Pool.
Complexity it introduces
  • Wait-free constructions add per-thread announcement state, an idempotent completion routine, and a proof obligation tied to a fixed participant count.
  • The helping path is concurrent code that runs rarely, which makes it the least-tested and most-likely-wrong part of the implementation.
  • The guarantee becomes a documented contract other code depends on, so the participant count becomes a deployment constraint rather than a tuning knob.
  • Reviewing a wait-free algorithm requires reasoning about a helper and an owner racing on the same announcement — strictly harder than lock-free review.
Simpler alternatives
  • Eliminate the contention. A single-producer single-consumer ring is wait-free by construction, with no helping and no announcement state. This is the answer in real-time audio far more often than any general construction.
  • A mutex plus a measured tail. Blocking with a known distribution beats non-blocking with an assumed one.
  • Per-thread state combined at a join point — no shared location, so every progress question becomes trivial. See Copy or Share?.
  • A bounded queue with backpressure, moving the coordination into a structure someone else has already made correct. See Bounded vs Unbounded Queues.
  • Reduce the participant count. Wait-free bounds scale with it, and so does contention, so fewer threads improves both. See More Threads Is Not More Speed.

compare_exchange in a loop — retries, and the pointer that lied

compare_exchange in a loop
Read the value, compute a new one, swap it in only if nobody changed it meanwhile — otherwise start over. The loop is lock-free: somebody always makes progress. It is not free: everybody else did the work twice.
do {
    old = counter.load();          # 1 read
    next = old + 1;                # compute off to the side
} while (!counter.compare_exchange(old, next));   # swap only if unchanged
successes
8
CAS attempts
36
wasted retries
28
attempts per success
4.5
Total CAS attempts to complete N increments
1 thread1 · 1 succeed, 0 wasted · 1.0× the work per increment
2 threads3 · 2 succeed, 1 wasted · 1.5× the work per increment
4 threads10 · 4 succeed, 6 wasted · 2.5× the work per increment
8 threads36 · 8 succeed, 28 wasted · 4.5× the work per increment
16 threads136 · 16 succeed, 120 wasted · 8.5× the work per increment
32 threads528 · 32 succeed, 496 wasted · 16.5× the work per increment
CAS succeeds on a stale pointer
Invariant · head points at a live node, and the stack contains exactly the nodes pushed and not yet popped.
#T1 — pop() via CAST2 — another threadState
1old ← head (= A)·head=A stack=A→B→C
2·pop() → Ahead=B stack=B→C
3·pop() → Bhead=C stack=C
4·push(A)head=A stack=A→C
5CAS(head, A, B) → SUCCESS·head=B stack=B→ freed
✕ head now points at B, which was popped and freed. Node C has vanished from the stack and T1 returned a node it never observed being on top.
6return A to the caller·head=B stack=corrupt
At 8 threads the loop costs 36 attempts for 8 increments — 4.5 attempts each, and the total grows as N²/2 while the useful work grows as N. Half the machine is now computing values that will be thrown away, and every failed attempt still pays for exclusive ownership of the cache line. Turn the guard on and watch the same schedule end differently. Without it, T1 asks "is head still A?" — the only question CAS can ask — and A is indeed back on top. But it is on top of a different stack: B was popped and freed while T1 was looking away, and the CAS happily installs a pointer to reclaimed memory. This is the ABA problem, and it is not a race in the usual sense: nothing was concurrent at the moment of the CAS, the world simply changed and changed back. Lock-free is a progress guarantee — some thread always advances — not a speed guarantee. Under this much contention a plain mutex often wins, because it lets the losers sleep instead of burning cores computing values nobody will keep.
SIMULATEDWorst-case contention: every thread attempts every round and exactly one wins. Real hardware backs off, and cache-line ownership changes the constant — the quadratic shape does not.

A lock-free stack, one head pointer

A lock-free stack, one head pointer
Push is: read head, point your node at it, swap head to your node — but only if head has not moved. Two pushers and a popper share that one word. Step them yourself and watch a losing CAS turn into a retry instead of a corruption.
push(node):                        pop():
    do {                              do {
        t = head;                         t = head;  if (t == null) return null;
        node.next = t;                    n = t.next;
    } while (!CAS(&head, t, node));   } while (!CAS(&head, t, n));
head
A
stack
A→∅
failed CAS retries
0
still running
P1, P2, C1
Invariant · every node that was pushed and not yet popped is reachable from head.
#Pusher 1 — push(X)Pusher 2 — push(Y)Popper — pop()State
1t ← head (= A)··head=A stack=A→∅
2·t ← head (= A)·head=A stack=A→∅
3X.next ← t (= A)··head=A stack=A→∅
4·Y.next ← t (= A)·head=A stack=A→∅
No CAS has failed yet: every publish so far saw the head it had read. Interleave the pushers more aggressively — step P1 once, then P2 twice — to force a failure and watch the retry recover. The head pointer is the entire synchronization here, and it protects exactly one invariant: the list is never observed half-linked, because a node is fully prepared privately and then published in a single indivisible write. What it does not solve is the popper handing a node back to the allocator while another thread is still dereferencing it — the hardest part of any real lock-free structure is not the CAS, it is knowing when memory is safe to free.
SIMPLIFIEDThis is a teaching model, not production code. It has no memory reclamation (a real popper cannot free the node it removed while another thread may still be reading it — that needs hazard pointers, epochs or RCU), no ABA guard, and no memory ordering annotations. Do not ship this.

Eight threads, one lock

Eight threads, one lock
Every thread does some work, then takes the same mutex. Watch how much of each lane is spent waiting for a turn, and what the machine actually delivers.
8 cores
Thread 1
work
lock
work
wait
lock
work
Thread 2
work
wait
lock
work
wait
lock
work
Thread 3
work
wait
lock
work
wait
lock
Thread 4
work
wait
lock
work
wait
Thread 5
work
wait
lock
work
wait
Thread 6
work
wait
lock
work
wait
Thread 7
work
wait
lock
work
wait
Thread 8
work
wait
lock
work
wait
runningreadywaitingblockedidle24 ms of wall clock
throughput
500/s
effective parallelism
2.50 / 8
lock busy
90.0%
mean lock wait
18 ms
serialised share of each task0.4 · 2.0 ms locked of 5.0 ms total — 40.0%
The critical section is busy 90% of the time. It is now the ceiling: more cores and more workers change nothing. Effective parallelism is 2.5 on 8 cores — the definition of false parallelism. Shrink the critical section or shard the lock. The critical section is 40.0% of each task, so 2.5 of 8 cores' worth of work is really happening at once. Waiting is not evenly distributed either: mean lock wait is 18 ms, and the tail is far worse than the mean because queueing delay grows non-linearly as the lock approaches saturation. Contention is not caused by threads; it is caused by the fraction of the work that must be serialised. Adding threads to a contended lock adds queue, not capacity — and past that point each extra thread makes the tail latency worse while leaving throughput exactly where it was.
SIMULATEDLanes are a discrete simulation of one mutex granted in arrival order; throughput comes from the lab model. Neither is a measurement, and real locks add cache-line traffic this omits.

What people believe, and what is true

Claim

Lock-free means no thread waits.

Reality

No thread blocks. A thread can retry indefinitely, which is waiting with the CPU on and no bound.

Claim

Wait-free is the faster version of lock-free.

Reality

It is the stronger guarantee and usually the slower implementation, because every operation pays the helping scan whether or not there was contention.

Claim

Our system is lock-free, so tail latency is fine.

Reality

Lock-freedom says nothing about any individual thread's latency. Tail latency needs a per-thread measurement regardless of the progress guarantee.

Go deeper

Overview

Lock-free: someone always finishes. Wait-free: everyone always finishes, within a bound. The second is stronger and more expensive.

Practical

Ask what happens to the unluckiest thread. If the answer must be "it finishes within X", you need wait-freedom or no sharing. If the answer can be "it takes a bit longer", lock-free — or a mutex — is enough.

Advanced

Helping makes the bound proportional to the participant count, so wait-freedom and scalability pull in opposite directions: more threads means a larger bound and a slower fast path. This is why the practical answer in real-time systems is usually to remove sharing rather than to construct a wait-free version of it.

Internals

Herlihy's universal construction (1991) shows any sequential object has a wait-free implementation given consensus objects, and CAS has unbounded consensus number. The construction is a proof that it is possible, with performance nobody would ship. Practical wait-free algorithms are hand-built per structure.

Apply it