Atomics & Lock-Free

The ABA Problem: The Value Came Back

Your CAS expected A, found A, and succeeded. In between, the value became B and then A again — and the world it pointed at is no longer the world you read. Value equality was never evidence that nothing happened.

▶ Run the lab

The question this answers

The question

The CAS succeeded and the value was exactly what I expected — how can the structure still be corrupt?

The work

A consumer popping from the lock-free stack while other consumers pop two nodes and push the first one back.

What is shared

The head pointer, and the node memory it points at — including memory that may be freed and reallocated.

The invariant — what must stay true under every interleaving

Every node is returned by at most one pop, and head always points at a node currently in the stack.

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?

A becomes B becomes A

The CAS contract is precise and narrower than it feels: it writes if the location *holds the expected value*. Programmers read it as "if nothing has changed since I looked", and those are different statements. Between your load and your CAS, the location can change any number of times and end up back where it started, and the CAS cannot tell.

For a counter that is harmless — 5 becoming 6 becoming 5 leaves a perfectly usable 5. For a pointer it is not, because the pointer is not the state; it is a *name* for state that lives elsewhere. The same address can be a different node, a freed node, or the same node in a different position in the list. The schedule below is the canonical instance, on the pop from A Lock-Free Stack, and What the Teaching Version Omits.

The step to watch is the last one. The CAS succeeds, no error is raised, and the stack now has its head pointing at a node that was already popped and handed to a caller. The next pop returns that node a second time — the same work item processed twice, which downstream looks like a duplicate charge, a duplicate email, a duplicate row.

Treiber-stack pop, interrupted by two pops and a push from another consumer.SIMULATED
Invariant · Each node is returned by at most one pop; head points at a node in the stack
#Consumer 1Consumer 2State
1load head -> A; read A.next -> B·head=A stack=A -> B -> C C1.expected=A C1.next=B
2·pop A (CAS head A -> B) success; returns A to its callerhead=B stack=B -> C
3·pop B (CAS head B -> C) success; returns B to its callerhead=C stack=C
4·push A back (its work was rejected and requeued)head=A stack=A -> C A.next=C
5resumes; CAS(head, expected=A, desired=B) -> SUCCESS·head=B stack=B -> ???
✕ head now points at B, which C2 already popped and handed to a caller. Node C is unreachable. If B was freed, head points at freed memory.
6returns A to its caller·note=A is now held by two callers
✕ A was returned by C2's pop and again by C1's pop. The same work item will be processed twice.
Every CAS in this trace succeeded and every operation reported success. The corruption is structural and silent, and it surfaces later as a duplicate side effect or a crash in a completely unrelated place.

Why a pointer is a name, not a state

The generalisation worth carrying out of this lesson: CAS compares a value, and you are usually reasoning about the state that value refers to. Whenever the value is an index, a pointer, a slot number, a generation-less id or a recycled handle, equality of the value is not equality of the state. The gap between them is exactly the ABA problem, and it is not confined to lock-free data structures.

The same shape appears far above this layer. A worker checks that job 42 is still in state PENDING and claims it — but job 42 was completed, retried, and re-created as PENDING by a supervisor in between, so the claim succeeds against a different logical job. A cache entry is validated by key presence when the entry was evicted and repopulated. An optimistic update checks a value rather than a version. See Optimistic Concurrency Control.

Memory reuse is what makes the low-level version so easy to hit. Allocators are good at handing back the address they just freed, so a popped-and-freed node very often reappears at the same address moments later. An address is one of the least stable identities available, which is why the fixes below all amount to attaching an identity that does not get recycled.

C1 loaded a pointer. Between the load and the CAS, the structure it named was replaced.
C1 reads nextC1 descheduled herevalue equal, meaning differentexpected == foundC1's stale next is appliedAt C1's loadhead -> AAfter C2's pop, pop, pushA.next = Bhead -> A (same address)B.next = CA.next = C (changed)C1: CAS(head, A -> B) succeedsB: popped, held by a caller, possibly freedhead -> a node that left the stack
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Categories of fix, and what each one costs

There is no way to make CAS compare the world instead of the value, so every fix either makes the value carry more identity or prevents the reuse that creates the ambiguity. Those are the two families, and knowing they exist is more useful than being able to implement either from memory.

Adding identity means version-tagging: store a counter alongside the pointer and increment it on every modification, so a pointer that leaves and returns comes back with a different tag and the CAS fails. This needs an atomic wide enough to hold both — double-width CAS where available, or pointer bits stolen from an aligned address, which caps the number of versions and reintroduces the problem after wraparound. It is a real fix with real limits.

Preventing reuse means a reclamation scheme: hazard pointers, where each thread publishes what it is currently reading and reclamation skips those; epoch-based or quiescent-state reclamation, where a node is freed only once no thread could still hold a reference from a prior epoch; or RCU. A tracing garbage collector gives you the same effect for free, which is why this problem is much smaller in Java, C# and JavaScript — but note carefully that a GC removes the *use-after-free*, not ABA itself. If your algorithm can legitimately re-push the same live object, as C2 does above, the tag is still required.

