翻译 Understanding Ruby GC through GC.stat

blacklee · 2019年01月09日 · 6361 次阅读

译注:

  • 带有 (???) 表明我对此句的翻译拿捏不准。水平有限。。
  • 「free」这个词拿捏不准就大多保留了原文。我的理解是这样的:「free object」应该是释放了对象(这个是进程内部的操作),如果译掉的话,又可能被理解成释放内存(这是进程和操作系统之家的操作)。

作者:Nate Berkopec,来自speedshop

概要:你有没有想过 Ruby 的 GC 是如何工作的?让我们看看我们能从 Ruby 为我们提供的GC.stat哈希里学到什么。

大多数的 Ruby 程序员都不清楚垃圾回收在运行时是如何工作的:什么触发了它,它运行得多频繁,它收集了什么以及不收集什么。这不完全是个坏事:动态语言(例如 Ruby)中的垃圾回收非常的复杂,而程序员最好只关注编写对用户重要的代码。

但是,偶尔的,你会被 GC 给懵了:它可能运行得太频繁或者又不够,也可能是你的程序使用了大量的内存但你却不知为何。也可能你只是想知道 GC 是如何工作的。

我们学习 CRuby(用 C 写的标准 Ruby 运行时)关于垃圾回收的一个方法是看看它内建的GC模块。如果你还没读过这个模块的文档,那得读一下,它有几个有意思的方法。但现在,我们只看这一个方法:GC.stat

GC.stat输出一组不同数字的哈希,但它们并没有被良好的文档描述,并且其中一些非常容易让人混淆,除非你阅读了相关的 C 代码!不过我帮你读了,现在一起看看GC.stat提供的信息吧。

这是我在一个用 Ruby-2.4.0 刚刚启动的irb会话中执行GC.stat的输出:

{
  :count=>15,
  :heap_allocated_pages=>63,
  :heap_sorted_length=>63,
  :heap_allocatable_pages=>0,
  :heap_available_slots=>25679,
  :heap_live_slots=>25506,
  :heap_free_slots=>173,
  :heap_final_slots=>0,
  :heap_marked_slots=>17773,
  :heap_eden_pages=>63,
  :heap_tomb_pages=>0,
  :total_allocated_pages=>63,
  :total_freed_pages=>0,
  :total_allocated_objects=>133299,
  :total_freed_objects=>107793,
  :malloc_increase_bytes=>45712,
  :malloc_increase_bytes_limit=>16777216,
  :minor_gc_count=>13,
  :major_gc_count=>2,
  :remembered_wb_unprotected_objects=>182,
  :remembered_wb_unprotected_objects_limit=>352,
  :old_objects=>17221,
  :old_objects_limit=>29670,
  :oldmalloc_increase_bytes=>46160,
  :oldmalloc_increase_bytes_limit=>16777216
}

注:Ruby-2.5.1 的输出是一样的

OK,很多东西,这有 25 个没有文档的键值。

首先,我们看看 GC counts

{
  :count=>15,
  # ...
  :minor_gc_count=>13,
  :major_gc_count=>2
}

这几个非常直接。minor_gc_countmajor_gc_count是 Ruby 进程启动之后的各类型 GC 运行次数。万一你不知道,自从 Ruby-2.1 开始就有个2种垃圾回收,major 和 minor。minor GC 只尝试回收「新」的对象——存活时间小于等于 3 次 GC 周期。而 major GC 会尝试回收所有对象,甚至是存活时间超过 3 次 GC 周期。count = minor_gc_count + major_gc_count。如果想了解更多,可以参考我在 FOSDEM 上关于the history of Ruby Garbage Collection的讲解。

出于几个原因跟踪 GC 次数会是有用的。例如,某个特定的后台任务总是触发 GC(以及触发了多少次)。例如,这是一个 Rack 中间件可以记录当服务器处理一个请求时的 GC 次数变化:

