Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 31

Understanding the ABA Problem in Lock-Free Algorithms

Recommended resources

Hello! Welcome back.

In our last few lessons, we've built a solid foundation in low-level concurrency. You learned how to use std::atomic, implement a spin-lock with compare_exchange, and, most recently, you developed a framework for choosing the correct memory orderings to balance safety and performance. The common thread in many lock-free algorithms is the "compare-and-swap" (CAS) loop: read a value, compute a new one, and then atomically swap it in if the original value hasn't changed.

Today, we'll explore a notorious and subtle bug that arises from this very pattern: the ABA problem. This lesson will explain what the ABA problem is, why it happens, and how to solve it. Understanding this is a rite of passage in lock-free programming. While our immediate goal, a single-producer, single-consumer (SPSC) queue, is cleverly designed to avoid it, grasping the ABA problem is essential for writing more complex concurrent structures and for truly mastering low-level systems programming.

The Illusion of "Unchanged"

The core of the compare-and-swap logic is checking if a memory location still holds an expected value. But what if the value changes and then changes back?

Let's say a thread reads value A from a shared memory location. It goes off to do some work, and when it comes back, it checks if the value is still A before swapping in a new value. The CAS operation will succeed. The problem is, another thread could have come in, changed the value to B, performed other operations that changed the state of the entire data structure, and then changed the value back to A.

The first thread's CAS succeeds because the value is the same, but its assumption that the state of the program is the same is now dangerously false. This is the ABA problem.


Visualizing the Problem: A Lock-Free Stack

The canonical example of the ABA problem is a lock-free stack implemented as a singly linked list. The pop operation might look something like this in pseudo-code:

  1. Read the current head pointer. Let's say it points to node A.
  2. Read the next pointer of node A. Let's say it points to node B.
  3. Atomically try to swap the head from A to B using compare_exchange.

Now, consider this interleaving between two threads:

ABA Problem Illustration with Interleaved Threads
This diagram shows a timeline of the ABA problem on a lock-free stack. T1 begins a `pop`, but is preempted. T2 then pops A, pops B, and pushes A back on. When T1 resumes, its CAS succeeds, but it corrupts the stack by setting the head to B, which is no longer in the list.

Let's break down the scenario shown in the image:

  1. Initial State: The stack is A -> B -> C. head points to A.
  2. Thread 1 starts pop(): It reads head (which is A) and head->next (which is B). It is now ready to execute compare_exchange(head, A, B).
  3. Context Switch: Thread 1 is preempted by the OS.
  4. Thread 2 runs:
    • It pops A. The stack is now B -> C.
    • It pops B. The stack is now just C.
    • It does some work, and then pushes A back onto the stack. The stack is now A -> C. Crucially, the head pointer once again holds the memory address of A.
  5. Context Switch: Thread 1 resumes execution.
  6. Thread 1's CAS succeeds: It executes compare_exchange(head, A, B). Since the current head pointer's value is indeed A, the check passes! The head is updated to point to B.
  7. Corruption: The stack's head now points to B, which is a "dead" node that is no longer part of the stack. Node C is now orphaned and lost, resulting in a memory leak and a corrupted data structure.

This happens because the compare_exchange operation only sees the pointer's raw value (the memory address), not the history of what has happened to the data structure.

Solving the ABA Problem for Lock-Free Free Lists

The article 'Solving the ABA Problem for Lock-Free Free Lists' on the moodycamel blog provides an excellent, detailed walkthrough of this exact scenario.

Read the first two sections, up to and including the paragraph that ends with '...causing a CAS to succeed when it shouldn't.' The explanation using 'Sammy' and 'Tommy' as nodes makes the abstract concept very concrete.


Solutions to the ABA Problem

Since the problem stems from a reverted value masking a change in state, the solutions focus on ensuring that a reverted value is detectable.

1. Tagged Pointers (or Versioning)

This is the most common software solution. Instead of just storing a pointer, you store a struct containing the pointer and a counter (also called a "tag" or "version").

struct TaggedPointer {
    Node* ptr;
    uintptr_t tag;
};

The process is modified as follows:

  • Every time you modify the shared pointer (e.g., in push or pop), you also increment the tag.
  • The compare_exchange operation now operates on the entire TaggedPointer struct.

In our ABA scenario, when Thread 2 pushes A back onto the stack, it would increment the tag.

  • Thread 1 expected {ptr: A, tag: n}.
  • After Thread 2's operations, the head is {ptr: A, tag: n+1}.

Now, when Thread 1 resumes, its compare_exchange on {ptr: A, tag: n} will fail, because the tag is different. The ABA problem is averted.

This requires the hardware to support a CAS operation that is wide enough to cover both the pointer and the tag atomically. This is often called a Double-Word Compare-And-Swap (DCAS) or, on 64-bit systems, a 128-bit CAS. Modern x86-64 CPUs support this with the CMPXCHG16B instruction, which std::atomic can use if is_lock_free() returns true for a 16-byte struct. If not, you may need to pack the tag and pointer into a single 64-bit word by stealing some high bits from the pointer, which limits your address space.

Solving the ABA Problem for Lock-Free Free Lists

Let's continue with the moodycamel article, which discusses various solutions, including tagged pointers.

