CodeRigor

FREE LESSON

Lock-free data structures: the Treiber stack

Build a stack that many threads push and pop without locks: a CAS loop on the head pointer, the ABA problem, and why a popped node can't simply be deleted.

A mutex is the right default for shared data. But a lock has a failure mode no amount of care removes: if the thread holding it is descheduled, preempted by a higher-priority thread, or stuck in a page fault, everyone else waits. Lock-free structures replace "take the lock, change things, release" with "prepare a change, then publish it with one atomic compare-exchange — and retry if someone beat you". The classic first example is R. Kent Treiber's stack (1986). It fits in thirty lines, and it shows every hard problem in the field: retry loops, memory ordering, ABA, and above all memory reclamation.

1. Progress guarantees

"No mutex" is not the definition. The non-blocking guarantees are about who is promised progress:

Guarantee Promise Example
Wait-free every thread finishes in a bounded number of its own steps fetch_add on x86
Lock-free whenever threads run, some thread finishes Treiber stack: a CAS fails only because another succeeded
Obstruction-free a thread finishes if it runs alone can livelock under contention
Blocking a stalled thread can stop everyone mutexes — and spin locks built from atomics

A spin lock made of std::atomic<bool> uses no mutex and is still blocking: if the owner is preempted, the spinners spin. Lock-free means the system as a whole can't get stuck, whatever the scheduler does. It does not mean faster — under heavy contention a good mutex often wins.

2. The stack: one atomic pointer

C++
struct Node {
    T value;
    Node* next;
};
std::atomic<Node*> head_{nullptr};

Push builds the node privately, points it at the current head, and swings head_ to it — but only if head_ is still what it read:

C++
void push(T value) {
    Node* n = new Node{std::move(value)};
    n->next = head_.load(std::memory_order_relaxed);
    while (!head_.compare_exchange_weak(n->next, n,
                                        std::memory_order_release,
                                        std::memory_order_relaxed)) {
    }
}
push: link privately, then publish with one CAS
start / 2
The new node is invisible to other threads until the CAS swings head_ to it. Step through it.
push: link privately, then publish with one CAS
The new node is invisible to other threads until the CAS swings head_ to it. Step through it.

compare_exchange_weak(expected, desired) atomically does: if head_ == expected, set it to desired and return true; otherwise copy the current value into expected and return false. Passing n->next as expected means a failed attempt refreshes n->next with the new head — so the loop body is empty. weak may also fail spuriously (on LL/SC machines like AArch64), which the loop handles for free.

Load-then-store loses a push
Both pushers read head H and point their nodes at it. The second plain store overwrites the first: node A is unreachable and leaked.
Load-then-store loses a push
Both pushers read head H and point their nodes at it. The second plain store overwrites the first: node A is unreachable and leaked.

Why not just load then store? Two pushers both load head H, both point their node at H, and the second store overwrites the first: a node vanishes. Each operation was atomic; the read-modify-write wasn't.

Worked example

Eight threads push 1000 values each, then one thread drains the stack. Every value must come out exactly once:

C++
#include <atomic>
#include <barrier>
#include <print>
#include <thread>
#include <utility>
#include <vector>

template <class T>
class Stack {
    struct Node {
        T value;
        Node* next = nullptr;
    };
    std::atomic<Node*> head_{nullptr};

public:
    ~Stack() {
        for (Node* n = head_.load(); n;) {
            Node* next = n->next;
            delete n;
            n = next;
        }
    }

    void push(T value) {
        Node* n = new Node{std::move(value)};
        n->next = head_.load(std::memory_order_relaxed);
        while (!head_.compare_exchange_weak(n->next, n, std::memory_order_release,
                                            std::memory_order_relaxed)) {
        }
    }

    // Single-threaded drain only: freeing here is safe because nobody else runs.
    bool pop_unsynchronised(T& out) {
        Node* n = head_.load(std::memory_order_acquire);
        if (!n) return false;
        head_.store(n->next, std::memory_order_relaxed);
        out = std::move(n->value);
        delete n;
        return true;
    }
};

