Rust in Everything
基于 crossbeam-epoch 0.9.21

pin 与 Guard

主线位置:回收主线的前半段。epoch::pin() 取到本线程的参与者,Local::pin 把全局纪元抄进本地纪元、打上「已钉住」标记、执行一道 SeqCst fence,返回 Guard;之后这个线程读到的任何共享指针都不会被回收,直到 Guard 被 drop。

flowchart TB
  pin["epoch::pin()"] --> tls["with_handle:线程本地 HANDLE"]
  tls --> lp["Local::pin"]
  lp --> gc{"guard_count == 0 ?"}
  gc -->|"否:重入"| inc["guard_count += 1,直接返回 Guard"]
  gc -->|"是"| ld["读全局纪元(Relaxed),打上 pinned 位"]
  ld --> arch{"x86 且不在 Miri 下?"}
  arch -->|"是"| cas["本地纪元 compare_exchange(SeqCst) + compiler_fence"]
  arch -->|"否"| st["本地纪元 store(Relaxed) + fence(SeqCst)"]
  cas --> cnt["pin_count += 1"]
  st --> cnt
  cnt --> every{"第 1、129、257… 次?"}
  every -->|"是"| col["global.collect()"]
  every -->|"否"| ret["返回 Guard"]
  col --> ret

Epoch:最低位是标志

epoch.rs 只有 149 行:

pub(crate) struct Epoch {
    /// The least significant bit is set if pinned. The rest of the bits hold the epoch.
    data: EpochRepr,
}

