Compare-and-Swap: The Primitive Everything Is Built On
Change this value, but only if it is still what I last saw. That conditional write is what makes lock-free algorithms possible, and the trap in it — that "still the same value" is not the same as "nothing happened" — is called ABA.
Software view, hardware view
The gap between what you wrote and what the machine does is where this whole domain lives.
The operation and its loop
Compare-and-swap takes an address, an expected value and a new value. Atomically, it compares the contents against the expected value; on a match it writes the new value and reports success; otherwise it leaves memory alone and reports failure, usually returning what it actually found. As Atomic Instructions: What the Hardware Actually Guarantees describes, this is LOCK CMPXCHG on x86-64, CAS on AArch64 with LSE, and an LR/SC pair elsewhere.
The reason it matters is that it collapses check-then-act into one indivisible step. The broken pattern — read, compute, write — has a window in which another thread can update the location, and your write silently discards their change. CAS makes that window detectable: if someone updated it, your comparison fails and you know to start over with the value they left.
That "start over" is why almost all CAS use is a loop. Read the current value, compute the new one, attempt the swap, and on failure re-read and recompute. The loop terminates because a failure means someone else made progress — the algorithm as a whole advances, even though this thread had to retry, which is the formal property called lock-freedom. It is a weaker promise than it sounds: an individual thread can be starved indefinitely while the system keeps making progress.
1// RACY: read-compute-write with a window2int v = shared.load();3v = expensive_transform(v);4shared.store(v); // silently discards any concurrent update5 6// CORRECT: CAS loop7int cur = shared.load();8int next;9do {10 next = expensive_transform(cur);11 // on failure, 'cur' is updated with what was actually found12} while (!shared.compare_exchange_weak(cur, next));13 14// Each failure means ANOTHER thread succeeded: the system15// makes progress even when this thread does not. That is16// lock-freedom -- and note it does not promise THIS thread17// ever finishes.18 19// Note the cost shape: expensive_transform() runs again on20// every retry. Heavy computation inside a CAS loop is21// wasted work under contention.ABA: the change CAS cannot see
CAS compares values, and a value is not a history. If another thread changes the location from A to B and back to A before your comparison runs, your CAS succeeds — the value is exactly what you expected — even though the world it referred to has been dismantled and rebuilt in between.
With a plain integer counter this is usually harmless. With a pointer it is a memory-corruption bug. The canonical case is a lock-free stack: you read the head pointer as node A and prepare to pop it by swinging head to A's successor. Before your CAS, another thread pops A, pops the node after it, frees them, allocates a new node that happens to land at A's old address, and pushes it. Your CAS sees head == A, succeeds, and sets head to a successor pointer read from a node that has been freed and reused. The stack now points into reclaimed memory.
The standard mitigations all work by making the comparison see more than the raw value. A version-tagged pointer packs a counter alongside the address so that A-with-tag-7 differs from A-with-tag-9, requiring a double-width CAS. Hazard pointers and epoch-based reclamation attack it from the other side, by preventing memory from being reused while any thread might still be looking at it. LL/SC is structurally immune, because the store-conditional fails if the reservation was cleared at all — it tracks *access*, not *value* — which is a genuine architectural advantage of that style.
When not to reach for CAS
CAS is the general primitive, which makes it tempting as the default. It is frequently the wrong default. Where the operation is a plain arithmetic update, fetch_add does it in one guaranteed-successful transaction, while a CAS loop under contention may retry many times — and each retry is a fresh coherence round trip for the line, so contention makes the loop worse in a way that fetch-and-add avoids entirely.
The comparison against a mutex is also less one-sided than lock-free advocacy suggests. As What a Mutex Actually Does describes, an uncontended mutex acquisition is itself a single atomic operation. What a mutex buys under contention is that waiters stop competing for the cache line and park; what a CAS loop does under contention is keep every thread hammering that line. Beyond a modest number of contending threads, the parking behaviour can win comfortably.
The honest decision procedure is short. Use fetch_add if the operation is expressible as one. Use a mutex if the critical section is more than a few instructions or contention is high. Reach for a CAS loop when the update is genuinely conditional, the critical section is tiny, and you have measured. And if the loop protects a pointer that can be freed, ABA is not a theoretical concern — resolve it before writing the code, not after the crash.
1int cur = counter.load();2while (!counter.compare_exchange_weak(cur, cur + 1)) {3 // retry; 'cur' refreshed with the observed value4}5 6// Under contention every failed attempt is another7// coherence round trip for the line. Throughput can8// fall as cores are added.1counter.fetch_add(1);2 3// One indivisible operation, no retry, no loop.4// Still costs line ownership under contention --5// but it costs it exactly once per increment.A CAS loop pays for the ability to make the write conditional. When the write is not conditional, that is a pure loss: you get retries, wasted recomputation and extra coherence traffic in exchange for a capability the operation never used. Reach for CAS when the update genuinely depends on the observed value.
Key points
- CAS writes only if the location still holds the expected value, which collapses check-then-act into one indivisible step.
- Almost all CAS use is a retry loop; each failure means some other thread made progress, which is what lock-freedom guarantees — and it does not guarantee any individual thread finishes.
- ABA: a value that changes to B and back to A defeats the comparison, because CAS compares values and not history.
- Mitigations are version tags, hazard pointers, epoch reclamation, or LL/SC — which is immune because it tracks access rather than value.
- Prefer
fetch_addwhen the update is unconditional, and a mutex when the critical section is more than a few instructions.
Compare-and-Swap
Change an input and watch which number moves — and which one refuses to.
Try a CAS, then interfere and try again.
The whole compare-and-swap happens as one indivisible step, which is what makes it useful: nothing can slip between the compare and the write. Failure is normal, not exceptional — lock-free code is built from retry loops around it. And note what it does not give you: if the value changed to something else and back again, the compare still succeeds. That is the ABA problem.
Follow the mechanism
The path through the machine, hop by hop — and the conclusions it invites that are wrong.
- 1Thread → load: reads the current value to use as the expected operand.
- 2Thread → compute: derives the new value from what it observed, outside any atomic section.
- 3CAS → cache: the core takes the line exclusive and compares memory against the expected value indivisibly.
- 4CAS → outcome: on a match it writes and reports success; otherwise it reports failure and returns the value actually found.
- 5Failure → loop: the thread recomputes from the newly observed value and retries, paying another coherence transaction each time.
- • "CAS succeeded, so nothing changed" — it means the value matches, which is a weaker statement and is exactly the ABA hazard.
- • "Lock-free means faster" — it means the system makes progress without blocking. Under heavy contention a lock that parks waiters frequently outperforms a loop that keeps hammering a cache line.
- • "Lock-free means no thread can starve" — it means *some* thread progresses. Wait-freedom is the stronger property, and CAS loops do not provide it.
- • "ABA is a theoretical problem" — it is a routine cause of corruption in lock-free stacks and queues over reusable memory.
Consequences, controls and cost
- • Lock-free structures degrade under contention rather than blocking, so the symptom is falling throughput and high cache traffic rather than threads parked in a wait state.
- • Expensive computation inside a CAS loop is repeated on every retry, so contention multiplies the wasted work.
- • ABA on a pointer produces memory corruption that appears long after the CAS and is extremely hard to attribute.
- • A lock-free algorithm can starve an individual thread indefinitely while the system as a whole makes progress.
- • Use `fetch_add` or another dedicated atomic where the operation permits — it cannot fail and needs no loop.
- • Keep computation outside the CAS loop where possible, so a retry re-attempts the write rather than redoing the work.
- • Handle ABA explicitly whenever the CAS operand is a reusable pointer: version tags, hazard pointers or epoch-based reclamation.
- • Shard or partition contended state so fewer threads compete for the same line, which removes retries rather than optimising them.
- • Prefer a mutex unless measurement shows it is the bottleneck; an uncontended mutex is one atomic operation and parks its waiters when contended.
- • Instrument the retry count of the CAS loop; a mean well above one under load means contention is the dominant cost.
- • Scale a benchmark across core counts — a CAS-based structure that flattens or regresses as cores are added is retry-bound.
- • Compare a CAS loop against `fetch_add` for the same operation at several thread counts before choosing.
- • Sample coherence counters for the contended line to confirm the cost is ownership transfer rather than the computation.
- • Lock-free avoids blocking and priority inversion at the cost of retries, wasted recomputation and heavier coherence traffic under contention.
- • ABA mitigations cost memory and complexity: version tags need double-width CAS, hazard pointers and epochs need real reclamation infrastructure.
- • CAS gives conditional updates; where the update is unconditional you pay for that capability and receive nothing for it.
Scope
§224 — what these claims are specific to.
- ISA-SPECIFICCAS is
LOCK CMPXCHGon x86-64,CASon AArch64 with LSE, and anLR/SCretry pair otherwise. LL/SC can fail spuriously and is structurally immune to ABA; single-instruction CAS is neither. - PLATFORM-SPECIFICDouble-width CAS for version-tagged pointers is available on common 64-bit platforms but is not universal, so tag-based ABA mitigation is not portable by default.
- SIMPLIFIEDThe ABA walkthrough assumes an allocator that promptly reuses a freed address. Real allocator behaviour varies, which changes how often ABA is observed but never whether it is possible.
Misconceptions
fetch_add is one guaranteed transaction against a loop that may retry many times.Where the rest of this lives
This lesson covers the instruction and its hazards. The progress guarantees these algorithms provide, and how to prove a lock-free structure is linearizable, belong to concurrency.