Rust in Everything
基于 crossbeam-epoch 0.9.21

一个线程一直 pin 着,内存只涨不降

主线位置:一块内存的回收,try_advance 的那一步。机制和完整的实验 E3 在 defer 与回收。

flowchart TB
  a["线程 A:pin 在纪元 e,长时间不放"] --> ta["try_advance:A 不在当前纪元,放弃"]
  ta --> st["全局纪元最多到 e + 1"]
  b["其他线程:defer_destroy"] --> bag["垃圾袋盖戳 e 或 e + 1"]
  st --> nf["没有袋子满足「落后两个纪元」"]
  bag --> nf
  nf --> grow["零回收,内存线性增长"]

现象

一个用 epoch 管理节点的结构(自己写的无锁链表、crossbeam-skiplist、deque 扩容换下的旧 buffer),某个线程在 pin() 之后做了一件很慢的事:阻塞 IO、等锁、sleep,或者把 Guard 存进一个长期存活的结构里。之后进程的内存只涨不降,没有报错,析构函数一个都没跑。

常见写法:

let guard = epoch::pin();
let node = head.load(Ordering::Acquire, &guard);
let resp = http_get(url_of(node)); // 慢操作仍在 guard 的作用域里

或者在一个长循环外面 pin 一次,循环里做完所有工作。

实测

实验 E3(exp-crossbeam/epoch-rss/,release,3 次结果完全一致):辅助线程 pin() 后睡眠(hold)或 pin 一下就放开(free);主线程 20 万次「pin、swap 进一个 1 KiB 的新对象、defer_destroy 旧的」:

已退休hold:已析构 / RSShold、每次 flush:已析构 / RSSfree:已析构 / RSS
20 0000 / 22 992 KiB0 / 75 680 KiB19 786 / 2 464 KiB
200 0000 / 211 248 KiB0 / 738 320 KiB199 777 / 2 480 KiB
  • 有一个线程长期 pin 住,20 万个对象一个都没析构,RSS 约 206 MiB,每个对象约 1.05 KiB。
  • 这时每次 flush 让情况更糟:每个只装 1 个对象的 Bag(约 2 KiB)都被推进全局队列,RSS 约 721 MiB。
  • 放开钉住的线程后,主线程要再 pin 50 113 次才回收完(每 128 次 pin 回收 8 袋);flush 版要 320 万次。

没有人长期 pin,回收也有速度上限:不 flush 时每次 pin 平均最多回收约 4 个对象。每次 pin 退休 8 个,结束时只回收了一半;16 个,四分之一(defer 与回收)。

从哪来

  • 全局纪元只有在所有已 pin 的参与者都处在当前纪元时才加一(try_advance)。一个参与者 pin 在纪元 e 不动,全局纪元最多走到 e + 1。
  • 一袋垃圾要等全局纪元比它的戳大 2 才析构,所以在那个参与者 unpin 之前,之后退休的所有对象都过不了期。
  • 这影响的是同一个收集器里的所有线程。默认收集器是全进程一个(pin 与 Guard),所以 A 模块里一个长期 pin 住的线程,会让 B 模块的 skiplist 也不回收。
  • 回收本身也是限速的:每 128 次 pin(PINNINGS_BETWEEN_COLLECT)才 collect 一次,每次最多 8 袋(COLLECT_STEPS),每袋 64 个,平均每次 pin 约 4 个(defer 与回收)。

这是 EBR 的结构性代价,不是 bug:读侧便宜,换来的是垃圾没有上界(为什么需要内存回收、设计取舍)。

怎么改

  • 缩短临界区:pin 只包住「读共享指针、拿到需要的值」那几步。要在慢操作里用的数据,先拷出来(或拿一个引用计数),再 drop guard。Shared<'g> 的生命周期绑在 guard 上,编译器会拦住「带出临界区」的写法(Atomic、Owned、Shared)。
  • 长循环里定期 repin:guard.repin() 等于 unpin 再 pin,让全局纪元有机会前进;循环每隔一段调一次。中间要做慢操作就用 guard.repin_after(|| slow()):执行期间不 pin,结束后重新 pin(pin 与 Guard)。repin 之后,之前读到的 Shared 都失效了,要重新 load。
  • 隔离:对延迟敏感、或者自己会长期 pin 的部分,用自己的 Collector(Collector 与 LocalHandle),别人的长期 pin 不会挡住它的回收,它也挡不住别人。
  • 批量删除之后 flush:一次 pin 里退休了大量对象时,guard.flush() 把本地袋子交出去并收集一次;E3 里每次 pin 退休 8 个、每次都 flush 时,积压只剩 8 个。但前提是没有人长期 pin:有人钉住时 flush 只会把内存翻几倍。
  • 观测:epoch 没有暴露「待回收数量」的公开 API;靠 RSS 曲线和自己在析构里计数判断。

小结

  • 一个长期 pin 住的线程让同一收集器里所有线程的垃圾都过不了期;E3 里 20 万个对象零回收。
  • 这时 flush 是负优化,RSS 从约 206 MiB 涨到约 721 MiB。
  • 不 flush 时回收速度上限约为每次 pin 4 个对象;批量删除后 flush 有用。
  • 缩短临界区、长循环里 repin / repin_after、用独立的 Collector 隔离。

💬 关联讨论

发起讨论
加载中...