EpochRepr 在有 64 位原子的平台上是 u64,否则是 usize(0.9.19 起,#1230;之前 32 位平台上是 32 位)。几个操作都是位运算:

方法实现含义
starting()0初始纪元,未钉住。本地纪元在「未钉住」时一律写回这个值
pinned()data | 1打上「已钉住」标记
unpinned()data & !1去掉标记
successor()data + 2下一个纪元(标记位不变)
wrapping_sub(rhs)(data - (rhs & !1)) as i64 >> 1两个纪元相差几步,带符号

纪元每次加 2,所以「纪元号」其实是 data >> 1。用 64 位以后,按每秒推进一百万次算也要约 29 万年才回绕;回绕本身也被 wrapping_sub 处理了。

每个参与者(Local)有一个本地纪元,全局(Global)有一个全局纪元,两个都是 CachePadded<AtomicEpoch>,各占一整条缓存行,避免被同结构体里的其他字段拖累。Local.epoch 的 CachePadded 是 2023 年 #1026(「Fix false sharing in Local struct」)加的。

Local::pin

internal.rs 第 399 行,去掉 x86 分支以后是这样:

pub(crate) fn pin(&self) -> Guard {
    let guard = Guard { local: self };

    let guard_count = self.guard_count.get();
    self.guard_count.set(guard_count.checked_add(1).unwrap());

    if guard_count == 0 {
        let global_epoch = self.global().epoch.load(Ordering::Relaxed);
        let new_epoch = global_epoch.pinned();

        // Now we must store `new_epoch` into `self.epoch` and execute a `SeqCst` fence.
        // The fence makes sure that any future loads from `Atomic`s will not happen before
        // this store.
        self.epoch.store(new_epoch, Ordering::Relaxed);
        atomic::fence(Ordering::SeqCst);

        // Increment the pin counter.
        let count = self.pin_count.get();
        self.pin_count.set(count + Wrapping(1));

        // After every `PINNINGS_BETWEEN_COLLECT` try advancing the epoch and collecting
        // some garbage.
        if count.0 % Self::PINNINGS_BETWEEN_COLLECT == 0 {
            self.global().collect(&guard);
        }
    }

    guard
}

三件事:

  1. 重入只计数。 guard_count 是普通的 Cell<usize>:Local 只属于一个线程,不需要原子操作。已经 pin 住的线程再 pin,只把计数加一,什么同步都不做,返回的 Guard 共享同一个本地纪元。
  2. 第一次 pin 才写本地纪元:读全局纪元、打标记、写进本地纪元,再来一道 SeqCst fence。
  3. 顺手回收。 pin_count 在加一之前取值,所以第 1 次、第 129 次、第 257 次……pin 会调 collect(PINNINGS_BETWEEN_COLLECT = 128,第 331 行)。collect 标了 #[cold],让编译器把它放在冷路径上。每次 collect 最多处理 8 袋垃圾,这个「128 次 pin 处理 8 袋」的节奏决定了自动回收的速度上限,下一章量了它。

那道 SeqCst fence 防的是什么

读者 pin 的顺序是「写本地纪元 → 读共享指针」,推进纪元的线程(try_advance)的顺序是「读全局纪元 → SeqCst fence → 逐个读各参与者的本地纪元」(第 228 行)。

要防的是 store → load 重排:如果读者「读共享指针」被提前到「写本地纪元」之前生效,就可能出现这样的交错:读者已经拿到旧对象的指针,但它的本地纪元还没被别人看见;推进者扫一遍参与者,以为它没钉住,连推两次纪元,旧对象的袋子过期被释放,读者再解引用就是 use-after-free。

Release / Acquire 只约束「前面的写不能挪到后面的 Release 写之后」「后面的读不能挪到前面的 Acquire 读之前」,管不到「前面的写」和「后面的读」之间。能禁止 store → load 重排的只有 SeqCst fence(或 SeqCst 读写对)。两边各有一道 SeqCst fence,它们在全局顺序里总有先后:

  • 读者的 fence 在前:推进者 fence 之后读本地纪元,一定能看到读者已经钉住。
  • 推进者的 fence 在前:读者 fence 之后读共享指针,一定能看到推进者 fence 之前已经完成的摘除,读不到旧对象。

push_bag 封袋子之前也有一道 SeqCst fence(第 185 行),作用相同:保证「摘除」在「读全局纪元给袋子盖戳」之前对所有人可见。这三道 fence 是整个回收算法正确性的支点,本库里的内存序把它们归在「SeqCst fence 做 store-load 屏障」一类。

deque 的 steal 也需要一道「读 front → fence → 读 back」的 SeqCst fence,它直接借用 pin 里这一道:没 pin 过就让 epoch::pin() 顺带执行,已经 pin 住(pin 是重入的,不会再执行 fence)就自己补一道。见 Chase-Lev 双端队列。

x86 上的 HACK:用 CAS 当 fence

真正的 pin 在 x86 / x86_64 上走另一条分支(第 412–440 行):

if cfg!(all(
    any(target_arch = "x86", target_arch = "x86_64"),
    not(miri)
)) {
    // HACK: On x86 architectures there are two different ways of executing
    // a `SeqCst` fence.
    //
    // 1. `atomic::fence(SeqCst)`, which compiles into a `mfence` instruction.
    // 2. `_.compare_exchange(_, _, SeqCst, SeqCst)`, which compiles into a `lock cmpxchg`
    //    instruction.
    //
    // Both instructions have the effect of a full barrier, but benchmarks have shown
    // that the second one makes pinning faster in this particular case.  It is not
    // clear that this is permitted by the C++ memory model (SC fences work very
    // differently from SC accesses), but experimental evidence suggests that this
    // works fine.  Using inline assembly would be a viable (and correct) alternative,
    // but alas, that is not possible on stable Rust.
    let current = Epoch::starting();
    let res = self.epoch.compare_exchange(
        current,
        new_epoch,
        Ordering::SeqCst,
        Ordering::SeqCst,
    );
    debug_assert!(res.is_ok(), "participant was expected to be unpinned");
    // We add a compiler fence to make it less likely for LLVM to do something wrong
    // here.  Formally, this is not enough to get rid of data races; practically,
    // it should go a long way.
    atomic::compiler_fence(Ordering::SeqCst);
} else {
    self.epoch.store(new_epoch, Ordering::Relaxed);
    atomic::fence(Ordering::SeqCst);
}

(注释原文的 HACK(…) 括号里有一个名字,这里略去。)

能这么做是因为第一次 pin 时本地纪元一定是 starting()(unpin 时写回的值),CAS 一定成功,效果等于一次 SeqCst 写;在 x86 上 lock cmpxchg 本身就是全屏障。注释自己承认:按 C++ 内存模型这是否允许并不清楚,SC 访问和 SC fence 的语义不同,靠的是「实验证据」;后加的 compiler_fence 也只是「让 LLVM 少出错」,注释明说形式上不足以消除数据竞争。

这段注释是逐步加上去的:2018 年 11 月 20 日连续三个提交 fdc168f0(「point out that using a CAS for a fence is somewhat dubious」)、c291a1b4(提到内联汇编是正确的替代)、d94e5eeb(「add a compiler fence」)。2022 年 ce2b5c35(「Do not use x86 specific fence on Miri」)给条件加上 not(miri):Miri 按 C++ 模型检查,不认这个替代。

本机是 AArch64,走 else 分支。反汇编实验程序可以看到 pin 的路径上是 str(写本地纪元)后紧跟一条 dmb ish,就是 fence(SeqCst) 编译出来的全屏障。

unpin、repin

unpin 是 Guard::drop 调的:

pub(crate) fn unpin(&self) {
    let guard_count = self.guard_count.get();
    self.guard_count.set(guard_count - 1);

    if guard_count == 1 {
        self.epoch.store(Epoch::starting(), Ordering::Release);

        if self.handle_count.get() == 0 {
            self.finalize();
        }
    }
}

最后一个 guard 离开时把本地纪元写回 starting()(未钉住)。这里只要 Release:要保证的是「临界区里的读写」不能挪到「宣布离开」之后,正是 Release 写的语义。handle_count == 0 的分支处理「handle 已经没了,guard 还在」的情况,Collector 与 LocalHandle 讲。

Guard::repin 给长时间持有 guard 的循环用:只有一个 guard 时,把本地纪元直接改成当前全局纪元,相当于 unpin 再 pin,但不调 collect、不做 SeqCst fence。源码注释(第 485–494 行)解释了为什么不需要 fence:新纪元里的访问提前到更新本地纪元之前执行是安全的,最坏只是别的线程晚一点看到新纪元、回收稍微推迟。原因在于方向:这时线程本来就钉在一个更旧的纪元上,旧纪元保护的范围更大。

repin_after(f) 是「暂时完全离开,跑完 f 再回来」:先 acquire_handle 防止 Local 在中途被注销,再 unpin,f 结束后(包括 panic)用一个作用域守卫重新 pin。这之后先前读到的 Shared 都不能再用,签名上 &mut self 保证了这一点:借着 guard 的 Shared 还活着时拿不到 &mut Guard。

默认收集器与线程本地 HANDLE

epoch::pin() 用的是全局默认收集器(default.rs):

fn collector() -> &'static Collector {
    static COLLECTOR: OnceLock<Collector> = OnceLock::new();
    COLLECTOR.get_or_init(Collector::new)
}

