Epoch-Based Reclamation in Lock-Free C++

Epoch-based reclamation (EBR) is a memory management strategy designed to safely reclaim dynamically allocated memory in lock-free concurrent data structures without relying on a tracing garbage collector. This article covers the fundamental mechanics of EBR, why it is critical for resolving safe memory reclamation problems in concurrent systems, how global and local epochs interact, and how to implement this pattern effectively using modern C++ memory ordering and synchronization primitives.


The Memory Reclamation Problem in Lock-Free Code

In concurrent, lock-free data structures (such as lock-free linked lists, queues, or skip lists), removing a node typically involves atomically swinging a pointer to bypass that node via std::atomic::compare_exchange_strong or compare_exchange_weak.

However, detaching a node from the shared structure does not mean it can be immediately deallocated with delete. Other reader threads may have loaded a pointer to that node just before the pointer swap occurred and may still be reading its contents. Freeing the memory immediately causes a data race and undefined behavior via use-after-free. Postponing deletion indefinitely causes memory leaks, while using traditional reference counting mechanisms (std::shared_ptr) often introduces atomic increment/decrement bottlenecks that negate the performance benefits of lock-free architectures.

EBR solves this dilemma by deferring the destruction of detached objects until it is mathematically impossible for any reader thread to still hold a reference to them.


Core Mechanics of Epoch-Based Reclamation

EBR coordinates threads using a small set of generational counters, typically cycling through three states: 0, 1, and 2. The system is governed by three primary components:

  1. Global Epoch Counter: A single atomic integer (ranging 0 to 2) visible to all threads.
  2. Thread-Local Epochs and Active Flags: Each thread maintains an active flag indicating whether it is currently accessing shared memory, along with a local snapshot of the epoch it observed when it began reading.
  3. Retirement Lists: Buffers that hold retired nodes earmarked for deletion, segregated by the epoch in which they were detached.

Critical Section Lifecycle

When a thread interacts with a lock-free data structure, it wraps its operations in a critical section:

  • Entry: The thread marks itself as active and reads the current global epoch into its thread-local epoch storage.
  • Access: The thread navigates and reads lock-free nodes safely.
  • Exit: The thread marks itself as inactive.

Object Retirement and Reclamation Cycle

When a mutating thread unlinks a node from the concurrent structure:

  1. The node is not deleted; instead, it is placed into a retirement queue tagged with the current global epoch (\(e\)).
  2. Periodically, a thread attempts to advance the global epoch from \(e\) to \((e + 1) \pmod 3\).
  3. The epoch can only advance if no active thread is still registered in the epoch prior to \(e\).
  4. Because threads can only observe nodes that were reachable when they entered, and no thread remains in epoch \((e - 2) \pmod 3\), all nodes retired during epoch \((e - 2) \pmod 3\) are guaranteed to be unreferenced.
  5. Memory from epoch \((e - 2) \pmod 3\) is then safely freed.

Applying EBR to C++ Lock-Free Patterns

Implementing EBR in C++ requires leveraging the C++11 memory model, atomic operations, thread-local storage, and RAII (Resource Acquisition Is Initialization).

1. Managing Thread State with Thread-Local Variables

Each thread tracks its state using thread-local storage to prevent cross-core cache invalidations on read-heavy workloads:

#include <atomic>
#include <vector>

struct ThreadEpochState {
    std::atomic<bool> is_active{false};
    std::atomic<uint64_t> local_epoch{0};
};

inline thread_local ThreadEpochState tl_state;

2. RAII Guard for Critical Sections

An RAII guard ensures that a thread’s active status is reset even if exceptions occur:

class EpochGuard {
public:
    EpochGuard(std::atomic<uint64_t>& global_epoch) {
        tl_state.local_epoch.store(global_epoch.load(std::memory_order_relaxed), 
                                  std::memory_order_relaxed);
        tl_state.is_active.store(true, std::memory_order_seq_cst);
    }

    ~EpochGuard() {
        tl_state.is_active.store(false, std::memory_order_release);
    }

    EpochGuard(const EpochGuard&) = delete;
    EpochGuard& operator=(const EpochGuard&) = delete;
};

Using std::memory_order_seq_cst or appropriate acquire/release fences ensures that memory reads inside the critical section cannot be reordered prior to setting is_active to true.

3. Retirement Queues and Deferred Destruction

Retired nodes are stored using polymorphic deleters or simple linked buffers:

struct RetiredNode {
    void* ptr;
    void (*deleter)(void*);
};

class EpochManager {
    std::atomic<uint64_t> global_epoch{0};
    std::vector<ThreadEpochState*> registry;
    std::vector<RetiredNode> retired_bins[3];

public:
    template <typename T>
    void retire(T* ptr) {
        uint64_t current = global_epoch.load(std::memory_order_relaxed);
        retired_bins[current % 3].push_back({
            ptr, 
            [](void* p) { delete static_cast<T*>(p); }
        });
    }

    void try_advance_and_reclaim() {
        uint64_t current = global_epoch.load(std::memory_order_relaxed);

        // Verify that all active threads have caught up to the current epoch
        for (auto* thread : registry) {
            if (thread->is_active.load(std::memory_order_acquire)) {
                if (thread->local_epoch.load(std::memory_order_relaxed) != current) {
                    return; // A thread is still in an older epoch; abort epoch advance
                }
            }
        }

        // Advance the global epoch
        global_epoch.store((current + 1), std::memory_order_relaxed);

        // It is now safe to free memory retired two epochs ago
        size_t safe_bin = (current + 2) % 3; 
        for (auto& item : retired_bins[safe_bin]) {
            item.deleter(item.ptr);
        }
        retired_bins[safe_bin].clear();
    }
};

Trade-offs and Best Practices in C++

  • Throughput: EBR delivers near-zero overhead on read paths compared to hazard pointers, as it relies on thread-local operations rather than atomic updates per node read.
  • The Stalling Hazard: If a single thread stalls, blocks on I/O, or experiences an OS descheduling event while inside an EpochGuard, the global epoch cannot advance. Consequently, retired nodes accumulate across all threads, leading to unbounded memory usage.
  • Short Critical Sections: In C++, lock-free algorithms utilizing EBR must ensure that critical sections are short and deterministic. Never perform I/O, block on condition variables, or yield CPU control while an epoch guard is active.