一个线程一直 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:已析构 / RSS | hold、每次 flush:已析构 / RSS | free:已析构 / RSS |
|---|---|---|---|
| 20 000 | 0 / 22 992 KiB | 0 / 75 680 KiB | 19 786 / 2 464 KiB |
| 200 000 | 0 / 211 248 KiB | 0 / 738 320 KiB | 199 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隔离。
💬 关联讨论
发起讨论加载中...