GC 与零泄漏:旧代的安葬仪式
lease 归零之后发生什么?一条专职线程按固定顺序把旧代拆干净——这一章讲"确认它真的拆干净了"的方法。
本章导读
30.1 引入
热重载的垃圾回收有个天然难题:旧代的死活取决于异步事件的完成时序(连接什么时候断、回调什么时候返回),多 worker 环境下不可预测。XServer 的发展史上真实出现过两种策略:早期版本选择"旧代全泄漏"(不释放,保绝对安全);现行版本进化为"引用归零 + 安全信号 + 延迟释放"——安全信号是旧驱动连接计数归零(所有 Close 回调完成),归零并过宽限后按序释放。演进的原因很简单:泄漏策略下频繁重载会吃光内存。
30.2 reaper 与析构顺序
顺序的含义:你的 ServiceUnit 跑在一切宿主资源拆除之前——清理时宿主设施仍可用;而它又跑在旧代已经不再服务之后——清理不会影响任何请求。这个位置是设计出来的。
30.3 实测案例
仓库历史里记录着这个机制的验收实验(三循环 8080↔8081 换代):同一组端口反复换代三轮,断言——
- 每轮服务全通(请求零失败)
- 6 个旧代全部 finalize(日志计数对齐换代次数)
- 零泄漏(内存报告前后一致)、零崩溃、无 force-free
你自己的服务可以用同样的三段式验收:压测流量下 N 次换代 → 日志 N 次 finalized → RSS 曲线平稳(drill 的采样方式)。这套断言就固化在 lifecycle_reload_test 与 reload_matrix 里(ch06 的基线表)。
30.4 定时器随代作废
GC 顺序里藏着一条对应用重要的推论:旧代的定时器在换代时作废,而不是等 GC。机制上,定时器绑定创建它的那个代(严格说是"正在执行的旧代"的线程局部标记)——换代后旧代的定时器被取消,新代的定时器从新代 Init 里重新挂。
- 结果:定时器永远不会打进错误的代——回调里访问的静态变量一定属于创建它的那代
- 代价:周期任务必须在新代 Init 里重建(ch27 文件监听触发器的坑就是它)
- 在途的那一次触发:已进入执行的回调由该代 lease 保护到完成
30.5 契约
- 顺序线:ServiceUnit → TCC → 驱动 → 配置,不可变。
- 观测线:finalized 日志计数 = 换代次数;force-free 出现即为缺陷。
- 定时器线:绑定创建代;换代作废;新代 Init 重建周期任务。
常见错误
- 症状
- 换代多代后 GC 队列堆积,finalize 日志滞后于换代。
- 原因
- reaper 线程串行执行各代的 Unit——一个 Unit 卡 30 秒,后面全部排队。清理要快(关句柄、刷缓冲),长活留给后台线程并自行管理。
- 症状
- 换代后好几分钟才看到 finalized,怀疑内存泄漏。
- 原因
- 长连接(WS/TAKEOVER)持有旧代 lease——finalize 在等它们结束(ch29 的 lease 表)。先查连接,再谈泄漏。
编程练习
连续换代 5 次,统计 finalized 日志数并与次数对账。
复刻 30.3 实验:压测下三循环换代 + RSS 采样曲线。
旧代挂一个 10s 周期任务(打印代标记);第 3 秒换代,观察旧任务停止、新代重建后继续。
小结
| 要点 | 结论 |
|---|---|
| 策略演进 | 早期全泄漏 → 引用归零 + 安全信号 + 延迟释放 |
| 析构顺序 | ServiceUnit → TCC → 驱动 → 配置(固定) |
| 观测 | finalized 计数对账;force-free = 缺陷信号 |
| 定时器 | 绑定创建代、换代作废、Init 重建 |
- xrt 参考 · memory_debug — 泄漏对账所用的分配报告
- xrt 教程 · 内存调试 — canary / UAF / 事件日志的完整用法