一块内存的回收
主线位置:主线 3 的全程。本页只画时序和关键数值,细节在 04 部分:pin 与 Guard、defer 与回收、内部的无锁链表和队列。
问题是这样的:一个无锁结构里,线程 A 把节点从链上摘下来,这时线程 B 可能刚读到指向这个节点的指针、正要解引用。A 不能立刻 free。基于纪元的回收(EBR)的回答是:所有线程在访问共享指针之前先「登记」(pin),登记时记下当前的全局纪元;摘下的节点不马上释放,而是贴上摘下时的纪元,等全局纪元往前推了两步,就能确定没有哪个线程还拿着它。
use crossbeam_epoch::{self as epoch, Atomic, Owned};
use std::sync::atomic::Ordering;
let a = Atomic::new(1);
let guard = &epoch::pin(); // 1. pin
let old = a.swap(Owned::new(2), Ordering::AcqRel, guard); // 2. 摘下旧节点
unsafe { guard.defer_destroy(old) }; // 3. 推迟释放
drop(guard); // 4. unpin
// ...之后某次 pin 触发 collect,旧节点在这里真正被 drop
sequenceDiagram participant T as 线程 A participant L as Local(A 的参与者) participant G as Global:epoch、locals、queue participant U as 其他参与者 T->>L: epoch::pin,HANDLE 取线程本地 LocalHandle L->>G: Relaxed 读全局纪元 e L->>L: 本地纪元写 e 加 pinned 位,SeqCst fence T->>T: Atomic::swap 取回旧指针 old T->>L: defer_destroy(old),进本地 Bag L->>G: Bag 满 64 个:SeqCst fence,读全局纪元,封袋进 queue L->>G: 每 128 次 pin 调一次 collect G->>U: try_advance:遍历 locals,已 pin 的都在 e 才能推进 G->>G: 全局纪元 e 加一(Release) G->>G: 弹出 global 减 bag.epoch 大于等于 2 的袋子,最多 8 个,执行析构 T->>L: drop(guard):本地纪元写回 unpinned(Release)
纪元怎么表示
Epoch 是一个整数(有 64 位原子时是 u64)。最低位是「pinned」标志,纪元本身存在高位,所以「推进一步」是加 2(successor):
| 值(二进制末三位) | 含义 |
|---|---|
000 = 0 | 起始纪元,未 pin |
001 = 1 | 纪元 0,已 pin |
010 = 2 | 纪元 1,未 pin |
101 = 5 | 纪元 2,已 pin |
wrapping_sub 先去掉右边那个数的标志位再相减、右移一位,得到的是「相差几个纪元」的有符号数,能处理回绕。
1. pin:登记当前纪元
epoch::pin() 用的是默认收集器:一个全局 OnceLock<Collector>,加一个线程本地 HANDLE: LocalHandle,线程第一次 pin 时在全局的参与者链表 locals 里插一个 Local。线程析构过程中 HANDLE 已经不可用了,with_handle 就临时 register() 一个用完即弃的 handle。
guard_count加一。原来不是 0 就到此为止:pin 是可重入的,同一线程嵌套 pin 不会重复写纪元。Relaxed读全局纪元,加上 pinned 位,写进本地纪元。SeqCstfence:保证之后对任何Atomic的读都不会被提前到「写本地纪元」之前。x86 上这一步换成一次compare_exchange(SeqCst),源码注释自己说「不清楚 C++ 内存模型是否允许」(本库里的内存序 第 ③ 类)。pin_count加一,count % 128 == 0时调collect。pin_count从 0 开始,所以线程第一次 pin 就会顺手回收一次。
返回的 Guard 只是一个 *const Local。它的生命周期就是「这段时间内读到的指针保证有效」的范围:Atomic::load(ord, &guard) 返回的 Shared<'g, T> 借用着 guard,编译器不让它活得比 guard 久。
2、3. 摘下节点,推迟释放
Atomic::swap 就是对内部 AtomicUsize 的一次 swap,返回旧值包成 Shared<'g, T>。此刻其他线程可能还拿着这个旧指针。
defer_destroy(old) 就是 defer_unchecked(move || ptr.into_owned()):把「把它变回 Owned 再 drop」这个闭包交给 Local::defer:
pub(crate) unsafe fn defer(&self, mut deferred: Deferred, guard: &Guard) {
let bag = self.bag.with_mut(|b| &mut *b);
while let Err(d) = bag.try_push(deferred) {
self.global().push_bag(bag, guard);
deferred = d;
}
}
- 闭包被装进
Deferred:三个usize放得下的闭包直接内联,放不下才装箱(deferred.rs的DATA_WORDS = 3)。 - 本地
Bag能放 64 个(MAX_OBJECTS;sanitizer 和 Miri 下是 4)。满了就push_bag:换一个空袋子,SeqCstfence,Relaxed读全局纪元,把旧袋子封成SealedBag{epoch, bag}推进全局的 Michael-Scott 无锁队列。 - 用
unprotected()的假 guard 调defer_*时,没有Local,闭包当场执行。
collect:推进纪元,回收袋子
Global::collect 先 try_advance,再从队列头弹出过期的袋子,最多 8 个(COLLECT_STEPS):
fn is_expired(&self, global_epoch: Epoch) -> bool {
// A pinned participant can witness at most one epoch advancement. Therefore, any bag that
// is within one epoch of the current one cannot be destroyed yet.
global_epoch.wrapping_sub(self.epoch) >= 2
}
try_advance:读全局纪元 e,SeqCst fence,遍历 locals 链表。只要有一个参与者是 pinned 且本地纪元不等于 e,就放弃,返回 e。遍历被并发的删除打断(IterError::Stalled)也放弃。全都跟上了,fence(Acquire),把全局纪元写成 e + 1(Release)。
为什么是「差 2」,源码只有上面那一句注释:一个已 pin 的参与者在 pin 着的期间最多能看到全局纪元推进一次(推进的前提是所有已 pin 的参与者都已经在当前纪元),所以封袋时读到的纪元和「还可能拿着这些对象的线程」pin 的纪元之间最多差一步,再留一步余量。这个阈值有过一次来回:2020 年 5 月的 #416 改成「推进三次才回收」,5 天后 #517 又改回 2。两种论证放在 defer 与回收 里讲,本页不展开。
袋子被 drop 时,Bag::drop 逐个调用里面的 Deferred,旧节点在这里才真正析构。执行析构的是调用 collect 的那个线程,不一定是摘下节点的线程。
4. unpin
Guard drop 时调 Local::unpin:guard_count 减一,减到 0 时把本地纪元写回「未 pin」(Release)。如果此时 handle 也已经全部 drop(线程正在退出),顺便 finalize:把本地袋子推到全局,从 locals 里摘掉自己。
由此可以直接推出 EBR 的那个弱点:一个线程只要一直拿着 Guard 不放,它的本地纪元就一直停在 e,try_advance 永远推不过去,所有之后封的袋子都不会过期,内存只涨不降。计划中的实验 E3 测的就是这个,结果在 defer 与回收。
每一步在哪一章
| 步 | 本卷 |
|---|---|
| 为什么需要 EBR,和引用计数、hazard pointer 比 | 为什么需要内存回收 |
Atomic、Owned、Shared、标签指针 | Atomic、Owned、Shared |
Epoch、Local::pin、repin、默认收集器、unprotected | pin 与 Guard |
Deferred、Bag、try_advance、is_expired 与它的历史 | defer 与回收 |
locals 链表和 SealedBag 队列 | 内部的无锁链表和队列 |
自定义 Collector、线程退出时的 finalize | Collector 与 LocalHandle |
| fence 的配对 | 本库里的内存序 |
小结
- 纪元是一个整数,最低位是 pinned 标志,推进一步加 2。
pin:首次 pin(guard_count从 0 到 1)时把全局纪元写进本地纪元并发SeqCstfence(x86 用 CAS 代替);每 128 次 pin 调一次collect,第一次 pin 也会。defer_destroy把析构闭包放进 64 格的本地袋子;满了SeqCstfence 后封上当前全局纪元,进全局无锁队列。collect:所有已 pin 的参与者都在当前纪元时才推进一步;弹出落后至少 2 个纪元的袋子,每次最多 8 个,由调用collect的线程执行析构。- 长期持有
Guard会让纪元推进停摆,垃圾无上界。