Lock-Free and Wait-Free Data Structures

A lock has a dark secret: if the thread holding it is pre-empted, crashes, or is simply slow, every other thread that wants the lock stops dead. One stalled thread freezes the whole structure. For a kernel data path or a real-time system, that is unacceptable. Lock-free programming throws the lock away entirely and coordinates with nothing but atomic read-modify-write instructions — usually compare-and-swap (CAS) — so that the failure or delay of any one thread can never block the others.

This is some of the hardest code humans write. It lives in kernels, garbage collectors, database engines, and lock-free queues between threads. To reason about it you need a precise vocabulary of progress guarantees, two canonical algorithms (the Treiber stack and the Michael–Scott queue), and a healthy fear of the ABA problem and of memory ordering. Get any of them wrong and the bug appears once a week, on one machine, under load.

Three progress guarantees, nested

"Non-blocking" is not one property but a hierarchy of increasingly strong promises about which threads are guaranteed to make progress, and when. They nest: every wait-free algorithm is lock-free, and every lock-free algorithm is obstruction-free — but not the reverse.

The distinction is about the system versus the individual. Lock-free guarantees the machine keeps moving; wait-free guarantees you personally keep moving. Most practical structures aim for lock-free — it captures the crucial "one stalled thread can't freeze everyone" property at a reasonable cost — and reserve wait-free for hard real-time paths where a per-operation bound is mandatory.

The Treiber stack: a lock-free push and pop

The classic lock-free structure is the Treiber stack (1986). A stack is a single \texttt{head} pointer to a chain of nodes. The whole trick is the optimistic CAS loop: read the current head, prepare your change, and CAS the head from the value-you-read to your new value. If someone else changed the head in between, the CAS fails, you re-read, and retry. To push:

  1. read h = \texttt{head};
  2. set your new node's \texttt{next} = h;
  3. \texttt{CAS(head, } h\texttt{, newNode)} — succeed and you're done; fail and loop from step 1.

Pop is symmetric: read h, read h.\texttt{next}, then \texttt{CAS(head, } h, h.\texttt{next)}. No lock is ever held; if your CAS fails it's only because someone else made progress, which is exactly the lock-free promise. It is not wait-free — a very unlucky thread could lose the CAS race indefinitely — but the stack as a whole never stalls.

Watch a CAS race play out

Let's run a Treiber stack against interference. The simulation pushes and pops, and deliberately injects a concurrent modification between a pop's read and its CAS so you can see the CAS fail and retry — the heartbeat of every lock-free algorithm. Nothing blocks; the loser simply loops and tries again.

// A lock-free (Treiber) stack. The only synchronisation is compareAndSwap on `head`. interface Node { value: number; next: Node | null; } class AtomicRef { private ref: Node | null = null; load(): Node | null { return this.ref; } // Atomic CAS: if head === expected, set to next and report success. cas(expected: Node | null, next: Node | null): boolean { if (this.ref === expected) { this.ref = next; return true; } return false; } } const head = new AtomicRef(); let casAttempts = 0, casFailures = 0; function push(value: number): void { const node: Node = { value, next: null }; for (;;) { const h = head.load(); // 1. read current head node.next = h; // 2. point new node at it casAttempts++; if (head.cas(h, node)) return; // 3. swing head -> success casFailures++; // someone beat us: retry } } function pop(interfere?: () => void): number | null { for (;;) { const h = head.load(); if (h === null) return null; const next = h.next; if (interfere) interfere(); // simulate another thread acting between read and CAS casAttempts++; if (head.cas(h, next)) return h.value; casFailures++; // head moved under us: retry } } push(10); push(20); push(30); console.log("pushed 10, 20, 30"); // Pop, but between our read and our CAS another thread pushes 99 -> our CAS fails once, then retries. let once = true; const v = pop(() => { if (once) { once = false; push(99); } }); console.log(`pop returned ${v} (retried after interference)`); console.log(`remaining top = ${head.load()?.value}`); console.log(`CAS attempts=${casAttempts}, failures(retries)=${casFailures}`);

The Michael–Scott queue

A stack touches one pointer; a FIFO queue touches two (head and tail) and is far subtler. The Michael–Scott queue (1996) is the lock-free queue — it is what Java's \texttt{ConcurrentLinkedQueue} and countless kernels use. Its cleverness is a dummy head node (so head and tail never alias even when empty) and a two-step enqueue: first CAS the last node's \texttt{next} to point at the newcomer, then CAS the tail forward. Because those two steps aren't atomic together, a thread may find the tail "lagging" — pointing at a node that already has a successor — and any thread that notices this helps by swinging the tail forward before proceeding. This helping is the signature of lock-free design: instead of waiting for a stalled thread, you finish its job for it, so the structure never blocks.