int main() {
    constexpr int threads = 8, per = 1000;
    Stack<int> s;
    {
        std::barrier start(threads);
        std::vector<std::jthread> ts;
        for (int t = 0; t < threads; ++t)
            ts.emplace_back([&, t] {
                start.arrive_and_wait();
                for (int i = 0; i < per; ++i) s.push(t * per + i);
            });
    }   // jthreads join here
    std::vector<int> seen(threads * per, 0);
    int v;
    while (s.pop_unsynchronised(v)) ++seen[static_cast<std::size_t>(v)];
    int ok = 0;
    for (int c : seen) ok += c == 1;
    std::println("{} of {} values seen exactly once", ok, threads * per);
}

The barrier starts all pushers at once so they really contend. Joining the threads before draining is what makes the single-threaded pop safe.

3. Pop, and ordering

Pop swings head_ from the top node to its successor:

C++
Node* old = head_.load(std::memory_order_acquire);
while (old && !head_.compare_exchange_weak(old, old->next,
                                           std::memory_order_acquire,
                                           std::memory_order_acquire)) {
}
if (!old) return std::nullopt;
// we own old now

Check old before every old->next — on an empty stack it is nullptr.

The orderings pair up: push's successful CAS is release, pop's loads are acquire. When a popper reads a pointer that a pusher released, everything the pusher wrote before the CAS — the node's value and next — happens-before the popper's reads. With relaxed instead, those plain reads race with the pusher's writes: undefined behaviour, which ThreadSanitizer reports even on x86, where the hardware happens to keep stores in order.

4. The reclamation problem

Here is the trap. After winning the CAS, it is tempting to delete old:

delete after the CAS: another thread still holds the pointer
Thread 1 loaded A before thread 2 unlinked and deleted it. Its read of old->next is a use-after-free, and no memory order prevents it.
delete after the CAS: another thread still holds the pointer
Thread 1 loaded A before thread 2 unlinked and deleted it. Its read of old->next is a use-after-free, and no memory order prevents it.

Unlinking A from head_ does not stop threads that loaded A earlier from dereferencing it. Thread 1's read is a use-after-free. No memory ordering fixes this: thread 1's load was perfectly legal when it happened.

ABA

It gets worse if the allocator recycles A's address:

ABA: the address comes back, the CAS can't tell
start / 4
Step through thread 2's pops and push while thread 1 is paused between reading old->next and its CAS.
ABA: the address comes back, the CAS can't tell
Step through thread 2's pops and push while thread 1 is paused between reading old->next and its CAS.

The compare-exchange compares addresses, and A → B → A looks like "nothing changed". That's the ABA problem. The aba-output exercise replays this interleaving step by step.

5. Ways out

Approach Idea Cost
Deferred reclamation never free a popped node while the stack lives; retire it, free all in the destructor memory grows with pops — fine for bounded lifetimes
Hazard pointers (next skill, std::hazard_pointer in C++26) a thread publishes "I'm about to read X"; retired nodes are freed only when no hazard points at them a few extra atomic ops per access
Epoch-based (RCU-like) free a node after every thread has passed a quiescent point one stalled thread blocks reclamation
Tagged / counted pointers CAS a (pointer, version) pair, so A → B → A is detected needs a double-width CAS
Split reference counts external count in the head word + internal count in the node intricate; double-width CAS again

Deferred reclamation is what you'll build: popped nodes go onto a second lock-free list, retired_, and the destructor frees them. Because addresses are never reused while the stack lives, it also rules out ABA. One subtlety: link retired nodes through a separate field. Other poppers may still be reading a popped node's next; writing it to build the retired list is a data race.