Read the sections 'Oh no, what to do?!' and the 'Another update' section at the end. These parts discuss the tagged pointer approach and its dependency on DCAS, and even provide a sample implementation.

2. Hazard Pointers and Deferred Reclamation

Another strategy is to prevent the root cause of the most dangerous ABA variant: memory reuse. The problem is most severe when node A is not just popped, but freed and then a new node is allocated at the same memory address.

Hazard Pointers solve this by preventing memory from being reclaimed while it might still be in use.

  • Announce: Before a thread accesses a shared pointer, it places that pointer's address into a thread-local "hazard pointer" slot, announcing to the system, "I am working with this node, don't delete it."
  • Reclaim: When a node is removed from the data structure, it's not deleted immediately. Instead, it's placed on a "retired" list.
  • Scan & Delete: Periodically, a reclamation process scans all threads' hazard pointers. If a retired node does not appear in any hazard pointer list, it's safe to delete.

This ensures that even if Thread 2 pops A, it cannot be freed and reallocated while Thread 1 still has it as a hazard. This prevents the CAS from succeeding on a recycled node.

ABA problem in lock-free data structure

The Medium article 'ABA problem in lock-free data structure' gives a good overview of several solutions, with a clear focus on Hazard Pointers.

Read the sections 'How to Solve the ABA Problem' and 'How It Solves the ABA Problem'. This will give you a solid conceptual understanding of how deferred reclamation techniques like hazard pointers work.

3. Load-Linked / Store-Conditional (LL/SC)

Some CPU architectures (like ARM and PowerPC, but notably not x86) provide an alternative to CAS: LL/SC.

  • Load-Linked reads a value from memory.
  • Store-Conditional attempts to write a new value, but it only succeeds if that memory location has not been modified by any other processor since the Load-Linked.

LL/SC is immune to the ABA problem by design because it checks for any modification, not just for value equality. If Thread 2 modifies the head and then restores it, the Store-Conditional in Thread 1 will still fail.

Test your understanding!

Consider a lock-free queue that uses a compare_exchange_strong loop to dequeue nodes. An Item* named head points to the front of the queue.

  1. Describe a plausible sequence of events across three threads (T1, T2, T3) that leads to the ABA problem.
  2. Explain exactly why T1's compare_exchange_strong would incorrectly succeed.
  3. If head were a tagged pointer (struct { Item* ptr; int tag; }), explain why the same compare_exchange_strong would now fail, preventing the bug.
Show answer

1. Sequence of Events:

  • Initial State: Queue has two items, A then B. head points to A.
  • T1 starts dequeue: It reads head which is A. It prepares to set head to A->next (which is B). T1 is then preempted.
  • T2 dequeues: It successfully dequeues A. head now points to B. T2 then puts A into a free list for later reuse.
  • T3 enqueues: It needs a new node, so it takes A from the free list and enqueues it at the back of the queue. The queue is now B -> A.
  • T2 dequeues again: It successfully dequeues B. head now points to A.

2. Why CAS Succeeds Incorrectly:

  • T1 resumes. The current value of head is A, which is exactly what T1 read initially.
  • T1's compare_exchange_strong(head, expected=A, desired=B) succeeds.
  • The head of the queue is now set to B, which has already been dequeued and possibly deleted or reused elsewhere. The queue is corrupted.

3. Why Tagged Pointers Prevent This:

  • Initial State: head is {ptr: A, tag: 10}.
  • T1: Reads head and stores {ptr: A, tag: 10} as its expected value.
  • T2 dequeues A: head becomes {ptr: B, tag: 11}.
  • T2 dequeues B: head becomes {ptr: A, tag: 12} (since A was re-enqueued, and dequeueing B makes it the new head).
  • T1 resumes: It executes compare_exchange_strong with its expected value {ptr: A, tag: 10}.
  • The operation fails because the current head is {ptr: A, tag: 12}. While the pointer matches, the tag does not. The corruption is prevented.

Conclusion

You've now dissected one of the most infamous problems in concurrent programming. The ABA problem demonstrates that in lock-free code, you cannot simply check a value; you must validate the state. Your telemetry engine's core data structure, the SPSC ring buffer, is a special case that sidesteps this issue because there is only one consumer and one producer, so they never contend for the same head/tail pointers in a way that could cause this problem. However, this knowledge is not theoretical—it's a critical part of your toolkit as you advance.

Key Takeaways:

  • The ABA Problem: A CAS can succeed on a value that has changed from A -> B -> A, hiding a potentially critical change in the underlying program state.
  • Root Cause: CAS checks for value equality, not state consistency.
  • Solution 1: Tagged Pointers: Pair a pointer with a version counter. This is the most direct software solution and makes each state unique.
  • Solution 2: Deferred Reclamation (Hazard Pointers): Prevent memory from being reused while any thread might be observing it. This attacks the problem of recycled memory addresses.
  • Solution 3: LL/SC: A hardware-level solution that detects any modification, but is not available on all platforms (like x86).

What's Next?

With a full understanding of atomics, memory ordering, and the subtle pitfalls like the ABA problem, you are now fully equipped to tackle the first major component of your project. In the next module, we will begin implementing the lock-free Single-Producer, Single-Consumer (SPSC) ring buffer. We'll apply the principles of std::atomic and acquire-release semantics to build the high-performance data pipeline that sits at the very heart of your telemetry engine.