Rust in Everything
基于 crossbeam-epoch 0.9.21

一块内存的回收

主线位置:主线 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。

Local::pin:

  1. guard_count 加一。原来不是 0 就到此为止:pin 是可重入的,同一线程嵌套 pin 不会重复写纪元。
  2. Relaxed 读全局纪元,加上 pinned 位,写进本地纪元。
  3. SeqCst fence:保证之后对任何 Atomic 的读都不会被提前到「写本地纪元」之前。x86 上这一步换成一次 compare_exchange(SeqCst),源码注释自己说「不清楚 C++ 内存模型是否允许」(本库里的内存序 第 ③ 类)。
  4. 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:换一个空袋子,SeqCst fence,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、默认收集器、unprotectedpin 与 Guard
Deferred、Bag、try_advance、is_expired 与它的历史defer 与回收
locals 链表和 SealedBag 队列内部的无锁链表和队列
自定义 Collector、线程退出时的 finalizeCollector 与 LocalHandle
fence 的配对本库里的内存序

小结

  • 纪元是一个整数,最低位是 pinned 标志,推进一步加 2。
  • pin:首次 pin(guard_count 从 0 到 1)时把全局纪元写进本地纪元并发 SeqCst fence(x86 用 CAS 代替);每 128 次 pin 调一次 collect,第一次 pin 也会。
  • defer_destroy 把析构闭包放进 64 格的本地袋子;满了 SeqCst fence 后封上当前全局纪元,进全局无锁队列。
  • collect:所有已 pin 的参与者都在当前纪元时才推进一步;弹出落后至少 2 个纪元的袋子,每次最多 8 个,由调用 collect 的线程执行析构。
  • 长期持有 Guard 会让纪元推进停摆,垃圾无上界。

💬 关联讨论

发起讨论
加载中...