← Back to list

C++26 Hazard Pointers: Safe Memory Reclamation for Lock-Free Code

From ABA Problem to Production-Safe Lock-Free Structures — A Practical Deep Dive into std::hazard_pointer

Sagar in Towards Dev · 2026-06-16 05:06 · 2 claps · 7.6 min read
#programming #software-development #cpp26 #cpp #c-plus-plus-language
Open on Medium ↗
Wiki topics: 💻 · Programming

C++26 Hazard Pointers: Safe Memory Reclamation for Lock-Free Code

From ABA Problem to Production-Safe Lock-Free Structures — A Practical Deep Dive into std::hazard_pointer

In the **The ABA Problem: The Silent Killer in Lock-Free Code, i broke down the ABA problem — how a perfectly logical CAS loop can silently corrupt your data because CAS checks equality, not identity**. We ended with a promise: hazard pointers are the fix, and C++26 standardizes them.

This is that post.

We’re going to build up the intuition from scratch, see why hazard pointers don’t stop ABA from occurring — but instead make it harmless by preventing memory from being reclaimed while it is still being observed — walk through the C++26 API, and finally fix the exact free list that was broken in the previous article. No switching to a different data structure. The same free list, the same ABA scenario, now made correct.

The Core Problem, One More Time

Let me distill the ABA problem into one sentence: A thread dereferences a pointer that another thread has already recycled, and CAS can’t tell because the address came back.

In our free list from last time, Thread 1 reads **head and `head->next`. Then Thread 2** acquires that block, uses it, releases it back. The address reappears. Thread 1's CAS succeeds on stale data. Two threads end up owning the same block. Corruption follows.

The fundamental issue is a lifetime gap:

What we need is a way for Thread 1 to say: “Hey, I’m looking at this block right now. Don’t recycle it yet.

That’s literally what hazard pointers do.

The Intuition: A “Do Not Recycle” Sign

Think of a library. Books circulate — people borrow them and return them. The library wants to remove old books to free up shelf space. But you can’t shred a book while someone is reading it at one of the tables.

So the library has a rule: if you’re reading a book at a table, you put a card with the book’s title on your table. The librarian checks all the cards before shredding anything. If any card matches the book she’s about to shred, she puts it aside and checks again later.

That’s the entire mental model:

┌───────────────────────────────────────────────────────────┐
│                                                           │
│  Thread           =  Reader at a table                    │
│  Hazard pointer   =  The card on the table                │
│  Protecting a ptr =  Writing the book title on the card   │
│  Retiring a ptr   =  Putting the book in the "to shred"   │
│                      pile                                 │
│  Reclamation      =  The librarian checking all cards     │
│                      before actually shredding            │
│                                                           │
└───────────────────────────────────────────────────────────┘

A thread protects a pointer by publishing it in a globally visible slot. When another thread wants to recycle a block, it doesn’t recycle immediately — it retires the block. The runtime periodically checks all hazard pointer slots across all threads. Any retired block that no thread is protecting gets reclaimed. The rest wait.

The Protocol: Protect, Retire, Reclaim

The protocol is simple. Here it is below:

C++26 API: What You Actually Write

C++26 gives us two classes in <hazard_pointer>:

  • std::hazard_pointer — a RAII handle to a hazard slot
  • std::hazard_pointer_obj_base<T, D> — a CRTP base class for objects you want to protect and retire

Making Your Type Hazard-Pointer-Compatible:

#include <hazard_pointer>

struct Block : std::hazard_pointer_obj_base<Block> {
    Block* next{nullptr};
    char payload[256]{};
};

By inheriting from hazard_pointer_obj_base<Block>, your type gains:

  • A retire() method that puts the object on the retired list
  • Integration with the runtime’s reclamation machinery
  • A default deleter (calls delete), or you can supply a custom one as the second template parameter

If your blocks come from a custom allocator:

struct PoolDeleter {
    void operator()(Block* b) const noexcept {
        // return to underlying memory pool, etc.
    }
};

struct Block : std::hazard_pointer_obj_base<Block, PoolDeleter> {
    // ...
};

Getting a Hazard Pointer:

std::hazard_pointer hp = std::make_hazard_pointer();