class GCCounter
  def initialize(app)
    @app = app
  end

  def call(env)
    gc_counts_before = GC.stat.select { |k,v| k =~ /count/ }
    @app.call(env)
    gc_counts_after = GC.stat.select { |k,v| k =~ /count/ }
    puts gc_counts_before.merge(gc_counts_after) { |k, vb, va| va - vb }
  end
end

如果你的服务是多线程的,那么这个数据不会 100% 的准确,因为其他的线程弄出来的内存压力也可能触发 GC,但这是一个入口。

现在,我们继续看堆数量heap numbers)的统计:

{
  # Page numbers
  :heap_allocated_pages=>63,
  :heap_sorted_length=>63,
  :heap_allocatable_pages=>0,

  # Slots
  :heap_available_slots=>25679,
  :heap_live_slots=>25506,
  :heap_free_slots=>173,
  :heap_final_slots=>0,
  :heap_marked_slots=>17773,

  # Eden and Tomb
  :heap_eden_pages=>63,
  :heap_tomb_pages=>0
}

在这里,堆heap是一个 C 数据结构,有时也称为对象空间ObjectSpace,在其中我们保持了对当前所有活 Ruby 对象的引用,每一个 堆 heap 页面 page 包含了大约 408 个槽 slots,每个 槽 slot 包含了一个活的 Ruby 对象的信息。

  • 首先,你得到了整个 Ruby 对象空间的总大小信息。heap_allocated_pages是当前已分配的堆空间的数字,这些页面 pages 可能完全是空的、完全满的、或者是中间状态。
  • heap_sorted_length是内存中堆的实际大小 —— 如果我们有 10 个堆页面,然后 free 了中间某个页面,那么 堆页面 的 长度length 仍然是 10(因为我们没法在内存中移动页面)。heap_sorted_length总是大于等于实际分配的页面数。
  • 最后,heap_allocatable_pages —— 这是 Ruby 当前拥有的 堆页面大小 的一些(已经分配的 malloced)内存块,我们可以分配一个新的堆页面。如果 Ruby 需要为增加的对象分配新的堆页面,那就会首先使用这个空间。

OK,现在我们拿到了一堆和单个对象的槽 slots有关的数字。heap_available_slots,很明显是堆页面中所有槽的数量 —— GC.stat[:heap_allocated_pages] 恒等于 GC.stat[:heap_available_slots] / GC::INTERNAL_CONSTANTS[:HEAP_PAGE_OBJ_LIMIT]。然后:

  • heap_live_slots是活跃对象数;
  • heap_free_slots是堆页面中空的槽;
  • heap_final_slots是被析构函数 finalizers附着了的对象槽。析构函数是 Ruby 中一类朦胧的特性 —— 它们是对象将被释放时运行的 Procs。例如:ObjectSpace.define_finalizer(self, self.class.method(:finalize).to_proc)
  • heap_marked_slots几乎是旧对象(存活超过 3 次 GC 周期的对象)的数量加上写屏障无保护对象 write barrier unprotected objects(一会细说)的数量。

注:上面两段列表,在原文中是段落形式,但是弄出列表形式似乎好点。

至于 GC.stat 中槽数量的实际使用,如果你遇到内存膨胀问题的话我建议监控heap_free_slots。大量的空对象槽(free slots???)通常预示着你有几个 actions 分配了大量的对象然后又释放了它们,这会不停地增加你的 Ruby 进程的内存。若想了解修复这问题的更多技巧,参考我在 Rubyconf 上关于 Ruby 内存问题的演讲

现在,我们有tomb_pageseden_pageseden_pages是包含至少一个活对象的堆页面。tomb_pages不包含活对象,所以有完全空的对象槽 (free slots???)。Ruby 运行时只释放 tomb_pages 返回操作系统,而eden_pages则永远不被 free。

简单的,还有几个累积的 已分配/已 free 数字

{
  :total_allocated_pages=>63,
  :total_freed_pages=>0,
  :total_allocated_objects=>133299,
  :total_freed_objects=>107793
}

这些数字是进程整个生命周期的累积值 —— 它们不会被重置、减小。根据它们的变量名称就已经很好理解了。