ApproachWhat it changesWhat it costsWhere it still fails
Version-tagged pointerCAS compares pointer + counter, so a returning pointer has a different tagNeeds double-width CAS or stolen alignment bits; the wider CAS is slowerCounter wraparound recreates ABA, just far more rarely
Hazard pointersEach thread publishes what it is reading; reclamation skips published nodesA store and a fence on every read; per-thread published slots; a scan before freeingBounded memory but a slower read path; complex to implement correctly
Epoch / quiescent-state reclamationNodes are freed only once every thread has passed through a quiescent pointCheaper per operation than hazard pointersA thread stalled inside a critical region halts reclamation — unbounded memory growth
RCUReaders are free; writers publish new versions and defer reclamationWriter-side complexity; requires a well-defined quiescent stateRead-mostly workloads only; write-heavy use defeats it
Tracing garbage collectorA referenced node is never reclaimed, so the address cannot be recycled underneath youGC pauses and memory overhead; not available in C++ or RustDoes NOT prevent ABA on a live object that is legitimately removed and re-added
Do not hand-write the structureUse a reviewed library, or a mutexA lock, and the progress guarantee you may not have neededNothing — this is the right answer for most code
The two families. Neither is free, and the last row is the one most teams should choose.

Key points

  • CAS compares a value. It does not and cannot compare the state that value refers to.
  • A pointer, index or handle is a name, not a state — and allocators eagerly reuse the address they just freed.
  • The consequence in a lock-free stack is a node returned by two pops: the same work item processed twice, with no error anywhere.
  • Fixes fall into two families: attach identity that does not recycle (version tags), or prevent reuse (hazard pointers, epochs, RCU, a GC).
  • A garbage collector removes the use-after-free half but not ABA itself — a live object legitimately removed and re-added still defeats value equality.

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
  • A thread loads the shared value A and reads state reachable from it, then stalls for any reason at all.
  • Other threads change the value to B, doing whatever restructuring that implies, and then change it back to A — usually because A was freed and reallocated, or because A was legitimately re-inserted.
  • The first thread resumes and performs its CAS with expected = A. The comparison succeeds because the bit pattern matches.
  • The write commits a decision computed from state that no longer exists — a stale next pointer, a stale index, a stale assumption about what the slot contains.
  • Nothing reports an error, because from CAS's point of view nothing went wrong.
Interleavings that matter
  • C1 loads head=A, next=B, stalls; C2 pops A, pops B, pushes A; C1 CAS(A -> B) succeeds; head points at the already-popped B and node C is lost.
  • Same trace with a version tag: C1 loads (A, tag=7); C2's three operations leave (A, tag=10); C1 CAS expecting (A, 7) fails and retries against the real state. Invariant holds.
  • Same trace under a tracing GC: B is not freed while C1 holds a reference, so there is no use-after-free — but head still points at a node C2 already returned, so the duplicate-pop bug survives.
  • Counter version, no pointers: v goes 5 -> 6 -> 5 and a CAS expecting 5 succeeds. Harmless, because the value *is* the state. This contrast is the whole lesson.
  • Job queue version: worker loads job 42 status PENDING; supervisor completes it and re-creates job 42 as PENDING; worker CAS-claims a logically different job. Same bug, no atomics involved.
What it guarantees — and does not
  • Promises: the CAS compared the value you gave it against the value present, indivisibly, and wrote only on a match.
  • Does NOT promise: that the value was unchanged throughout the interval — only that it matched at the instant of the compare.
  • Does NOT promise: that anything reachable through the value is unchanged. This is the entire problem.
  • Does NOT promise: that a pointer identifies the same logical object across a free and a reallocation.
  • Version tags promise: a returning value is distinguishable — up to counter wraparound, which is a real bound and not a theoretical one on narrow tags.
  • Hazard pointers and epochs promise: no reclamation of a node a thread may still be reading. They do NOT promise freedom from ABA on objects that are legitimately re-inserted.
Where contention appears
  • Version tags increase the width of the contended word, and a double-width CAS is more expensive per attempt than a single-width one.
  • Hazard pointers add a store and a fence to every read, which turns a previously read-only path into one that generates coherence traffic. See What a Shared Write Costs.
  • Epoch schemes concentrate contention on the epoch counter itself, which becomes a second hot location.
  • Every defence makes the operation more expensive, which is another way of saying the progress guarantee has a price you keep paying after you have bought it.
How it fails
  • Duplicate processing — the same item returned by two pops, producing duplicate side effects downstream unless every consumer is idempotent.
  • Lost items — a node made unreachable by a CAS that applied a stale link, leaking silently.
  • Use-after-free — dereferencing a node reclaimed between the load and the use; usually crashes somewhere unrelated.
  • Cycle creation — a stale link applied to a restructured list producing a loop, so a subsequent traversal never terminates.
  • Silent corruption with no error path at all, which is why this is diagnosed from downstream symptoms rather than from the structure.
  • Wraparound recurrence — a 16-bit tag on a hot structure wraps in seconds under load, restoring the bug with a rarer trigger.