make_hazard_pointer() grabs a hazard slot from a global pool. The returned std::hazard_pointer is a RAII handle — when it goes out of scope, the slot is cleared and returned to the pool. Create one at the top of your operation and let it clean itself up.

The Important Methods:

class hazard_pointer {
public:
    // Protect a pointer read from an atomic.
    // Does the read-publish-validate loop internally.
    template<typename T>
    T* protect(const std::atomic<T*>& source) noexcept;

    // Clear protection — stop guarding the current pointer
    void reset_protection() noexcept;

    // Check if this hazard pointer is empty
    bool empty() const noexcept;
};

Usage Example: Before we touch the free list, let’s see hazard pointers work on the simplest possible case — a shared config that reader threads continuously access while a writer thread swaps in updates.

#include <hazard_pointer>
#include <atomic>
#include <thread>
#include <iostream>

struct Config : std::hazard_pointer_obj_base<Config> {
    std::string server = "default.api.com";
    int timeout = 30;
};

std::atomic<Config*> global_config{nullptr};
std::atomic<bool> running{true};

void reader() {
    while (running.load()) {
        // Get a hazard pointer slot
        std::hazard_pointer hp = std::make_hazard_pointer();

        // Protect the config before reading it
        Config* cfg = hp.protect(global_config);

        if (cfg) {
            // Safe to read — cfg won't be deleted
            // while hp guards it
            std::cout << "server: " << cfg->server
                      << ", timeout: " << cfg->timeout << "\n";
        }

        // hp goes out of scope — protection cleared automatically
    }
}

void updater() {
    std::string servers[] = {"fast.api.com", "backup.api.com", "eu.api.com"};
    int timeouts[] = {10, 20, 5};

    for (int i = 0; i < 3; ++i) {
        Config* new_cfg = new Config{servers[i], timeouts[i]};

        // Swap in the new config, get the old one
        Config* old_cfg = global_config.exchange(new_cfg);

        if (old_cfg) {
            // Don't delete directly!
            // A reader might still be looking at it.
            old_cfg->retire();  // safe deferred deletion
        }

        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }

    running.store(false);
}

int main() {
    global_config.store(new Config{});

    std::jthread t1(reader);
    std::jthread t2(reader);
    std::jthread t3(updater);

    t1.join(); t2.join(); t3.join();

    // Cleanup whatever is left
    Config* final_cfg = global_config.exchange(nullptr);
    if (final_cfg) delete final_cfg;
}

Three things happened. That’s the entire pattern:

┌──────────────────────────────────────────────────────┐
│                                                      │
│  Reader (in a loop):                                 │
│    hp = make_hazard_pointer()   ← get a slot         │
│    ptr = hp.protect(atomic)     ← guard before read  │
│    use ptr->whatever            ← safe to deref      │
│    hp goes out of scope         ← auto-clear         │
│                                                      │
│  Writer:                                             │
│    swap in new data                                  │
│    old_ptr->retire()            ← defer deletion     │
│                                                      │
│  Runtime:                                            │
│    eventually deletes old_ptr                        │
│    only after all hazard slots clear                 │
│                                                      │
└──────────────────────────────────────────────────────┘

No locks. No reference counting. The reader never blocks the writer. The writer never corrupts the reader’s data. And the old config gets cleaned up automatically once it’s safe.

Notice what would go wrong without hazard pointers:

Writer:  old = global_config.exchange(new_cfg)
Writer:  delete old           ← freed!

Reader:  cfg = global_config  ← got old pointer before the swap
Reader:  cfg->server          ← USE AFTER FREE - UB

The hazard pointer makes this race impossible. **retire()** says "delete this later" instead of "delete this now", and "later" means "after every reader has moved on."

That’s the whole idea. Now let’s apply it to our broken free list.

Fixing the Free List with Hazard Pointers

Now let’s return to the free list that suffered from ABA in the previous article.

The bug was here:

old_head = head.load();
if (!old_head) return nullptr;

new_head = old_head->next;  // potentially unsafe

Nothing protects **old_head between loading it and dereferencing it. Another thread can remove the node, use it, and return it to the free list before we read `old_head->next`**.

