Epoch-Based Garbage Collection
Tutorial
The Problem
Lock-free data structures remove nodes without a lock, but cannot immediately free the memory: another thread may still be traversing the node. Reference counting (like Arc) adds atomic overhead to every access. Epoch-based reclamation (EBR), introduced by Fraser (2004) and popularised by the crossbeam-epoch crate, solves this by grouping time into epochs. A thread "pins" its current epoch before reading, then unpins after. Memory retired in epoch E is only freed when every thread has advanced past E, guaranteeing no live references remain.
🎯 Learning Outcomes
freeAtomicU64 and Acquire/Release orderingretire (mark for deferred free) and collect (advance epoch and reclaim)Code Example
//! Epoch-based garbage collection concept.
//!
//! Demonstrates deferred reclamation: retired values are tagged with the
//! current global epoch and are only reclaimed once the epoch has advanced
//! past any reader that might still observe them.
use std::sync::Mutex;
/// A value retired at a particular epoch, awaiting safe reclamation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Retired<T> {
/// The retired value.
pub value: T,
/// Epoch at which the value was retired.
pub epoch: u64,
}
/// A simple epoch-based reclamation manager.
pub struct EpochGc<T> {
state: Mutex<State<T>>,
}
struct State<T> {
global_epoch: u64,
retired: Vec<Retired<T>>,
}
impl<T> Default for EpochGc<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> EpochGc<T> {
/// Creates a new manager at epoch `0` with no retired values.
pub fn new() -> Self {
Self {
state: Mutex::new(State {
global_epoch: 0,
retired: Vec::new(),
}),
}
}
/// Retires `value` at the current global epoch.
pub fn retire(&self, value: T) {
let mut state = self.state.lock().expect("epoch gc mutex poisoned");
let epoch = state.global_epoch;
state.retired.push(Retired { value, epoch });
}
/// Advances the global epoch by one and returns the new epoch.
pub fn advance(&self) -> u64 {
let mut state = self.state.lock().expect("epoch gc mutex poisoned");
state.global_epoch += 1;
state.global_epoch
}
/// Returns the current global epoch.
pub fn epoch(&self) -> u64 {
self.state
.lock()
.expect("epoch gc mutex poisoned")
.global_epoch
}
/// Returns the number of retired values still pending reclamation.
pub fn retired_len(&self) -> usize {
self.state
.lock()
.expect("epoch gc mutex poisoned")
.retired
.len()
}
/// Reclaims all retired values whose epoch is strictly less than
/// `min_safe_epoch` and returns them in retirement order.
pub fn collect(&self, min_safe_epoch: u64) -> Vec<Retired<T>> {
let mut state = self.state.lock().expect("epoch gc mutex poisoned");
let retired = std::mem::take(&mut state.retired);
let (safe, keep): (Vec<_>, Vec<_>) =
retired.into_iter().partition(|r| r.epoch < min_safe_epoch);
state.retired = keep;
safe
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retire_tracks_current_epoch() {
let gc: EpochGc<&'static str> = EpochGc::new();
gc.retire("a");
gc.advance();
gc.retire("b");
assert_eq!(gc.retired_len(), 2);
assert_eq!(gc.epoch(), 1);
}
#[test]
fn collect_reclaims_only_older_epochs() {
let gc: EpochGc<&'static str> = EpochGc::new();
gc.retire("e0-a");
gc.retire("e0-b");
gc.advance();
gc.retire("e1-a");
let reclaimed = gc.collect(1);
assert_eq!(reclaimed.len(), 2);
assert!(reclaimed.iter().all(|r| r.epoch == 0));
assert_eq!(gc.retired_len(), 1);
}
#[test]
fn collect_zero_reclaims_nothing() {
let gc: EpochGc<i32> = EpochGc::new();
gc.retire(10);
gc.retire(20);
let reclaimed = gc.collect(0);
assert!(reclaimed.is_empty());
assert_eq!(gc.retired_len(), 2);
}
#[test]
fn advance_returns_new_epoch() {
let gc: EpochGc<()> = EpochGc::new();
assert_eq!(gc.advance(), 1);
assert_eq!(gc.advance(), 2);
assert_eq!(gc.epoch(), 2);
}
#[test]
fn scenario_matches_ocaml_example() {
let gc: EpochGc<&'static str> = EpochGc::new();
gc.retire("node-A");
gc.retire("node-B");
gc.retire("node-C");
assert_eq!(gc.epoch(), 0);
assert_eq!(gc.retired_len(), 3);
assert_eq!(gc.advance(), 1);
let reclaimed = gc.collect(1);
assert_eq!(reclaimed.len(), 3);
assert_eq!(gc.retired_len(), 0);
}
}Key Differences
AtomicU64 with ordering annotations; OCaml's Atomic module provides sequentially consistent operations by default, hiding the ordering complexity.Mutex<VecDeque> for the retire list because there is no GC-assisted write barrier; OCaml would use a domain-local list to avoid cross-domain locking.unsafe boundary**: Rust's actual lock-free structures require unsafe for raw pointer dereference; OCaml's type system prevents raw pointer arithmetic entirely.OCaml Approach
OCaml's garbage collector handles memory automatically, so EBR is not required in pure OCaml code. For C bindings or Bigarray-allocated memory, manual tracking is necessary. The Domainslib library for Multicore OCaml uses domain-local state to track safe points, analogous to pin/unpin:
(* Conceptual OCaml EBR sketch *)
let epoch = Atomic.make 0
let pinned = Domain.DLS.new_key (fun () -> ref (-1))
let pin () =
let e = Atomic.get epoch in
!(Domain.DLS.get pinned) <- e; e
let unpin () =
!(Domain.DLS.get pinned) <- -1
Full Source
//! Epoch-based garbage collection concept.
//!
//! Demonstrates deferred reclamation: retired values are tagged with the
//! current global epoch and are only reclaimed once the epoch has advanced
//! past any reader that might still observe them.
use std::sync::Mutex;
/// A value retired at a particular epoch, awaiting safe reclamation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Retired<T> {
/// The retired value.
pub value: T,
/// Epoch at which the value was retired.
pub epoch: u64,
}
/// A simple epoch-based reclamation manager.
pub struct EpochGc<T> {
state: Mutex<State<T>>,
}
struct State<T> {
global_epoch: u64,
retired: Vec<Retired<T>>,
}
impl<T> Default for EpochGc<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> EpochGc<T> {
/// Creates a new manager at epoch `0` with no retired values.
pub fn new() -> Self {
Self {
state: Mutex::new(State {
global_epoch: 0,
retired: Vec::new(),
}),
}
}
/// Retires `value` at the current global epoch.
pub fn retire(&self, value: T) {
let mut state = self.state.lock().expect("epoch gc mutex poisoned");
let epoch = state.global_epoch;
state.retired.push(Retired { value, epoch });
}
/// Advances the global epoch by one and returns the new epoch.
pub fn advance(&self) -> u64 {
let mut state = self.state.lock().expect("epoch gc mutex poisoned");
state.global_epoch += 1;
state.global_epoch
}
/// Returns the current global epoch.
pub fn epoch(&self) -> u64 {
self.state
.lock()
.expect("epoch gc mutex poisoned")
.global_epoch
}
/// Returns the number of retired values still pending reclamation.
pub fn retired_len(&self) -> usize {
self.state
.lock()
.expect("epoch gc mutex poisoned")
.retired
.len()
}
/// Reclaims all retired values whose epoch is strictly less than
/// `min_safe_epoch` and returns them in retirement order.
pub fn collect(&self, min_safe_epoch: u64) -> Vec<Retired<T>> {
let mut state = self.state.lock().expect("epoch gc mutex poisoned");
let retired = std::mem::take(&mut state.retired);
let (safe, keep): (Vec<_>, Vec<_>) =
retired.into_iter().partition(|r| r.epoch < min_safe_epoch);
state.retired = keep;
safe
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retire_tracks_current_epoch() {
let gc: EpochGc<&'static str> = EpochGc::new();
gc.retire("a");
gc.advance();
gc.retire("b");
assert_eq!(gc.retired_len(), 2);
assert_eq!(gc.epoch(), 1);
}
#[test]
fn collect_reclaims_only_older_epochs() {
let gc: EpochGc<&'static str> = EpochGc::new();
gc.retire("e0-a");
gc.retire("e0-b");
gc.advance();
gc.retire("e1-a");
let reclaimed = gc.collect(1);
assert_eq!(reclaimed.len(), 2);
assert!(reclaimed.iter().all(|r| r.epoch == 0));
assert_eq!(gc.retired_len(), 1);
}
#[test]
fn collect_zero_reclaims_nothing() {
let gc: EpochGc<i32> = EpochGc::new();
gc.retire(10);
gc.retire(20);
let reclaimed = gc.collect(0);
assert!(reclaimed.is_empty());
assert_eq!(gc.retired_len(), 2);
}
#[test]
fn advance_returns_new_epoch() {
let gc: EpochGc<()> = EpochGc::new();
assert_eq!(gc.advance(), 1);
assert_eq!(gc.advance(), 2);
assert_eq!(gc.epoch(), 2);
}
#[test]
fn scenario_matches_ocaml_example() {
let gc: EpochGc<&'static str> = EpochGc::new();
gc.retire("node-A");
gc.retire("node-B");
gc.retire("node-C");
assert_eq!(gc.epoch(), 0);
assert_eq!(gc.retired_len(), 3);
assert_eq!(gc.advance(), 1);
let reclaimed = gc.collect(1);
assert_eq!(reclaimed.len(), 3);
assert_eq!(gc.retired_len(), 0);
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retire_tracks_current_epoch() {
let gc: EpochGc<&'static str> = EpochGc::new();
gc.retire("a");
gc.advance();
gc.retire("b");
assert_eq!(gc.retired_len(), 2);
assert_eq!(gc.epoch(), 1);
}
#[test]
fn collect_reclaims_only_older_epochs() {
let gc: EpochGc<&'static str> = EpochGc::new();
gc.retire("e0-a");
gc.retire("e0-b");
gc.advance();
gc.retire("e1-a");
let reclaimed = gc.collect(1);
assert_eq!(reclaimed.len(), 2);
assert!(reclaimed.iter().all(|r| r.epoch == 0));
assert_eq!(gc.retired_len(), 1);
}
#[test]
fn collect_zero_reclaims_nothing() {
let gc: EpochGc<i32> = EpochGc::new();
gc.retire(10);
gc.retire(20);
let reclaimed = gc.collect(0);
assert!(reclaimed.is_empty());
assert_eq!(gc.retired_len(), 2);
}
#[test]
fn advance_returns_new_epoch() {
let gc: EpochGc<()> = EpochGc::new();
assert_eq!(gc.advance(), 1);
assert_eq!(gc.advance(), 2);
assert_eq!(gc.epoch(), 2);
}
#[test]
fn scenario_matches_ocaml_example() {
let gc: EpochGc<&'static str> = EpochGc::new();
gc.retire("node-A");
gc.retire("node-B");
gc.retire("node-C");
assert_eq!(gc.epoch(), 0);
assert_eq!(gc.retired_len(), 3);
assert_eq!(gc.advance(), 1);
let reclaimed = gc.collect(1);
assert_eq!(reclaimed.len(), 3);
assert_eq!(gc.retired_len(), 0);
}
}
Exercises
Mutex<Vec<u64>> with thread_local! storage so pin/unpin do not contend on a shared mutex.unpin, reducing mutex acquisitions.EpochMgr to retire removed nodes, verifying with Miri or loom that no use-after-free occurs.