When it helps
  • As a review question with a very high hit rate: "what happens if this value goes away and comes back?" applied to any CAS, any optimistic claim, any check-then-act on an id.
  • As the reason to prefer a monotonically increasing version over a value comparison everywhere — in memory, in a database row, in an HTTP If-Match. See Conditional Requests: ETags, 304 and 412.
  • As the argument that settles whether to hand-write a lock-free structure: if the team cannot state the reclamation scheme, the answer is no.
When it hurts
  • When the fear of ABA is applied to a plain counter, where the value is the state and 5 -> 6 -> 5 is genuinely fine.
  • When it motivates tag bits stolen from pointers in code that will be ported to an architecture with different alignment or pointer-authentication guarantees.
  • When it motivates a hand-rolled hazard-pointer scheme instead of a library or a mutex, which trades a rare bug for a permanent maintenance burden.
How you would know
  • Count downstream duplicates against upstream enqueues. A small, load-correlated excess of processed items over produced items is the signature.
  • Run under AddressSanitizer: the use-after-free half is exactly what it detects, and it will fire close to the cause rather than at the eventual crash.
  • Add a per-node generation counter used only for assertions in debug builds — pop asserts the generation matches what it expected, and the assertion fires on the ABA path.
  • Stress with an allocator configured to reuse addresses aggressively, plus deliberate delays between the load and the CAS, since the bug needs both reuse and a wide window. See Stress Testing: A Test That Passed Once Proves Nothing.
  • Do not expect a thread sanitizer to find it. Every access here is a correctly synchronized atomic; there is no data race to report. See Race Detectors: What They Find, and What They Structurally Cannot.
Complexity it introduces
  • Choosing a defence means owning a reclamation scheme with its own tuning, its own failure mode, and its own worst case.
  • A version tag makes the atomic word's layout part of the structure's contract, and it caps how many modifications can occur before the guarantee weakens.
  • The bug is undetectable by the tools teams already run, so preventing it depends entirely on someone knowing to look — which makes it a knowledge dependency rather than a tooling one.
  • Every defence slows the common path in exchange for a rare correctness case, which is a trade that needs to be stated explicitly rather than discovered in a profile.
Simpler alternatives
  • A mutex. There is no ABA problem when the structure cannot be modified while you hold it. See Mutexes: What They Protect and What They Do Not.
  • A monotonically increasing version or sequence number in place of value comparison, everywhere the identity can be recycled. See Optimistic Concurrency Control.
  • A reviewed library implementation whose reclamation scheme is already correct and already tested.
  • A bounded ring over preallocated slots with monotonically increasing indices — indices that never wrap within the structure's lifetime cannot come back. See Bounded vs Unbounded Queues.
  • A garbage-collected language for this component, if the structure genuinely needs to be lock-free and the reclamation problem is the obstacle.

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.

The lost update, step by step

The lost update, step by step
One fixed schedule of two concurrent increments. Nothing to choose — watch where the invariant dies, and where the cause actually was.
1/6 · A · rA ← counter
Invariant · after k completed increments, counter === k. No update is lost.
#Task A — counter++Task B — counter++State
1rA ← counter·counter=0 rA=0 rB=—
2·rB ← countercounter=0 rA=0 rB=0
3rA ← rA + 1·counter=0 rA=1 rB=0
4counter ← rA·counter=1 rA=1 rB=0
5·rB ← rB + 1counter=1 rA=1 rB=1
6·counter ← rBcounter=1 rA=1 rB=1
✕ 2 increments completed, counter = 1
step
1 of 6
counter
0
increments completed
0
invariant
holds
A reads 0. Correct at this instant, and about to stop being correct. A read-modify-write is a window, not an instant. It stays open from the read to the write.
SIMPLIFIEDOne of twenty possible interleavings of this program, chosen because it fails.

What people believe, and what is true

Claim

The CAS succeeded, so nothing changed while I was away.

Reality

It means the value matched at that instant. The value can have changed arbitrarily and returned, and everything it points at can be different.

Claim

ABA only matters in lock-free data structures.

Reality

Any check-then-act on a recyclable identity has the same shape — job ids, cache keys, slot numbers, file descriptors. The lock-free stack is just where it is easiest to demonstrate.

Claim

We use a garbage-collected language, so ABA cannot happen.

Reality

The GC prevents the address from being recycled while you hold a reference, which removes the use-after-free. It does nothing about a live object being removed and re-added, which is still ABA.

Go deeper

Overview

The value you compared went away and came back. CAS saw a match and wrote, but the thing the value referred to is not what you read.

Practical

Ask of every CAS: can this value be recycled? For a counter, no — the value is the state. For a pointer, index or id, yes, and you need a version, a reclamation scheme, or a lock.

Advanced

Hazard pointers give bounded memory and a slower read path; epoch reclamation gives a fast read path and unbounded memory if any thread stalls in a critical region. That is the real trade, and it is the same latency-versus-memory decision that appears in A Lock-Free Stack, and What the Teaching Version Omits.

Internals

The problem is named for the IBM 370 compare-and-swap literature of the 1970s, where it was documented alongside the instruction itself. Tagged pointers were the original answer and remain the standard one where double-width CAS exists; hazard pointers (Michael, 2004) were the answer for platforms where it does not.

Apply it