thread_local! {
    static HANDLE: LocalHandle = collector().register();
}

pub fn pin() -> Guard {
    with_handle(|handle| handle.pin())
}

fn with_handle<F, R>(mut f: F) -> R
where
    F: FnMut(&LocalHandle) -> R,
{
    HANDLE
        .try_with(|h| f(h))
        .unwrap_or_else(|_| f(&collector().register()))
}

(loom 下 COLLECTOR 换成 loom::lazy_static!,因为 loom 没有对应 OnceLock 的东西。)

  • 每个线程第一次 pin 时注册一个参与者,线程退出时 HANDLE 析构、注销。
  • OnceLock 是 crate 自己的一份(sync/once_lock.rs,88 行,注释说基于当时还不稳定的 std::sync::OnceLock),用来保住 MSRV 1.61。
  • try_with 失败说明线程正在退出、HANDLE 已经析构。这时临时注册一个新的参与者,用完就丢。这条路径是为了「别的线程本地变量的析构函数里调用 pin」不 panic,default.rs 里的测试 pin_while_exiting 专门覆盖它。临时 handle 在表达式结束时就 drop 了,而返回的 Guard 还指着那个 Local:handle_count 归零但 guard_count 不为零,Local 要等 guard drop 时由 unpin 里那个 finalize 分支收尾。