StructurePointersGuaranteeUsed in
Treiber stack1 (head)lock-freefree-lists, memory allocators
Michael–Scott queue2 (head, tail)lock-free (with helping)Java ConcurrentLinkedQueue
Wait-free queue (Kogan–Petrank)2 + per-thread statewait-freehard real-time paths

The ABA problem: when "unchanged" is a lie

Here is the trap that has bitten every lock-free programmer. CAS checks a value, not a history. Your pop reads \texttt{head} = A and plans to CAS it to A.\texttt{next} = B. Before your CAS runs, another thread pops A, pops B, frees them, then pushes a recycled node that reuses A's address back on top. Your CAS sees \texttt{head} == A, concludes "nothing changed," and succeeds — installing a stale B that has since been freed. The structure is now corrupt. The value went A \to B \to A, and CAS could not tell.

// The ABA problem: CAS on a bare value cannot see A -> B -> A. // A version-tagged pointer fixes it: the counter changes even when the pointer returns to A. interface Tagged { ptr: string; tag: number; } class TaggedRef { private cur: Tagged; constructor(ptr: string) { this.cur = { ptr, tag: 0 }; } load(): Tagged { return { ...this.cur }; } cas(exp: Tagged, nextPtr: string): boolean { if (this.cur.ptr === exp.ptr && this.cur.tag === exp.tag) { this.cur = { ptr: nextPtr, tag: exp.tag + 1 }; // bump the tag on every change return true; } return false; } } // --- Naive CAS on the bare pointer: ABA slips through --- let head = "A"; // bare pointer const myView = head; // reader observes A, plans CAS(A -> B) head = "B"; head = "A"; // another thread: A -> B -> A (recycled address!) const naiveSucceeds = (head === myView); console.log(`naive CAS sees head==A -> succeeds? ${naiveSucceeds} <-- WRONG, world changed`); // --- Tagged pointer: the same A->B->A now carries a different tag --- const ref = new TaggedRef("A"); const view = ref.load(); // {ptr:'A', tag:0} ref.cas(ref.load(), "B"); // A -> B, tag 1 ref.cas(ref.load(), "A"); // B -> A, tag 2 (pointer back to A, tag moved!) const taggedSucceeds = ref.cas(view, "Z"); // expects {A,0} but current is {A,2} console.log(`tagged CAS with stale {A,0} -> succeeds? ${taggedSucceeds} <-- correctly REJECTED`);

Two standard fixes. Tagged (versioned) pointers: glue a counter to the pointer and bump it on every change, so a recycled A carries a different tag and the CAS fails (this needs a double-width CAS, e.g. x86's \texttt{CMPXCHG16B}). Hazard pointers: each thread publishes the pointers it is currently using, and memory is not reclaimed until no hazard pointer references it — so A can never be freed and reused underneath you in the first place. Linux's RCU is a third, deferred-reclamation answer to the same underlying question: when is it safe to free?

Why memory ordering is not optional here

Lock-free code lives or dies by the memory consistency model. When you push a node, you write its \texttt{value} and \texttt{next} fields and then publish it by CAS-ing the head. On a weakly-ordered CPU (ARM, POWER), another core can see the new head pointer before it sees the node's field writes — reading uninitialised garbage. A lock hid this from you, because acquiring and releasing a lock imply full memory barriers. Strip the lock away and you must place the fences yourself: a release on the publishing CAS (so the field writes are visible before the pointer) paired with an acquire on the reader's load. "Lock-free" never means "fence-free" — it means you are now responsible for the ordering the lock used to guarantee.

The word "lock-free" sells the wrong dream. Its guarantee is about progress — one stalled thread can't freeze the rest — not about speed. Under heavy contention a Treiber stack's CAS loop can thrash worse than a well-tuned lock, because every failed CAS still paid for a coherence transaction on the head's cache line (the same ping-pong that plagues spinlocks). Benchmarks routinely show a good MCS lock or a flat-combining design beating a naive lock-free structure at high core counts. Reach for lock-free when you genuinely need its fault-tolerance and progress properties — real-time deadlines, signal handlers, code that runs where a thread might be paused arbitrarily — not as a reflexive "locks are slow" optimisation. The correct question is never "lock or lock-free?" but "what progress property does this path actually require?"

A common conflation: "my structure is lock-free, so no thread can starve." False. Lock-free guarantees only that the system makes progress — some thread completes each round. A specific unlucky thread can lose the CAS race again and again, retrying forever while faster threads keep succeeding; that is starvation, and it is perfectly compatible with lock-freedom. Only wait-free rules it out, by bounding every thread's own step count. If you have a per-operation latency requirement — a packet that must be processed within a deadline no matter what — lock-free is not enough; you need wait-free (and will pay for it in complexity and average-case throughput). Know which one your problem demands before you write a line.