最小 EBR
主线位置:主线 3「一块内存的回收」的缩小版。
pin对照 pin 与 Guard,退休和推进对照 defer 与回收。代码在exp-crossbeam/hands-on/src/mini_ebr.rs。
flowchart TB pin["pin(slot):读全局纪元 e,本地 = (e << 1) | 1,SeqCst fence"] --> read["读共享指针,用它"] read --> unpin["Guard drop:本地 = 0(Release)"] retire["retire:拿锁,读全局纪元 e,放进 bags[e % 3]"] --> adv adv["try_advance:拿锁,读 e,SeqCst fence,所有已 pin 的本地纪元都是 e?"] -->|是| step["全局 = e + 1;取出 bags[(e + 2) % 3],即纪元 e - 1 的那一袋,放锁后释放"] adv -->|否| no["返回 false"]
数据结构
pub struct Ebr {
global: AtomicUsize,
/// 0 = not pinned; (epoch << 1) | PINNED = pinned in `epoch`.
locals: [AtomicUsize; SLOTS],
bags: Mutex<[Vec<Garbage>; 3]>,
fences: Fences,
}
和 crossbeam 的对应关系:
| 这里 | crossbeam-epoch | 简化了什么 |
|---|---|---|
global | Global::epoch | 纪元每次加 1,「已 pin」标志放在本地纪元里而不是纪元值的最低位 |
locals: [AtomicUsize; 4],调用方自己选槽位 | Local 的无锁侵入式链表(内部的无锁链表和队列) | 固定 4 个参与者,没有注册和注销 |
bags: Mutex<[Vec<Garbage>; 3]> | 每线程的本地 Bag + 全局 MS 队列 + 封袋时盖纪元戳 | 一把全局锁;三个袋子按 epoch % 3 轮换,相当于戳 |
fences | 无 | 只为了让测试能逐个去掉 fence |
Garbage 是一个裸指针加一个释放函数(free_box::<T> 把它还原成 Box<T> 再 drop),相当于 crossbeam 的 Deferred 只支持 defer_destroy 一种。
pin
pub fn pin(&self, slot: usize) -> Guard<'_> {
let e = self.global.load(Ordering::Relaxed);
self.locals[slot].store((e << 1) | PINNED, Ordering::Relaxed);
// store(local) -> load(shared pointer) must not be reordered: pairs with
// the fence in `try_advance` (store-buffering otherwise).
if self.fences.pin {
fence(Ordering::SeqCst);
}
Guard { ebr: self, slot }
}
和 crossbeam 的 Local::pin 一样:Relaxed 读全局、Relaxed 写本地、一道 SeqCst fence(pin 与 Guard)。Guard drop 时把本地纪元 Release 写成 0。
retire 与 try_advance
pub unsafe fn retire<T>(&self, _guard: &Guard<'_>, ptr: *mut T) {
// The epoch is read under the same lock `try_advance` holds, so a bag is
// stamped either before an advance (and that advance happens after the
// unlink) or after it (and gets the newer epoch). No fence needed here.
let mut bags = self.bags.lock().unwrap();
let e = self.global.load(Ordering::Relaxed);
bags[e % 3].push(Garbage { ptr: ptr as *mut (), free: free_box::<T> });
}
pub fn try_advance(&self) -> bool {
let mut bags = self.bags.lock().unwrap();
let e = self.global.load(Ordering::Relaxed);
if self.fences.advance {
fence(Ordering::SeqCst);
}
for local in &self.locals {
let l = local.load(Ordering::Relaxed);
if l & PINNED != 0 && (l >> 1) != e {
return false;
}
}
fence(Ordering::Acquire);
let new = e + 1;
// bags[(new + 1) % 3] == bags[(e - 1) % 3]: retired at epoch new - 2
let expired = std::mem::take(&mut bags[(new + 1) % 3]);
self.global.store(new, Ordering::Release);
drop(bags);
for g in expired {
unsafe { (g.free)(g.ptr) };
}
true
}
规则和 crossbeam 相同:所有已 pin 的参与者都在当前纪元 e 才推进;在纪元 s 退休的垃圾,等全局纪元到 s + 2 才释放(crossbeam 的 global - bag.epoch >= 2,defer 与回收)。三个袋子轮换正好实现这一点:推进到 new 时清空的 bags[(new + 1) % 3] 就是 bags[(new - 2) % 3]。
retire 要求调用方已 pin(参数里的 &Guard):这保证在它放进 bags[e % 3] 之前,全局纪元最多再走一步,不会走到把这个袋子清掉的 e + 2。
为什么 retire 里没有 fence
crossbeam 的 push_bag 在读全局纪元给袋子盖戳之前有一道 SeqCst fence。这里没有,因为读纪元和推进纪元都在同一把锁里:要么这次 retire 的临界区在某次推进之前,那么摘链(swap)先于这次推进对全局纪元的 Release 写,之后读到新纪元的读者经 pin 里的 fence 与它同步,一定看得到摘链;要么在推进之后,袋子盖的就是推进后的纪元,晚一个纪元释放。crossbeam 的封袋走无锁的 MS 队列,没有这把锁可借,才需要那道 fence。
下面的 loom 实验验证了这一点,也顺带说明了 fence 放在哪里的问题。
loom 检查
tests/ebr_loom.rs,--features loom 时 mini_ebr 的原子类型、fence、Mutex 都换成 loom 的。模型(照 Collector 与 LocalHandle 里 E5 第二部分的写法):
- 读者:
pin(1),Acquire读共享指针;如果读到的是旧节点,断言旧节点的「已释放」标志还是假。读者不解引用,坏的情况表现为断言失败,而不是真的读已释放内存。 - 写者:
pin(0),swap进新节点,retire旧节点,unpin;然后try_advance三次(够从退休纪元走两步)。 - 两线程模型里写者也负责推进;三线程模型里写者只
swap+retire,推进由主线程做两次。
fn model(fences: Fences, pin_reader: bool) {
loom::model(move || {
let ebr = Arc::new(Ebr::with_fences(fences));
// ... old / new 两个节点,shared = AtomicPtr::new(old)
let reader = thread::spawn(move || {
let guard = if pin_reader { Some(ebr.pin(1)) } else { None };
let p = shared.load(Ordering::Acquire);
if p as usize == old_addr {
assert!(!old_freed.load(Ordering::Acquire), "old node freed while a reader still held it (execution {iter})");
}
drop(guard);
});
{
let guard = ebr.pin(0);
let prev = shared.swap(new, Ordering::AcqRel);
unsafe { ebr.retire(&guard, prev) };
}
for _ in 0..3 {
ebr.try_advance();
}
reader.join().unwrap();
});
}
(片段省略了节点构造、Arc 克隆和收尾释放,完整的在测试文件里。)LOOM_MAX_PREEMPTIONS=2,release:
| 模型 | 改动 | 结果 |
|---|---|---|
| 两线程 | 无 | 188 个交错全部通过 |
| 三线程 | 无 | 3 727 个交错全部通过 |
| 两线程 | 读者不 pin | 第 2 个交错断言失败 |
| 两线程 | 去掉 pin 里的 fence | 第 2 个交错断言失败 |
| 两线程 | 去掉 try_advance 里的 fence | 第 2 个交错断言失败 |
| 三线程 | 去掉 pin 里的 fence | 第 4 452 个交错断言失败 |
| 三线程 | 去掉 try_advance 里的 fence | 第 4 360 个交错断言失败 |
LOOM_MAX_PREEMPTIONS=3 时两个无改动的模型分别是 457 和 29 237 个交错,全部通过;反向的五种改动同样全部失败(三线程的两种在第 35 842、35 094 个交错)。失败时的交错数可以比通过时的总数大:去掉 fence 以后读操作能读到的值更多,状态空间本身变大了。
两道 fence 缺一不可,是标准的 store-buffering 形状:读者「写本地纪元,再读共享指针」,推进者「(经锁)看到摘链,再读本地纪元」。两边各一道 SeqCst fence,才能排除「读者读到旧指针、推进者又读到读者未 pin」同时发生。
一个没有被抓到的版本
这一页的代码最初在 retire 里也放了一道 SeqCst fence(照 crossbeam 的 push_bag)。那一版在两线程模型下去掉 try_advance 的 fence,loom 跑完 584 个交错没有报错。原因是两线程模型里写者自己也是推进者:retire 里那道 fence 恰好位于「摘链」和「读本地纪元」之间、在同一个线程上,起了和推进者 fence 一样的作用。去掉 retire 的 fence 之后,同样的改动在第 2 个交错就失败了;三线程模型(推进者是另一个线程)则不管 retire 有没有 fence 都能抓到。
这说明两件事:反向验证的结论依赖模型的形状,一个改动「没被抓到」不等于它安全;以及 loom 自己也写明了它模拟不了某些跨变量的重排(loom 0.7.2 文档「Relaxed Memory Ordering」一节),所以「loom 通过」只是在它能模拟的范围内通过。
多线程测试
tests/ebr_std.rs 不用 loom,用真线程:3 个读者循环「pin、读指针、解引用并检查节点的 alive 标志、unpin」,1 个写者 20 万次「pin、换新节点、retire、unpin、try_advance」。读者共读了约 570 万次,没有一次读到已释放的节点;结束时全局纪元约 18.7 万,20 万个节点里 199 999 个已经释放(剩下的一个在最后两个纪元的袋子里),Ebr drop 时其余全部释放,计数正好 200 001(含最后仍挂在共享指针上的那个)。这个测试只能说明「没有撞上」,正确性的论证靠上面的 loom。
省掉了什么
| 省掉的 | crossbeam 怎么做 |
|---|---|
| 固定槽位 | 线程本地 HANDLE 自动注册,Local 挂在无锁链表上,线程退出时 finalize(Collector 与 LocalHandle) |
| 重入的 pin | guard_count,只有最外层 pin 写本地纪元 |
| 退休要拿全局锁 | 本地袋子攒满 64 个才封袋,进无锁 MS 队列;所以封袋前要那道 SeqCst fence |
| 谁来推进 | 每 128 次 pin 顺带 collect 一次,每次最多 8 袋(defer 与回收) |
只有 Box<T> 的释放 | Deferred 内联 3 个字的任意闭包 |
| 指针类型 | Atomic / Owned / Shared<'g>,Shared 的生命周期绑在 guard 上,编译期挡住「带出临界区」(Atomic、Owned、Shared) |
这里的 retire 是 unsafe fn、Guard 也不限制读到的裸指针活多久:调用方要自己保证「只在 guard 活着时用读到的指针」。crossbeam 用 Shared<'g> 把这条规则交给了编译器。
小结
- 一个全局纪元、每个参与者一个本地纪元、三个轮换的垃圾袋,就是 EBR 的全部骨架:已 pin 的都在当前纪元才推进,退休满两个纪元才释放。
- 读侧只有「写本地纪元 +
SeqCstfence」;推进侧在读所有本地纪元前也要一道SeqCstfence,两边配成 store-buffering 的一对。 - 退休和推进共用一把锁时,退休不需要 fence;crossbeam 的封袋是无锁的,所以需要。
- loom:两线程 188、三线程 3 727 个交错通过;读者不 pin、去掉任一道 fence 都会被抓到。一个早期版本「没被抓到」的原因是模型里写者兼推进者,fence 恰好在同一线程上。