Why Concurrent Data Structures Matters
Two threads pop the same shared stack. Both read its top value 10 and successor 20. If both blindly write the successor into Head and return 10, one value has been removed twice. A concurrent structure must define which update wins, how a loser retries, and when an unlinked node may finally be destroyed.
Concurrent data structures implement shared operations such as push/pop and enqueue/dequeue while overlapping callers observe a valid abstract stack or queue. The challenge has three distinct parts: logical correctness, progress under contention, and memory lifetime. Compare-and-swap helps with the first two; it does not by itself make a pointer safe to dereference.
This lesson covers CAS, a Treiber-style lock-free stack, the Michael–Scott queue, ABA, and hazard pointers. It follows one paused pop through a competing removal, a failed CAS and a protected reclamation scan; then follows an enqueue that takes effect before its caller returns while a dequeue helps its lagging Tail.
The programs deliberately implement a sequentially consistent step simulator in Python, C++, and Java. All three use node IDs and explicitly scheduled actions, with identical output. They are runnable models of the protocols, not production lock-free containers: there are no actual threads, atomic library objects, hardware fences, or physical frees. Stack reclamation marks nodes dead and checks later dereferences; queue nodes are retained throughout. These choices let us explain interleavings without presenting a partially specified low-level memory-management implementation.
A concurrent stack or queue must give overlapping operations a valid sequential meaning while maintaining a separate progress and lifetime argument. CAS selects successful updates, retries and helping handle interference, and a reclamation protocol prevents removed nodes from disappearing while readers may still use them.
Core Concepts and Terminology
A stack is LIFO: pushing 20 then 10 gives pops 10 then 20. A queue is FIFO: enqueuing 7 then 9 gives dequeues 7 then 9. For concurrency, these rules apply to a suitable ordering of overlapping calls, with each call appearing once.
Linearizability requires a point between an operation's invocation and response at which it takes effect. If $\operatorname{inv}(o)$ and $\operatorname{resp}(o)$ are its event positions, a proposed point satisfies
$$\operatorname{inv}(o)\le\operatorname{LP}(o)\le\operatorname{resp}(o).$$
The resulting sequential history must obey the object's specification and preserve real-time precedence: a call that returns before another begins must come first. Overlapping calls can linearize in a different order from their return times. An enqueue may publish its node, pause, and return after another thread has already dequeued that item.
Compare-and-swap (CAS) atomically compares one location with an expected value and writes a desired value only if they match:
$$\operatorname{CAS}(x,e,d)=\begin{cases}\text{success and }x\gets d,&x=e,\\\text{failure and }x\text{ unchanged},&x\ne e.\end{cases}$$
Our modeled CAS is strong: it has no spurious failures and takes expected by value. Real APIs can update an expected argument on failure or provide weak CAS that may fail spuriously; translate retry logic using that API's exact contract. CAS changes one selected location, not a whole multi-step algorithm or every field of a node.
Lock-free is a system progress guarantee: under the algorithm's assumptions, an infinite execution in which threads keep taking steps cannot have all operations remain pending forever. Individual operations may starve. Wait-free additionally bounds an individual operation's own steps, independently of competitors' delays. A mutex can provide simple linearizable behavior but a stopped lock owner can block its contenders. Lock-free does not imply fairness, bounded latency, or that allocation and reclamation are automatically nonblocking.
Retire means a node is unlinked and queued for later reclamation. Reclaim means its lifetime may end or its storage may become reusable. A hazard slot publishes a reader's candidate node; the reader must re-read the shared source and validate it before dereferencing that candidate. Publishing after a node has already been reclaimed cannot retroactively make an earlier dereference safe. Our simulator can publish an already-dead ID tentatively, but validation then fails before any node fields are accessed.
ABA occurs when a compared value changes from A to something else and back to A, making a stale equality check succeed. Reuse of a node address is one cause; reinsertion of the same live node is another. A pointer/version pair can detect intervening changes only when compared atomically and when the tag has not wrapped. A version does not keep the pointed-to object alive. Proper hazard reclamation blocks reuse of a protected old node, but does not alone forbid all logical ABA patterns.
In the following timeline, event numbers describe SC transition order, not elapsed CPU time. The meaningful metrics here are correctness, retry work, operation intervals and progress; bulk-parallel work/span speedup formulas do not explain a contender's latency by themselves.
| Term | Example | What it establishes |
|---|---|---|
| CAS | Head expected 2, actual 1: fail without writing | Prevents committing a stale structural update |
| Linearization point | Queue next-link CAS at event 34 | Fixes an abstract enqueue's position in history |
| Retry | Reload Head after a failed pop CAS | Discards an obsolete candidate and successor |
| Helping | A dequeue advances a lagging Tail | Lets progress continue while the enqueuer is paused |
| Dummy node | Queue Head points to the predecessor of its first item | Separates queue structure from returned payloads |
| Hazard publication | $HP[0]=2$, then revalidate Head | Protects a validated candidate before dereference |
| Retirement | Node 2 removed from Head but still protected | Unlinking does not imply reclaimability |
| ABA | Address 1 is reused after another node lived there | Equal address bits need not identify the old logical node |
| Lock-free / wait-free | System progress versus bounded individual steps | Different progress contracts, not performance synonyms |
| Sequential consistency | One total modeled order preserving each thread's action order | A defined memory model for the simulator |
How Concurrent Data Structures Works, Step by Step
The model has a stack arena and a separate queue arena; the same numeric ID in them refers to different nodes. ID 0 is null. New pushes/enqueues always receive fresh increasing IDs, and removed nodes are never reinserted. Stack nodes can be marked dead after a hazard scan; queue nodes are never reclaimed. The separate address-reuse example illustrates ABA without dereferencing a dead node.
Treiber-style push. Allocate and initialize a private node. Load Head into a local expected value, set the private node's next link to that expected Head, then CAS Head from expected to the new node. A successful CAS is the push's linearization point. On failure, reload Head and rebuild the private next link before retrying. The private node is not reachable until its successful publication.
Hazard-protected pop. Load a Head candidate. If it is null, return EMPTY with that load as the linearization point. Otherwise publish the candidate in this thread's hazard slot, then re-read Head. If it differs, clear the slot and retry before dereferencing. If it matches, read the protected node's immutable next link and copy its value. Attempt CAS Head from the candidate to its saved successor. Success linearizes the pop; retire the removed node, clear protection, and return the copied value. Failure clears protection and restarts from a fresh Head. There is one hazard slot per thread here because the stack pop dereferences only its protected candidate; it never dereferences the saved successor in the same attempt.
In a real implementation, retirement, clearing and local completion are separate actions. The simulator combines their bookkeeping with a successful pop transition; its scans occur at explicit scheduled steps. The candidate remains protected through its dereferences, and no node is reclaimed before being retired. This model checks the stated safety protocol under SC, not every machine-level implementation detail. A scan is one model transition, so interleavings within a native multi-slot hazard scan are outside this simulator.
Reclamation scan. Snapshot the published hazard IDs. For each retired stack node, keep it if protected; otherwise mark it dead and record its ID as reclaimed. A stopped reader may delay reclaiming its protected node without preventing other pushes or pops. Clear hazards when the attempt finishes or restarts. A scan compares IDs and examines retirement metadata; it must not dereference a tentatively protected, possibly already-dead candidate. Physical arena metadata remains allocated so the simulator can detect an invalid dereference instead of executing one.
Michael–Scott enqueue. Start with one dummy node and Head=Tail=dummy. Prepare a fresh node with null next. Read Tail, read that node's next, and validate that Tail still matches the saved Tail. If its next is non-null, another enqueue has already linked a node but Tail lags: try to advance Tail to that successor and retry. If next is null, CAS that next link from null to the new node. The successful next-link CAS is the enqueue's linearization point. Finally try to advance Tail; even if that last CAS fails, the enqueue has already succeeded. Another thread may have helped or progressed further.
Michael–Scott dequeue. Read Head, Tail, and Head.next, then validate Head. If Head changed, retry. If saved Head equals saved Tail and saved next is null, the queue was empty at the validated next-pointer observation; return EMPTY. If Head equals Tail but next is non-null, Tail is behind: help it advance and retry. Otherwise copy the successor's value before CAS, then CAS Head to that successor. Success is the dequeue's linearization point and turns the successor into the new dummy; failure retries. The old dummy is not the value returned. Our queue retains all nodes, so these saved-node dereferences remain valid; adding queue reclamation requires its own validated protection protocol for every dereferenced pointer, not simply copying the stack's one-slot pattern.
A subtle empty case matters: a new enqueue can link after a dequeue reads null next but before its validation/return. If Head still validates, the empty dequeue may linearize at its earlier null-next read. The program records that earlier event in lp rather than falsely claiming the queue is empty at the later return event. A test explicitly exercises this interleaving.
The flowchart below describes the protected stack-pop loop. Queue helping and publication follow the procedures above. Every T command advances just one modeled transition; F repeatedly advances the selected operation while other threads stay paused.
flowchart TD
A["Load Head candidate"] --> B{"Candidate is null?"}
B -->|Yes| C["Return EMPTY; LP is this load"]
B -->|No| D["Publish candidate in hazard slot"]
D --> E{"Head still equals candidate?"}
E -->|No| F["Clear hazard and retry"]
F --> A
E -->|Yes| G["Read protected next link and copy value"]
G --> H["CAS Head from candidate to saved next"]
H --> I{"CAS succeeded?"}
I -->|No| F
I -->|Yes| J["Pop LP; retire node and clear hazard"]
J --> K["Return copied value; scan later"]
Worked Example with a Full Trace
The sample uses two logical threads, numbered 0 and 1. It begins by pushing 20 into stack node 1 and 10 into node 2. At event 8, the top-to-bottom stack is [10,20], Head=2, and both hazard slots are zero. The queue has its own dummy node 1 and is still empty.
Protected pop race, events 9–27. Thread 0 begins a pop, loads candidate 2 at event 10, publishes hazard 2 at 11, validates Head at 12, and reads next=1/value=10 at 13. It pauses immediately before its CAS. Thread 1 begins another pop at 14, protects the same node, and successfully CASes Head from 2 to 1 at 19. That pop returns 10, retires node 2, and clears thread 1's hazard. Thread 0 still holds hazard 2.
The scan at event 20 therefore releases nothing: node 2 is retired but remains alive. At 21, thread 0's CAS expecting Head 2 fails because actual Head is 1. It neither removes another node nor returns its stale cached value 10. It clears its hazard, reloads/protects node 1, reads value 20 and null successor, and successfully CASes Head to null at 26. It returns 20 and retires node 1. The scan at 27 releases model nodes 1 and 2; neither is now protected or reachable. An additional pop returns EMPTY at 29. The two overlapping pops have legal linearization order: thread 1 at 19, then thread 0 at 26.
Queue helping, events 30–47. Thread 0 begins enqueueing 7 in fresh queue node 2 at 30. At 34 it successfully links dummy.next from null to node 2. This is the enqueue LP, so logical Q is already [7] even though Tail still points to dummy 1. Thread 0 pauses before its final Tail update.
Thread 1 begins dequeue at 35. It reads Head=Tail=1 and next=2, so this is a lagging Tail, not an empty queue. It helps Tail from 1 to 2 at 40, retries the snapshot, reads value 7, and successfully CASes Head from 1 to 2 at 46. It returns 7 and node 2 becomes the new dummy. Thread 0 resumes at 47; its final Tail CAS expecting 1 fails because Tail is already 2. It still returns OK with LP=34. A dequeue returning before that enqueue caller returns is legal because their call intervals overlap and publication already occurred.
Ordinary queue operations, events 48–65. Enqueue 9 links fresh node 3 at 52, its LP, advances Tail at 53, and returns OK. A dequeue advances Head from 2 to 3 and returns 9 at 60. The final dequeue observes null next at event 64 and validates/returns EMPTY at 65, recording LP=64. Final Q is empty, with Head=Tail=3; node 3 is the dummy. Queue nodes 1–3 remain allocated in this model, including obsolete dummies.
There are 12 CAS attempts and 10 successes. The failures are thread 0's stale stack-pop CAS and the original enqueuer's already-helped Tail update. A failed CAS means no write by that attempt; it does not mean that the entire object stopped making progress. The full log below includes all 65 events, every intermediate Head/Tail/hazard/retirement state, and operation return/LP events.
Separate ABA address model. A paused thread saves Head address 1 and successor 2 from chain 1→2→3. Other operations remove 1, remove 2, and reuse address 1 for a fresh node pointing to 3. Address-only CAS expecting 1 succeeds and installs stale successor 2, which now denotes freed storage. If every Head change also increments an atomically compared stamp, the saved pair $(1,0)$ differs from current $(1,3)$ and the stale pair-CAS fails. No actual invalid pointer dereference occurs in this numeric demonstration.
Trace It by Hand
Pause at event 20. Node 2 is retired, Head is 1, and thread 0 still publishes hazard 2. Can the scanner reclaim node 2? What happens when thread 0 resumes its CAS?
Reveal: the scan keeps node 2 alive. Thread 0's CAS expects 2 but sees Head 1, so it fails, clears its hazard, and retries instead of returning cached value 10. Its later successful attempt returns 20. Clearing the hazard makes node 2 eligible for a later scan; the sample performs that scan at event 27.
Now pause the enqueuer after event 34. Predict whether dequeue sees an empty queue and whether the enqueuer's final Tail CAS must succeed. Reveal: next is already non-null, so dequeue helps Tail and can return 7; the final Tail CAS can fail while enqueue still returns OK because its LP was the earlier successful link.
Implementation in Python, C++, and Java
Every panel contains the full simulator: a fresh-node stack, protected-pop state machine, hazard scan, Michael–Scott enqueue/dequeue state machines, and a separate numeric ABA/version check. They use identical commands and print identical events. Python requires 3.9 or later; C++ uses C++17; Java uses Java 8 or later and should be saved as Main.java. Run python3 main.py; compile/run C++ with g++ -std=c++17 -O2 main.cpp -o concurrent-model then ./concurrent-model; compile/run Java with javac Main.java then java Main.
simulate(workers,commands) accepts 1–4 modeled threads and at most 256 commands. A command has kind, thread ID, and an integer value defaulting to zero. P starts a stack push, O starts a stack pop, E starts an enqueue, and D starts a dequeue. T advances one transition; F finishes the selected pending operation while others stay paused; R scans retired stack nodes. All command thread IDs must be valid and all value fields must lie in $[-100,100]$, including unused fields. Only push/enqueue use the value as payload.
Validation checks WORKERS, then script SIZE, then each command's COMMAND, THREAD, and VALUE fields before creating the machine. During execution, starting a new operation on a thread with a pending call raises BUSY; stepping/finishing a thread with no pending call raises IDLE. Scripts may intentionally end with pending operations for inspection. Python rejects booleans and other non-integer runtime types in numeric fields. The command containers and command records are left unchanged.
An Operation stores private expected pointers, stage pc, cached value, invocation/return events, and LP. Node ID 0 is null; stack and queue IDs belong to separate arenas. A retired stack node's alive marker becomes false at reclamation, but its metadata remains available for checking. The model's guarded stack dereference rejects dead IDs. Queue nodes are retained and their lifetime is not handled by the stack's hazard scanner. The independent ABA demo operates only on integers; it never constructs or dereferences an invalid native pointer.
Modeled pointer loads, hazard publication/validation, CAS actions, and small local bookkeeping are serialized into SC transitions. This is deliberately a model of atomic behavior, not the use of native atomics: replacing the single-thread scheduler with ordinary threads would introduce data races. Production C++ needs the appropriate atomic pointer/link/hazard fields, publication ordering and a proven reclamation protocol; Java reference lifetime, Python runtime behavior, and language memory models must be treated on their own terms. The code is useful for inspecting histories and correctness arguments, not claiming measured throughput or production lock-free behavior.
Verification: 2,113 scripts were checked per language, with 2,102 accepted and 11 rejected. Accepted scripts contain 17,382 modeled operations and 134,019 logged events, including intentionally pending prefixes. Bounded two-operation exploration visits 1,092 normalized structural/control states and 60 terminal states; timestamps are normalized for state deduplication, so this is not an enumeration of every timed history. Seeded multi-operation schedules use two, three, and four threads. Independent checks compare LP-ordered histories with sequential stack/deque specifications, enforce invocation/response bounds, and check reachability, acyclicity, retirement/hazard eligibility, safe protected dereferences, CAS counts, and unchanged commands. Across exploration/generation/replay, 125,916 invariant checks pass. Full formatted traces agree across languages and sample stdout matches byte for byte. Eight extra Python cases check invalid runtime types. C++ passes address/undefined-behavior sanitizers; Java compiles with all lint warnings enabled. These tests establish properties of this SC node-ID model, not weak-memory correctness or every unbounded execution.
Identical expected output (each item is one printed line; R/F are retired/reclaimed stack IDs, and H/T are queue Head/Tail):
001 begin P t=0 value=20 node=1 | S=[] head=0 hp=[0,0] R=[] F=[] Q=[] H=1 T=1002 t=0 P.read head=0 | S=[] head=0 hp=[0,0] R=[] F=[] Q=[] H=1 T=1003 t=0 P.private-next=0 | S=[] head=0 hp=[0,0] R=[] F=[] Q=[] H=1 T=1004 t=0 P.cas-head expect=0 set=1 ok=1 return=OK lp=4 | S=[20] head=1 hp=[0,0] R=[] F=[] Q=[] H=1 T=1005 begin P t=0 value=10 node=2 | S=[20] head=1 hp=[0,0] R=[] F=[] Q=[] H=1 T=1006 t=0 P.read head=1 | S=[20] head=1 hp=[0,0] R=[] F=[] Q=[] H=1 T=1007 t=0 P.private-next=1 | S=[20] head=1 hp=[0,0] R=[] F=[] Q=[] H=1 T=1008 t=0 P.cas-head expect=1 set=2 ok=1 return=OK lp=8 | S=[10,20] head=2 hp=[0,0] R=[] F=[] Q=[] H=1 T=1009 begin O t=0 value=0 node=0 | S=[10,20] head=2 hp=[0,0] R=[] F=[] Q=[] H=1 T=1010 t=0 O.read head=2 | S=[10,20] head=2 hp=[0,0] R=[] F=[] Q=[] H=1 T=1011 t=0 O.publish hp=2 | S=[10,20] head=2 hp=[2,0] R=[] F=[] Q=[] H=1 T=1012 t=0 O.validate ok=1 | S=[10,20] head=2 hp=[2,0] R=[] F=[] Q=[] H=1 T=1013 t=0 O.read-next=1 value=10 | S=[10,20] head=2 hp=[2,0] R=[] F=[] Q=[] H=1 T=1014 begin O t=1 value=0 node=0 | S=[10,20] head=2 hp=[2,0] R=[] F=[] Q=[] H=1 T=1015 t=1 O.read head=2 | S=[10,20] head=2 hp=[2,0] R=[] F=[] Q=[] H=1 T=1016 t=1 O.publish hp=2 | S=[10,20] head=2 hp=[2,2] R=[] F=[] Q=[] H=1 T=1017 t=1 O.validate ok=1 | S=[10,20] head=2 hp=[2,2] R=[] F=[] Q=[] H=1 T=1018 t=1 O.read-next=1 value=10 | S=[10,20] head=2 hp=[2,2] R=[] F=[] Q=[] H=1 T=1019 t=1 O.cas-head expect=2 set=1 ok=1 return=10 lp=19 | S=[20] head=1 hp=[2,0] R=[2] F=[] Q=[] H=1 T=1020 scan released=[] | S=[20] head=1 hp=[2,0] R=[2] F=[] Q=[] H=1 T=1021 t=0 O.cas-head expect=2 set=1 ok=0 | S=[20] head=1 hp=[0,0] R=[2] F=[] Q=[] H=1 T=1022 t=0 O.read head=1 | S=[20] head=1 hp=[0,0] R=[2] F=[] Q=[] H=1 T=1023 t=0 O.publish hp=1 | S=[20] head=1 hp=[1,0] R=[2] F=[] Q=[] H=1 T=1024 t=0 O.validate ok=1 | S=[20] head=1 hp=[1,0] R=[2] F=[] Q=[] H=1 T=1025 t=0 O.read-next=0 value=20 | S=[20] head=1 hp=[1,0] R=[2] F=[] Q=[] H=1 T=1026 t=0 O.cas-head expect=1 set=0 ok=1 return=20 lp=26 | S=[] head=0 hp=[0,0] R=[1,2] F=[] Q=[] H=1 T=1027 scan released=[1,2] | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=1 T=1028 begin O t=0 value=0 node=0 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=1 T=1029 t=0 O.read head=0 return=EMPTY lp=29 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=1 T=1030 begin E t=0 value=7 node=2 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=1 T=1031 t=0 E.read-tail=1 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=1 T=1032 t=0 E.read-next=0 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=1 T=1033 t=0 E.validate ok=1 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=1 T=1034 t=0 E.link-next expect=0 set=2 ok=1 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[7] H=1 T=1035 begin D t=1 value=0 node=0 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[7] H=1 T=1036 t=1 D.read-head=1 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[7] H=1 T=1037 t=1 D.read-tail=1 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[7] H=1 T=1038 t=1 D.read-next=2 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[7] H=1 T=1039 t=1 D.validate ok=1 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[7] H=1 T=1040 t=1 D.help-tail expect=1 set=2 ok=1 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[7] H=1 T=2041 t=1 D.read-head=1 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[7] H=1 T=2042 t=1 D.read-tail=2 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[7] H=1 T=2043 t=1 D.read-next=2 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[7] H=1 T=2044 t=1 D.validate ok=1 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[7] H=1 T=2045 t=1 D.read-value=7 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[7] H=1 T=2046 t=1 D.cas-head expect=1 set=2 ok=1 return=7 lp=46 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=2 T=2047 t=0 E.advance-tail expect=1 set=2 ok=0 return=OK lp=34 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=2 T=2048 begin E t=0 value=9 node=3 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=2 T=2049 t=0 E.read-tail=2 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=2 T=2050 t=0 E.read-next=0 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=2 T=2051 t=0 E.validate ok=1 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=2 T=2052 t=0 E.link-next expect=0 set=3 ok=1 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[9] H=2 T=2053 t=0 E.advance-tail expect=2 set=3 ok=1 return=OK lp=52 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[9] H=2 T=3054 begin D t=1 value=0 node=0 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[9] H=2 T=3055 t=1 D.read-head=2 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[9] H=2 T=3056 t=1 D.read-tail=3 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[9] H=2 T=3057 t=1 D.read-next=3 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[9] H=2 T=3058 t=1 D.validate ok=1 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[9] H=2 T=3059 t=1 D.read-value=9 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[9] H=2 T=3060 t=1 D.cas-head expect=2 set=3 ok=1 return=9 lp=60 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=3 T=3061 begin D t=0 value=0 node=0 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=3 T=3062 t=0 D.read-head=3 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=3 T=3063 t=0 D.read-tail=3 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=3 T=3064 t=0 D.read-next=0 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=3 T=3065 t=0 D.validate ok=1 return=EMPTY lp=64 | S=[] head=0 hp=[0,0] R=[] F=[1,2] Q=[] H=3 T=3CAS attempts=12 wins=10ABA raw current-next=3 CAS=1 new-head=2 dangling=1ABA tagged expected=(1,0) current=(1,3) CAS=0
Complexity, Correctness, and Invariants
Concurrency requires separate correctness, progress, cost, and lifetime arguments. A finite passing trace is evidence for the stated model; it does not replace a proof for all legal executions or a language's memory-order rules.
Stack linearizability. The reachable chain from Head represents the abstract top-to-bottom stack. A push publishes one initialized private node through a successful Head CAS, placing it ahead of the previous chain. A successful pop removes exactly the Head candidate whose identity still matches its expected value and returns that node's previously copied value. These CAS actions are the respective LPs. A failed pop CAS removes nothing and must retry; an empty pop linearizes at its observed null Head. Fresh IDs and no reinsertion rule out logical reuse in the modeled stack, while the hazard protocol handles its modeled node lifetime.
Queue linearizability. The abstract queue is the linked sequence after the Head dummy. A successful null-to-new-node next-link CAS appends one initialized value and is the enqueue LP. Tail is a hint that may lag the last node; helping Tail changes a hint, not queue membership. A successful Head CAS advances the dummy to its successor and removes exactly that successor's copied value from the abstract queue, making it the dequeue LP. An empty dequeue's validated null-next observation supplies a point at which the queue is empty; in this code that point can precede its final validation and response. Reading only Head==Tail is insufficient because a linked successor may already exist.
The representation stays acyclic, Head is a retained dummy on the queue chain, and Tail is reachable from Head and at most one link behind the physical last node in this version. Link fields change only from null to a fresh successor. Fresh IDs/no queue reclamation make saved queue references valid; the original memory-management assumptions must be revisited before porting these arguments to reusable pointer storage.
Hazard safety under SC. Suppose a pop has published hazard h, re-read Head equal to h, and is about to dereference it. If h is removed afterward, a scan while this protection is held sees the slot and keeps h. If removal/reclamation happened before publication, validation sees the changed Head and rejects h before dereference, because IDs never cycle back. A scan that occurs before publication while h is still reachable cannot reclaim it: only retired nodes are candidates. Therefore the modeled pop does not dereference a reclaimed node. Keeping a node alive does not force its CAS to succeed; structural freshness and lifetime are different checks. This is why the sample can safely fail its stale CAS after a competing pop.
Progress. Under the usual assumptions of nonblocking strong CAS, finite local steps, safe memory lifetime, and continued scheduling, a stale strong-CAS failure witnesses some intervening modification. Stack Head changes are successful structural operations. A queue next-link or Head change makes abstract progress; Tail helping supports already-linked publication and prevents a paused enqueuer from becoming an owner others must wait for. Persistent contention can prevent one caller from winning forever while others complete, so Treiber/MS core loops are lock-free rather than wait-free. Weak CAS spurious failure alone does not witness another thread's progress; a proof using that API must account for it. Blocking allocation, logging, a blocking reclaimer or a non-lock-free wide atomic can change the end-to-end progress contract.
Core operation cost. Let f be the number of failed/restarted attempts, including repeated snapshot validation failures, before a particular call completes. Each modeled attempt has a bounded number of pointer actions, so its structural step cost is $O(1+f)$, excluding allocation, scans, and logging. The uncontended successful/empty paths are $O(1)$. Under unbounded contention, a single operation's worst-case steps are unbounded; lock-freedom supplies no finite individual upper bound. There is no universal average-case bound without a scheduler/contention distribution. A queue method's final Tail CAS can fail after its enqueue LP without requiring the completed publication to be repeated.
Reclamation cost. With H published hazard slots and R retired candidates, collecting slots into a hash set and testing each candidate takes expected $O(H+R)$ time, subject to the set's assumptions. A deterministic ordered-set implementation instead gives $O((H+R)\log(H+1))$ as a convenient upper bound. Python/Java use hashed sets here; C++ uses an ordered set. These bounds cover eligibility testing; sorting released IDs and full-state logging add separate simulator costs. A scanner must also synchronize its observations correctly in a real memory model. This lesson scans explicitly, so it makes no automatic amortized scan-frequency claim. A protected node can remain unreclaimed indefinitely if its owner stops; that does not by itself block unrelated stack operations.
Space. A real linked structure retains live nodes plus retired nodes not yet safe to reclaim, hazard records, and any allocator metadata. H hazard slots cost $O(H)$; the total retired backlog is not automatically bounded by H. A particular hazard scheme can establish stronger bounds with its own thresholds and ownership assumptions. This simulator keeps metadata for every allocated node, including reclaimed stack markers and obsolete queue dummies. Its storage therefore grows with total pushes/enqueues, not merely current stack/queue length. Saved events also grow with the trace length.
Simulator overhead is intentional. Each logged event traverses the live stack/queue, checks cycles, sorts retired/freed IDs for display, and formats state. If V bounds allocated nodes and E is the event count, a coarse bound is $O(E(V\log(V+1)+H+1))$ word-level work for this verbose model, with additional text-size costs; saved text can likewise scale with E times displayed state size. These costs are unrelated to the core's uncontended constant-time pointer actions. The node arenas and plain fields are not production lock-free storage, and the deterministic scheduler does not measure throughput.
The proofs above also assume initialized payloads become visible when a node is published, link/head/tail accesses obey the chosen memory model, and only a validated live node is dereferenced. Logical linearizability without those lifetime/publication conditions is not enough for a valid native implementation.
| Design or component | Uncontended/core cost | Contended/progress behavior | Lifetime and space conditions |
|---|---|---|---|
| Mutex-protected stack/queue | $O(1)$ list work plus locking | Can block behind a paused owner; fairness depends on lock/scheduler | Ownership often simplifies reclaiming nodes under the lock |
| Treiber-style CAS stack | $O(1)$ pointer actions per attempt | $O(1+f)$ to finish after f restarts; no finite individual worst bound | Safe reclamation and no unsafe ABA/reinsertion assumptions |
| Michael–Scott core queue | $O(1)$ actions per attempt | Helping plus retries; lock-free core under stated assumptions | Dummy node and valid references to all dereferenced nodes |
| Hazard scan, hashed membership | Expected $O(H+R)$ | Reclaim only unprotected retired nodes | H slots; backlog/reclamation policy must be analyzed separately |
| Hazard scan, ordered membership | $O((H+R)\log(H+1))$ upper bound | Same eligibility rule; different set cost | C++ teaching model uses this option |
| Atomic pointer/version pair | One pair comparison/update if supported | Detects intervening changes until tag wrap | Must be atomic and meet desired progress guarantee; does not ensure lifetime |
| This deterministic SC simulator | Full-state logging adds nonconstant work | One host thread follows scheduled transitions | Retains all node metadata and queue nodes; no native concurrency claim |
Every completed operation must have a legal linearization point and preserve the abstract LIFO/FIFO order. Separately, every dereferenced node must still be alive: hazard publication must be followed by source revalidation, and retirement must be followed by a scan that excludes protected nodes.
Variants, Pitfalls, and Optimizations
CAS does not protect a stale dereference. Reading p->next before publishing/revalidating protection can access freed memory even if the later CAS fails. A second Head check after that dereference is too late. In our stack protocol, the hazard stays published through the node-field reads and the CAS attempt, and value copying finishes before protection is cleared. The modeled scanner may encounter a tentative hazard that names dead metadata, but it only compares the ID; the reader must fail validation before using fields.
ABA has two separate dimensions. A stale address may equal a different node's recycled address; a live node may also be removed and reinserted with a changed successor. The fresh-node/no-reinsertion model rules both out within its actual arenas, while the separate address demonstration makes the danger visible. Correct hazard reclamation prevents freeing/reusing a validated protected node, but cannot alone forbid arbitrary reinsertion of the same live object. Atomic stamped pointers can detect version changes, provided the entire pair is compared atomically and the stamp has not wrapped. After $2^b$ increments a b-bit stamp cycles, so its adequacy needs a bound or another argument. Stamps do not make dereferencing an already-freed node safe.
Publish initialized nodes with the required ordering. Payload and next initialization must become visible before a reader consumes the newly published node. Real head/tail/link/hazard locations need appropriate atomic accesses and an established memory-ordering protocol. A release store to a hazard slot and acquire loads in a scan are not, by themselves, a proof that the protect/validate/scan race matches this SC model. Use a verified algorithm/library and its required fences/orderings. The plain assignments here are valid because the host runs one modeled transition at a time; adding OS threads to this code is not a correct port.
Tail equality and failed Tail CAS are easy to misread. In MS enqueue, successful linking already commits the item. Requiring the final Tail CAS to succeed before reporting that publication would misunderstand helping. In dequeue, Head=Tail with non-null next means Tail lags; returning EMPTY there loses a queued item. Keep dummy movement distinct from value removal and never return the old dummy's payload. A queue reclaimer must protect every pointer it may dereference and validate the relevant snapshot; the stack's one-slot recipe is not an automatic MS-queue reclamation implementation.
Garbage collection changes lifetime, not all correctness obligations. Holding a managed reference can keep that object alive, but logical ABA through removal/reinsertion remains possible. Moving between Python, Java and C++ changes APIs, atomic contracts, object lifetime, and reclamation mechanisms; identical state-machine outputs do not erase those differences. A runtime lock or GIL also does not make an arbitrary multi-step abstract operation linearizable automatically.
Progress extends beyond a CAS loop. Dynamic allocation, destruction, reference counting, scans, logging, or fallback atomics may block or add unbounded work. Backoff can reduce contention but does not make an operation wait-free. Elimination techniques can let complementary stack pushes/pops meet away from Head, but require their own matching/linearization proof. Epoch-based reclamation, RCU-style techniques, bounded ring buffers, and queue locks offer different costs and lifetime/progress assumptions. A stalled participant can prevent epoch advancement; hazard pointers have a different retention pattern. Measure allocator behavior, cache-line contention, false sharing, fairness and tail latency under the actual workload.
Testing limits and useful tools. Explore adversarial interleavings, validate LP witnesses or search for a valid sequential history, and test lifetime separately from return values. A sanitizer can expose some memory bugs; passing it cannot prove atomic ordering or linearizability. An SC node-ID model cannot expose all weak-memory reorderings, platform-level lock-free guarantees, or real address reuse. Those require implementation-specific tests and proofs. Our test report deliberately names its bounded state normalization and retained-node assumptions so its scope is reviewable.
Do not confuse a matching address with the same logical node, an unlinked node with reclaimable storage, or lock-free system progress with bounded individual latency. Validate hazard-protected sources before dereference, distinguish queue linking from Tail maintenance, and preserve the real language's atomic and lifetime rules.
Practice Problems and Real-World Use
Begin with sequential stack and queue specifications. Record each operation's invocation, response and result. For a proposed LP ordering, verify that each point lies inside its call interval and replay that ordering against the specification. Include effects of an enqueue that has linked but has not yet returned when checking a partial history. Real-time precedence is preserved automatically by a valid in-interval witness, but the witness must still reproduce every result and final abstract state.
Construct schedules that pause a stack pop before hazard publication, after validation, and immediately before CAS. Interleave removal and scans at each location. A scan before publication may reclaim a removed candidate, so the later validation must reject it without dereferencing. A scan while a validated hazard is held must retain it. After protection clears, a later scan may reclaim it. Deliberately schedule two successful pops competing for one top candidate and verify the loser retries rather than returns a duplicate value.
For MS queues, pause an enqueuer immediately after successful linking. A second enqueuer or dequeue should be able to help Tail. Try two competing dequeues, empty queues, repeated payload values, and enqueue-after-null-next histories; distinguish identity from payload equality. Extend the model with actual address reuse only together with an explicit identity/lifetime policy, then test raw, stamped and protected variants against that policy. Never turn an illustrative unsafe dereference into executable native test code.
The exercises below move from state-machine reasoning toward real implementations. A practical next step is a small linearizability checker for recorded histories, followed by a memory-model-aware implementation built on a proven reclamation scheme. Benchmark a well-engineered locked baseline as well as the nonblocking version. Report correctness assumptions, progress conditions, allocator/reclaimer behavior, thread counts, contention, throughput and latency distributions; “uses CAS” is not an adequate performance result.
Primary reading: Michael and Scott's corrected queue pseudocode documents the link/help/dummy algorithm and its memory-management assumptions. Maged Michael's hazard-pointer paper develops safe reclamation for lock-free objects; IBM's publication record identifies the original work. The node-ID lifetime guards, exact event numbering and finite script tests above belong to this lesson's SC teaching model.
| Exercise or application | What to implement/check | Difficulty |
|---|---|---|
| Two-pop Treiber history | One winning CAS, stale loser retry, correct LIFO returns | Intermediate |
| Protect–validate lifetime schedules | Scans before publication, during protection, and after clearing | Advanced |
| Delayed-tail MS queue | Help a paused enqueuer and identify the actual publication LP | Advanced |
| Empty dequeue overlapping enqueue | Place the empty LP at a valid earlier observation | Advanced |
| ABA address/version model | Atomic identity comparison, wrap assumptions, lifetime separation | Advanced |
| Small history linearizability checker | Respect invocation/response intervals and replay a sequential spec | Advanced |
| Native bounded producer/consumer queue study | Proven atomic/reclamation protocol, allocator behavior and contention | Advanced |
| Locked versus lock-free workload benchmark | Equivalent semantics, throughput, fairness and tail latency | Advanced |