最后,我们还有 垃圾回收阈值 garbage collection thresholds

{
  :malloc_increase_bytes=>45712,
  :malloc_increase_bytes_limit=>16777216,
  :remembered_wb_unprotected_objects=>182,
  :remembered_wb_unprotected_objects_limit=>352,
  :old_objects=>17221,
  :old_objects_limit=>29670,
  :oldmalloc_increase_bytes=>46160,
  :oldmalloc_increase_bytes_limit=>16777216
}

呃,Ruby 开发者的一个主要误解是 GC*何时*被触发。我们可以通过GC.start手动触发 GC,但这不发生于产品线。一些人觉得 GC 是根据某种定时器(例如每隔几秒或几个请求)来运行的,事实并非如此。

  • minor GC 是当缺少空槽时被触发的。Ruby 不会自动 GC 任何对象 —— 它只当空间不足时运行回收。所以当没有free_slots剩下时,Ruby 运行 minor GC —— 标记并且清除所有新对象和记忆集未被写屏障保护的对象。这些术语随后有解释。
  • major GC 将在 minor GC 运行后仍然缺少空槽时被触发,或者是以下 4 个阈值中任何一个被突破了:
    1. oldmalloc
    2. malloc
    3. old object count
    4. 「shady」/「写屏障未保护数」

GC.stat 包含了这四个阈值(限制)和运行时的当前状态。

  • malloc_increase_bytes 指的是 Ruby 为「堆(我们已经讨论过的)」 对象分配的空间。堆页面里的每一个对象槽只有 40 字节(参考GC::INTERNAL_CONSTANTS[:RVALUE_SIZE]),那当对象大小超过 40 字节(比如长字符串)时会发生什么呢?我们为这个对象在其他任何地方malloc空间!如果我们为一个字符串分配了 80 字节的空间,那 malloc_increase_bytes 就将增加 80。当这个数值抵达了限制,就触发了一次 major GC。
  • oldmalloc_increase_bytesmalloc_increase_bytes 类似,但只针对 old 对象。
  • remembered_wb_unprotected_objects已记忆集 remembered set中的部分但没有被写屏障 write-barrier保护 的对象数量。
    1. 写屏障是一个简单的 Ruby 运行时和对象之间的接口,能让我们在对象被创建时追踪它的引用和被引用。C 扩展可以不经过写屏障而创建到对象的引用,所以被 C 扩展引用了的对象被称为「shady」或者「写屏障未保护的」。
    2. 已记忆集是拥有引用新 new对象的老 old对象的列表集合。
  • old_objects 被标记为 老 对象槽的数量。

如果你的问题是 major GC 的次数过多,那追踪这些阈值可能对 debug 有帮助。

我希望这是对 GC.stat 的教育看法 —— 它是个信息丰富的哈希,能用来在需要修复不良 GC 行为时构建临时的 debug 方案。

译注:添加了一个 gc+grouped_stat.rb 文件,可以简单看看


不是非常有把握的翻译:

  • 1.
    • malloc_increase_bytes refers to when Ruby allocates space for objects *outside *of the “heap” we’ve been discussing so far
    • malloc_increase_bytes 指的是 Ruby 为「堆(我们已经讨论过的)」 对象分配的空间
  • 2.
    • remembered_wb_unprotected_objects is a count of objects which are not protected by the write-barrier and are part of the remembered set
    • remembered_wb_unprotected_objects已记忆集 remembered set中的部分但没有被写屏障 write-barrier保护 的对象数量。
  • 3.
    • The write-barrier is simply a interface between the Ruby runtime and an object, so that we can track references to and from the object when they’re created
    • 写屏障是一个简单的 Ruby 运行时和对象之间的接口,能让我们在对象被创建时追踪它的引用和被引用。
  • 4.
    • The part of GC.stat we’re looking at here shows each of those four thresholds (the limit) and the current state of the runtime on the way to that threshold.
    • GC.stat 包含了这四个阈值(限制)和运行时的当前状态
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请 注册新账号