Hazard Pointers: Lock-Free Memory Reclamation Without GC


The Hook

Your lock-free stack works great — until you need to free popped nodes. Who frees the memory? Reference counting adds overhead. Epoch-based reclamation is coarse. Hazard pointers let each thread publish “I’m accessing this pointer” — the reclaimer waits until no hazards exist. Lock-free, wait-free, no GC needed.

The Thesis

Memory reclamation is the hard part of lock-free programming. You can’t free a node while another thread might still be reading it. Hazard pointers solve this with a simple protocol: threads publish “hazard pointers” to nodes they’re accessing; the reclaimer scans all hazards before freeing. Wait-free for readers, bounded for reclaimers.

The Science

The Problem

// Lock-free stack pop
Node* pop() {
    Node* head = head_.load();
    while (head && !head_.compare_exchange_weak(head, head->next)) {
        // retry
    }
    // DANGER: Can we delete head here?
    // Another thread might still be reading head->next!
    return head;
}

The problem: Thread A reads head, gets preempted. Thread B pops, frees head. Thread A resumes, dereferences freed memory → crash.

Hazard Pointers (Michael, 2004)

Core idea: Each thread publishes “I’m accessing this pointer” in a global hazard pointer array. Reclaimer checks all hazards before freeing.

// Global hazard pointer array
constexpr int MAX_THREADS = 128;
constexpr int HAZARDS_PER_THREAD = 2;

struct HazardPointers {
    std::atomic<Node*> hazards[MAX_THREADS][HAZARDS_PER_THREAD];
    std::atomic<int> active_threads;
    
    // Thread registers a hazard
    void protect(int thread_id, int hazard_slot, Node* ptr) {
        hazards[thread_id][hazard_slot].store(ptr, std::memory_order_release);
    }
    
    // Thread clears hazard
    void clear(int thread_id, int hazard_slot) {
        hazards[thread_id][hazard_slot].store(nullptr, std::memory_order_release);
    }
    
    // Check if any thread protects ptr
    bool is_protected(Node* ptr) {
        for (int t = 0; t < MAX_THREADS; ++t) {
            for (int h = 0; h < HAZARDS_PER_THREAD; ++h) {
                if (hazards[t][h].load(std::memory_order_acquire) == ptr)
                    return true;
            }
        }
        return false;
    }
};

Usage in Lock-Free Stack

class LockFreeStack {
    std::atomic<Node*> head_;
    HazardPointers hp_;
    std::vector<Node*> retired_;  // Per-thread retired list
    
    void push(int value) {
        Node* node = new Node(value);
        node->next = head_.load();
        while (!head_.compare_exchange_weak(node->next, node));
    }
    
    int pop() {
        int thread_id = get_thread_id();  // Thread-local
        
        while (true) {
            Node* head = head_.load();
            if (!head) return -1;  // Empty
            
            // Publish hazard
            hp_.protect(thread_id, 0, head);
            
            // Re-check (head might have changed)
            if (head != head_.load()) {
                hp_.clear(thread_id, 0);
                continue;
            }
            
            // Try to pop
            if (head_.compare_exchange_weak(head, head->next)) {
                int value = head->value;
                hp_.clear(thread_id, 0);
                
                // Retire node
                retired_.push_back(head);
                if (retired_.size() >= 100) scan_and_delete();
                
                return value;
            }
            
            hp_.clear(thread_id, 0);
        }
    }
    
    void scan_and_delete() {
        for (Node* node : retired_) {
            if (!hp_.is_protected(node)) {
                delete node;
            } else {
                // Keep for next scan
            }
        }
        // Rebuild retired list with unprotected nodes
    }
};

Optimizations

1. Thread-Local Retired Lists + Global Scan

thread_local std::vector<Node*> thread_retired;

void retire(Node* node) {
    thread_retired.push_back(node);
    if (thread_retired.size() >= BATCH_SIZE) {
        global_retired_list.push_back(thread_retired);
        thread_retired.clear();
    }
}

void global_scan() {
    // Collect all retired lists
    // Check each against all hazard pointers
    // Delete unprotected
}

2. Epoch-Based Alternative (Simpler, Coarser)

class EpochReclamation {
    std::atomic<uint64_t> global_epoch{0};
    thread_local uint64_t thread_epoch = 0;
    thread_local std::vector<Node*> retired;
    
    void enter() { thread_epoch = global_epoch.load(); }
    void exit() { thread_epoch = 0; }
    
    void retire(Node* n) { retired.push_back(n); }
    
    void try_advance() {
        if (global_epoch.compare_exchange_weak(epoch, epoch + 1)) {
            // Scan all retired lists from epochs < epoch - 1
        }
    }
};

Comparison

Method Reclamation Latency Memory Overhead Complexity Throughput Impact
Hazard Pointers Bounded (scan interval) O(threads × hazards) Medium Low
Epoch-Based Up to 2 epochs O(threads) Low Very low
Reference Counting Immediate O(1) per node Low High (atomic inc/dec)
RCU Grace period O(CPUs) High Very low (readers)

The Protocol: Implement Hazard Pointers

Step 1: Global Hazard Array

// Fixed-size array, cache-line aligned
struct alignas(64) HazardSlot {
    std::atomic<Node*> ptr;
};
HazardSlot hazards[MAX_THREADS][HAZARDS_PER_THREAD];

Step 2: Thread Registration

thread_local int my_thread_id = []() {
    static std::atomic<int> next_id{0};
    return next_id.fetch_add(1);
}();

Step 3: Protect/Clear in Critical Sections

void access(Node* ptr) {
    hp.protect(my_thread_id, 0, ptr);
    // ... use ptr ...
    hp.clear(my_thread_id, 0);
}

Step 4: Retire and Scan

void retire(Node* n) {
    thread_retired.push_back(n);
    if (thread_retired.size() >= 64) {
        scan_retired();
    }
}

void scan_retired() {
    for (auto it = thread_retired.begin(); it != thread_retired.end();) {
        if (!hp.is_protected(*it)) {
            delete *it;
            it = thread_retired.erase(it);
        } else {
            ++it;
        }
    }
}

The Counterpoint

  • Scan overhead: O(hazards × retired) per scan
  • Memory bloat: Retired nodes accumulate until scan
  • Not wait-free for reclaimers: Scanning can take time
  • ABA problem: Hazard pointers don’t solve ABA (need tagged pointers)

Practical Takeaway

Use hazard pointers when: Lock-free data structures, manual memory management, no GC. Use epoch-based when: Simpler, can tolerate longer reclamation latency. Use reference counting when: Simplicity > performance, low contention.

Libraries: libcds (C++), concurrent (Rust), liblfds (C), folly (Facebook).


Sources: Michael (2004) “Hazard Pointers”; Maged Michael “Hazard Pointers: Safe Memory Reclamation”; Dmitry Vyukov “Memory Reclamation”; Facebook Folly Hazptr; Rust crossbeam::epoch; Linux kernel RCU.