epoch::is_pinned() 也走 with_handle,只读 guard_count > 0。deque 每次 steal 都调它(决定要不要手动补 fence)。

Guard 与 unprotected()

Guard 只有一个字段 local: *const Local。裸指针让它自动成为 !Send + !Sync:guard 代表「这个线程钉住了」,不能交给别的线程。

unprotected() 返回一个 local 为空指针的静态 guard。凡是接受 &Guard 的方法都对空指针做了特判:

操作正常 guardunprotected()
Atomic::load 等返回 Shared<'g, T>同样返回,但 'g 是 'static,什么都不保护
defer / defer_destroy放进本地袋子,等回收立即执行
flush交出本地袋子并回收什么都不做
dropunpin什么都不做

它是 unsafe fn,条件是「被访问的 Atomic 不会被其他线程并发修改」。合法的场景是构造和析构:数据结构的 Drop 里已经独占,没必要 pin(pin 还会推迟回收)。crate 内部 Local::register、List::drop、Queue::new / drop 都这么用。在有并发的地方用它,就等于退回到「摘下即释放」,为什么需要内存回收里那两个错误都会回来。Collector 与 LocalHandle 的 loom 实验把读者换成 unprotected(),loom 在第 21 个交错就报出「旧对象在读者手里时被析构」。

一次 pin 多贵

exp-crossbeam/epoch-rss/ 的 pin_cost:单线程循环 1000 万次,取平均。

操作三次运行(ns/次)
最外层 pin() + drop(写本地纪元、dmb ish、每 128 次一次 collect)3.94、3.98、3.99
已钉住时再 pin() + drop(只改 guard_count)3.96、3.95、3.99
is_pinned()0.54、0.54、0.54

测量时 1 分钟负载 2.99。两种 pin 几乎一样快:单线程、本核没有未完成的写时,dmb ish 在这台机器上几乎不花时间,4 ns 主要花在线程本地变量访问和计数上。这个数只说明「无竞争时 pin 不贵」,不代表多线程下的开销:多线程时 SeqCst fence 要等本核之前的写全部对其他核可见,pin 写的本地纪元所在的缓存行还会被 try_advance 的线程读走,这些在单线程循环里都体现不出来。

发布之后

main 上 2026-07-13 的提交 c8153dbe(「epoch: Fix SB violation when guard used after handle&collector dropped」,没有标 PR 号)没有进入 0.9.21:先 drop Collector 和 LocalHandle、再用还活着的 Guard(比如 guard.flush())时,Guard::drop 里 &*self.local 生成的引用在 finalize 释放 Local 期间仍然活着,违反 Stacked Borrows。修复把 Local::unpin 改成接受裸指针 unsafe fn unpin(this: *const Self),并加了测试 use_guard_after_handle_and_collector_dropped。普通的 epoch::pin() 用法碰不到这条路径。另外 main 上的 default.rs 已经改用标准库的 std::sync::OnceLock,crate 自带的那份 once_lock.rs 不再被默认收集器使用。

小结

  • Epoch 是一个 u64,最低位是「已钉住」标志,每推进一次加 2。
  • 第一次 pin 写本地纪元,紧跟一道 SeqCst fence,和 try_advance、push_bag 里的 SeqCst fence 配对,防止「写本地纪元」和「读共享指针」之间的 store → load 重排。重入的 pin 只给 guard_count 加一。
  • x86 上用一次必然成功的 SeqCst CAS 代替 mfence,源码注释承认它在 C++ 模型下是否合法并不清楚;Miri 下关掉。AArch64 上就是 dmb ish。
  • 第 1 次、此后每 128 次 pin 顺带调一次 collect。
  • 默认收集器是全局 OnceLock,每线程一个 HANDLE;线程退出期间临时注册一个参与者兜底。
  • unprotected() 让 defer 立即执行、什么都不保护,只能用在没有并发的构造和析构里。

💬 关联讨论

发起讨论
加载中...