Deferred reclamation: popped nodes move to a second list, untouched
A popped node is pushed onto retired_ through its own retired_next field — never by rewriting next, which a slow popper may still be reading. The destructor walks both lists and frees everything.
Deferred reclamation: popped nodes move to a second list, untouched
A popped node is pushed onto retired_ through its own retired_next field — never by rewriting next, which a slow popper may still be reading. The destructor walks both lists and frees everything.

What about the tools the standard library offers?

C++
#include <atomic>
#include <cstdint>
#include <memory>
#include <print>

struct Node;
struct Tagged {
    Node* ptr;
    std::uintptr_t tag;
};

int main() {
    std::println("atomic<Node*>:              {}", std::atomic<Node*>{}.is_lock_free());
    std::println("atomic<Tagged> (16 bytes):  {}", std::atomic<Tagged>{}.is_lock_free());
    std::println("atomic<shared_ptr<int>>:    {}", std::atomic<std::shared_ptr<int>>{}.is_lock_free());
}

On GCC 16 / x86-64 only the plain pointer is lock-free. x86-64 does have cmpxchg16b, but GCC sends 16-byte atomics through libatomic (there's no lock-free 16-byte load), so tagged-pointer stacks usually steal unused pointer bits instead. And std::atomic<std::shared_ptr> is implemented with a lock in libstdc++ — correct and convenient, but not a lock-free building block.

Faded example

Write retire(Node* n): push n onto retired_, linking through retired_next. It is the same pattern as push.

C++
void retire(Node* n) {
    n->retired_next = /* ??? */;
    while (!retired_.compare_exchange_weak(/* ??? */, n,
                                           std::memory_order_release,
                                           std::memory_order_relaxed)) {
    }
}
Answer

n->retired_next = retired_.load(std::memory_order_relaxed); and pass n->retired_next as the expected value. On failure the CAS refreshes it.

6. Measuring contention

A CAS loop's cost is its retries. Count them:

C++
#include <atomic>
#include <barrier>
#include <print>
#include <thread>
#include <vector>

int main() {
    for (int threads : {1, 4, 8}) {
        std::atomic<long> counter{0}, retries{0};
        std::barrier start(threads);
        {
            std::vector<std::jthread> ts;
            for (int t = 0; t < threads; ++t)
                ts.emplace_back([&] {
                    start.arrive_and_wait();
                    for (int i = 0; i < 2000; ++i) {
                        long cur = counter.load(std::memory_order_relaxed);
                        while (!counter.compare_exchange_weak(cur, cur + 1, std::memory_order_relaxed)) {
                            retries.fetch_add(1, std::memory_order_relaxed);
                        }
                    }
                });
        }
        std::println("{} thread(s): {} increments, {} failed CAS attempts", threads, counter.load(),
                     retries.load());
    }
}

The count is always right (lock-free!), but failed attempts climb with the thread count: every failure is wasted work and a cache line bouncing between cores. That's why lock-free structures keep the CAS window tiny, back off under contention, or avoid a single hot word altogether (per-thread or sharded structures).

Summary

  • Lock-free = some thread always makes progress; not "no mutex", not "faster".
  • Push and pop are compare-exchange loops on head_; a load-then-store loses updates.
  • Publish with release, consume with acquire, or reading the node is a data race.
  • A popped node can't be freed immediately: other threads may still read it (use-after-free), and recycled addresses cause ABA.
  • Fix it with a reclamation scheme: deferred until destruction (simplest), hazard pointers, epochs, or tagged/counted pointers.
  • std::atomic<16-byte struct> and std::atomic<std::shared_ptr> are not lock-free with libstdc++ — check is_lock_free().

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Builds lock-free structures step by step with CAS loops and shows exactly where ABA and reclamation bite.

Part 2: a lock-free linked list and the memory-management problem a Treiber stack shares with it.

Practical lock-free design: what progress guarantees mean and how to reason about a CAS loop's correctness.

Measured comparisons of locks, spinlocks and lock-free structures — when lock-free actually wins, and when contention makes it lose.

Rigorous lecture on memory models, CAS, the ABA problem and lock-free stacks.