To make the free list hazard-pointer-safe, the first change is making **Block** participate in the hazard-pointer reclamation system:

struct Block : std::hazard_pointer_obj_base<Block> {
    Block* next{nullptr};
    char payload[256]{};
};

This gives **Block a `retire()`** member function and allows the hazard-pointer runtime to manage its eventual reclamation.

The second change is protecting the head node before dereferencing it:

Block* acquire() {
    std::hazard_pointer hp = std::make_hazard_pointer();

    Block* old_head;
    Block* new_head;

    do {
        old_head = hp.protect(head);

        if (!old_head)
            return nullptr;

        new_head = old_head->next;

    } while (!head.compare_exchange_weak(
        old_head,
        new_head,
        std::memory_order_acquire,
        std::memory_order_relaxed));

    return old_head;
}

The key difference is the call to:

old_head = hp.protect(head);

Instead of loading **head directly, we first protect the node with a hazard pointer. This guarantees the node cannot be reclaimed or recycled while we're examining it, making the subsequent dereference of `old_head->next`** safe.

Finally, if a block ever leaves the system permanently, we retire it rather than deleting it directly:

static void destroy(Block* block) {
    block->retire();
}

That’s it. The free-list algorithm itself is unchanged. The only additions are:

  • making **Block** hazard-pointer-aware,
  • protecting **head** before dereferencing it,
  • retiring nodes instead of deleting them immediately.

Those changes close the lifetime hole that allowed ABA to corrupt the free list.

Walk the race through one more time, now with protection in place:

Thread 1                          Thread 2
--------                          --------
hp.protect(head)
  → reads head = A
  → publishes A in hazard slot
  → re-reads head, still A  ✓
                                  acquire() returns A
                                  ... uses A ...
                                  destroy(A) → A->retire()
                                  Runtime scans hazard slots:
                                    A is GUARDED by Thread 1!
                                  → A is NOT freed; kept on
                                    the retired list, retry later
new_head = A->next  ← SAFE
  (A still valid memory)
CAS(head, A, new_head)

Ok diagram would be better right:

The crucial insight from the intro holds: hazard pointers don’t prevent ABA from happening — they make it harmless. The address could still come back, but the block’s memory cannot be reclaimed and reused while a hazard slot points at it. The dereference at step ④ always touches live memory.

The free list logic barely changed. We swapped a raw head.load() for hp.protect(head), made Block inherit one base class, and replaced any direct delete with retire(). That's the elegance of C++26 hazard pointers: the algorithm stays lock-free, and safe reclamation becomes a small, local concern rather than a global redesign.

Closing Thoughts

We started with a CAS loop that looked correct but wasn’t. CAS can tell you that a pointer’s value hasn’t changed; it can’t tell you whether the object behind that pointer is still the same one.

That’s where ABA comes from.

Hazard pointers solve that problem with a simple guarantee: Memory won’t be reclaimed while any thread is still observing it.

That’s enough to make ABA harmless. The same address may reappear, but the memory behind it cannot be recycled while another thread still holds a hazard pointer to it.

And C++26 makes the technique straightforward. Inherit from **hazard_pointer_obj_base<T>, acquire a slot with `make_hazard_pointer()**,protect()` before dereferencing, and **retire()** instead of deleting immediately.

The algorithm stays lock-free. The runtime handles safe reclamation.

If you remember one thing, remember this: Protect before you read, retire instead of delete, and reclaim only when nobody is still looking.

For years, safe memory reclamation was one of the hardest parts of lock-free programming. C++26 finally gives us a standard solution.

Found this article helpful? Please Clap 👏 and follow for more C++ and system programming content.


메타데이터
post_id
08b4b1db8a01
slug
cpp26-hazard-pointers-fix-aba-problem-lock-free-08b4b1db8a01
url
https://medium.com/@sagarmadala/cpp26-hazard-pointers-fix-aba-problem-lock-free-08b4b1db8a01
canonical_url
https://medium.com/@sagarmadala/cpp26-hazard-pointers-fix-aba-problem-lock-free-08b4b1db8a01
author_url
https://medium.com/@sagarmadala
status
ok
fetched_at
2026-06-16 19:09:56