深入 Ruby 34:对象分配与 GC
系列导航
导读 · 上一篇:33:进程、超时与取消 · 下一篇:35:Prism、YARV 与字节码 · 完整源码包
内存增长需要先确定测量对象
一个 Ruby 进程的常驻内存上升,可能来自仍被引用的业务对象、频繁分配形成的堆容量、本地库缓冲区、线程栈或 JIT 代码。只看操作系统的 RSS 曲线,无法确定哪类资源增长,更无法直接证明垃圾回收失效。
Taskbook 每次导入都会产生任务 Hash、字符串和标签数组。若处理后不再引用它们,生命周期应当结束;若把所有历史结果保存在缓存里,对象继续存活是程序引用关系的结果。GC 不会猜测“业务已经不需要”,也不会主动删除仍可达的缓存项。
本章用可控缓存建立基线,分别记录累计分配、存活槽位、显式保留对象的内存估计和进程 RSS。目的在于建立诊断顺序,避免尚未找到保留路径就调整 GC 参数。参数调优无法消除一条仍然存在的强引用。
四种数字回答四个问题
GC.stat提供当前实现的统计量。其中累计分配是从进程开始到现在发生过多少对象分配,数字通常持续增加;存活槽位描述某个时刻仍占用的 Ruby 对象槽位。这两者不能相减后直接得到进程内存字节数,因为不同对象还可能持有额外存储。
按需加载 objspace 后,ObjectSpace.memsize_of 可以估计指定对象占用的内存。它适合比较受控对象,但返回值有实现限制,不是整个对象图的递归总和,也不能覆盖所有本地扩展内存。把一个数组的 memsize 当成数组和所有元素的总大小,会漏掉被引用对象。
RSS 是操作系统统计的常驻页集合,单位与工具相关。本实验使用 macOS 的 ps -o rss=,记录为 KiB 口径的数值。它包含 Ruby 堆以外的成本,回收对象后也不保证立即下降。分配器仍可保留页面供后续使用,页面中其他存活对象也会阻止整页释放。
GC 次数则描述回收活动。次数多不一定是问题:短命对象很多时,频繁小回收可能维持较低的存活量。次数少也不自动意味着高效:持续保留对象会让内存增加。诊断需要把单位、采样时点和业务处理量一并记录。
可控缓存实验
从仓库根目录运行:
1 | |
脚本先记录空缓存,再连续三轮各追加五千个字符串,最后清空缓存。每轮显式执行完整标记与即时清扫,以减少“只是还没触发一次回收”造成的解释歧义。这种做法用于实验分隔阶段,不能直接搬到每个请求中强制执行。
1 | |
字符串包含轮次、编号和固定长度内容,避免所有条目只是同一个对象引用。缓存本身也会扩容,因此条目内存之和并不是整个缓存总成本;实验字段刻意叫 cache_bytes,并在解释中限定为条目估计。
可靠断言包括缓存最终保留一万五千条、累计分配有所增加、条目估计大于零,以及 clear 后显式缓存条目和对应求和归零。脚本不要求 RSS 下降到基线,也不要求全局 live slots 恰好减少一万五千,因为快照与运行库本身也会分配对象。
这样保留了一个重要区别:业务容器已经释放引用,是本实验可直接控制的事实;进程是否把页面返还给操作系统,是另一个层次的行为。若把后者写成硬断言,同样正确的程序可能因为分配器策略不同而被误判。
从增长模式查到保留关系
若相同输入反复执行后,显式缓存条目持续增加且没有上限,先检查淘汰策略。缓存可能按唯一用户、文件名或查询参数累积,即使每个值很小,总空间也会随键数量增长。把值冻结不能减少键数量,也不能解决生命周期设计。
若业务容器规模稳定,但累计分配增长快、GC 次数随请求增加,检查临时中间数组、字符串拼接和重复转换。此时优化目标是每次业务操作的分配量,而非想办法让累计计数下降。累计指标不会因为优化自动归零,应比较相同工作量下的差值。
若 Ruby 存活对象趋于稳定而 RSS 持续上升,需要进一步检查本地扩展、文件映射、线程数量和分配器碎片。ObjectSpace 看不到全部成本,无法排除原生内存问题。此时继续盯着 Ruby Hash 数量可能浪费时间,应该引入对应层次的测量工具。
若只有高峰后 RSS 保持较高、后续请求不再增长,可能是容量保留。还需在更长时间与更大输入上观察,但不能仅因高水位未回落就宣布泄漏。相同业务负载下的增长斜率,比某一时刻比启动时高多少更有解释力。
GC 机制与实验边界
CRuby 默认 GC 通过可达性判断对象是否仍需要保留。代际策略利用对象存活时间差异,减少每次都扫描所有对象的成本;增量标记和清扫时机又会影响暂停分布。具体统计键与策略属于实现,版本升级后应查对应文档,不能当成 Ruby 语言层面的固定接口。
Ruby 3.4 还允许特定构建使用模块化 GC。本实验输出 GC.config 中的实现信息,以说明到底观察了哪个回收器;同一个 Ruby 版本号不保证编译选项与回收器完全相同。比较机器时,解释器版本、构建配置和工作负载都需要一致。
关闭 GC 会改变整个测量环境。它有时便于隔离分配成本,却可能迅速放大内存,并消除真实请求路径中的回收开销。若使用这类实验,必须在 ensure 恢复,并把“禁用 GC”写进结果条件。本章不通过禁用回收器制造更好看的耗时。
压缩对象也不等于解决业务保留。压缩可改变对象布局,但仍可达的对象继续存在;本地扩展是否支持移动对象,还涉及实现兼容性。因此出现增长时,先找保留者,再判断是否需要更低层的布局分析。
采样本身也会分配
生成 JSON、创建快照 Hash、运行外部 ps 命令都会产生额外工作。要比较微小对象数量差异,应把采样移出被测区间,或复用统计容器,减少探针影响。实验明确记录的是包含运行框架背景活动的进程,不把每一个额外对象都归因于某行业务代码。
内存字节与对象数量也可能变化相反。一个大字符串只算一个对象,许多小包装对象则可能占用更多槽位但更少字节。同时保存对象数和估计字节,有助于判断究竟是数量问题还是载荷问题。文件读取若一次读入几十兆文本,不会因为对象数量少就变得便宜。
面向生产的采样还需考虑频率和敏感数据。完整对象转储可能包含令牌、输入正文和用户标识,不能默认写入公开证据。这里仅使用合成字符串和统计摘要;诊断真数据时应限制输出范围,确保日志不会成为第二份长期缓存。
本次曲线的具体含义
本次默认 GC 下,缓存条目依次为零、五千、一万、一万五千、零;条目内存估计最高为二百四十万字节。清空前后的 RSS 均为二万零一百七十六 KiB,存活槽位却从三万三千零四十六降到一万八千零四十八。这个组合直接说明“对象释放后 RSS 必须立即下降”不适合作为验收条件。
累计分配从三万八千五百增加到九万八千五百九十一,清空后不会倒退。累计回收增加、缓存计数归零与存活槽位下降相互支持,但仍不把全局槽位差精确归因于缓存,因为统计和JSON准备也会产生对象。
在受限沙箱中,ps 可能被禁止执行。此时不能输出零冒充进程内存,也不能默默删除该列后宣称完整验收;需要允许读取自身进程统计的环境重新运行。随文日志来自具备该读取权限的完整运行,初次权限失败未被用作通过证据。
练习与验收
把缓存改成最多保留一千条,重复三轮后检查条目数量保持上限,再比较累计分配是否仍增加。这个练习能显示容量受控与分配减少是不同改动,不能用一个指标代替另一个。
随后在 clear 前把其中一个字符串存进另一个数组,清空原缓存后检查该对象仍可访问。解释其保留路径,并讨论闭包、全局集合和长期请求上下文如何形成类似关系。验收应指向具体引用,不只说“GC 没回收”。
| 观察 | 首要问题 | 后续检查 |
|---|---|---|
| 缓存条目持续增长 | 是否有容量与生命周期 | 键空间和淘汰策略 |
| 分配高但存活稳定 | 临时对象是否过多 | 同工作量分配差值 |
| Ruby对象稳定而RSS增长 | 原生或页面层成本 | 扩展、线程、分配器 |
| clear后RSS不降 | 页面是否仍被保留 | 后续稳定负载趋势 |
