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
}
三件事:
- 重入只计数。
guard_count是普通的Cell<usize>:Local只属于一个线程,不需要原子操作。已经 pin 住的线程再 pin,只把计数加一,什么同步都不做,返回的Guard共享同一个本地纪元。 - 第一次 pin 才写本地纪元:读全局纪元、打标记、写进本地纪元,再来一道 SeqCst fence。
- 顺手回收。
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 的方法都对空指针做了特判:
| 操作 | 正常 guard | unprotected() |
|---|---|---|
Atomic::load 等 | 返回 Shared<'g, T> | 同样返回,但 'g 是 'static,什么都不保护 |
defer / defer_destroy | 放进本地袋子,等回收 | 立即执行 |
flush | 交出本地袋子并回收 | 什么都不做 |
| drop | unpin | 什么都不做 |
它是 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 立即执行、什么都不保护,只能用在没有并发的构造和析构里。