Rubyのガベージコレクションを理解したい(1)
はじめに
Rubykaigi2025 3日目のセッションでPeter Zhu氏のModular Garbage Collectors in Rubyを拝聴しました。Ruby3.4に含まれるModular GCによりRuby標準と異なるGCをロード可能になったという内容でした。あと、独自のGCを実装する話も。
独自のGCを実装...だいぶ低レイヤな話題で、私が仕事として遭遇する機会はあまり無さそうです。ですがエンジニアとして「普段お世話になっている機能を自作して使う」というのはなかなかアツい話題に感じます。また自作とまではいかなくとも、今後GCのパラメータを調整して最適化したり、アルゴリズムを選択する機会はあるかもしれません。
となるとまずはGCそのものについて、またRubyのデフォルトであるMark-Sweep-Compact
について理解したいなーと思いました。
そもそもGCとは
ガベージコレクション
= Garbage Collection
= GC
です。
不要になったメモリ領域をよしなに開放してくれる機能です。GCがあれば、プログラマは不要なメモリ領域を判断したり、メモリを開放し忘れる心配をすることなく、プログラミングに集中できます。
個人的にGCといえばJavaを連想してしまいますが、実はその歴史はなかなか古く1960年にJohn McCarthy氏により発表されたようです。注釈にGarbage Collection
という言葉が登場していて最高です。
先述した↑のPDFはMark-Sweep(マークアンドスイープ)
によるGCのようです。他のアルゴリズムによるGCもあってReference Counting(参照カウント)
、Semispace Copying(コピーGC)
などはPeter Zhu氏のセッションでもRubyのGCに無いものとして紹介されていました。
セッション資料には分かりやすい図解もあるのでぜひご参照ください。
ちなみにJavaのGCはGarbage-First (G1) Garbage Collector
がデフォルトで、他のアルゴリズムも選択出来るようになっています。やっぱりJavaのGCには気合のようなものを感じます。すごい。
GCを観察してみる
さて、ここまでの話はちょっと調べれば分かることです。もうちょっと具体的にGCを知るためには、実際に動かして観察してみるのが良いでしょう。RubyのGCに関する情報はここにあります。
GC.statというメソッドでGC内部の統計情報が分かります。これを使ってGCの仕事っぷりを見てみましょう。
次のプログラムは、長さ10万
の文字列を100万
個生成して、処理時間とGCの実行回数、生成したオブジェクト数を確認するものです。
require 'benchmark'
def generate_objects
1_000_000.times.map { "x" * 100_000 }
end
def record_gc_stats
stats = GC.stat
{
gc_count: stats[:count], # ガベージコレクションの回数
minor_gc_count: stats[:minor_gc_count], # マイナーGCの回数
major_gc_count: stats[:major_gc_count], # メジャーGCの回数
total_allocated: stats[:total_allocated_objects], # 生成されたオブジェクトの総数
heap_allocated_pages: stats[:heap_allocated_pages], # ヒープに確保されたページ数
malloc_increase_bytes_limit: stats[:malloc_increase_bytes_limit] # メモリ確保の上限
}
end
puts "== GC Enabled =="
stats = nil
time = Benchmark.realtime do
generate_objects
stats = record_gc_stats
end
puts "Time: #{time} seconds"
puts "GC Stats: #{stats}"
実行してみると、私の環境では次の結果が得られました。
$ ruby benchmark_gc.rb
== GC Enabled ==
Time: 31.018653000239283 seconds
GC Stats: {:gc_count=>4091, :minor_gc_count=>2920, :major_gc_count=>1171, :total_allocated=>2052126, :heap_allocated_pages=>2491, :malloc_increase_bytes_limit=>33554432}
GC | 処理時間 | GCの回数 | マイナーGC | メジャーGC | 生成オブジェクト数 | ヒープのページ数 |
---|---|---|---|---|---|---|
Enabled | 31.01 sec | 4,091 | 2,920 | 1,171 | 2,052,126 | 2,491 |
GCが4091回
、ちゃんと動いていることを確認できました。
100万
の文字列を作成したつもりが、生成されたオブジェクトの数が205万
以上あります。文字列を作成する裏でいろんなオブジェクトを作っているのでしょう。多分。
ではGCが動かないとどうなるの?
GCを禁止するためにGC.disableが使えます。さっそくやってみましょう。
さっきのプログラムをちょっと変更して、オブジェクト生成の前にGCを禁止します。変更箇所を抜粋するとこんな感じ。
- puts "== GC Enabled =="
+ puts "== GC Disabled =="
stats = nil
time = Benchmark.realtime do
+ GC.disable
generate_objects
+ GC.enable
stats = record_gc_stats
end
GC禁止バージョン、どんな結果になるのか。早速実行してみます。
$ ruby benchmark_gc_disable.rb
== GC Disabled ==
Time: 15.534041000064462 seconds
GC Stats: {:gc_count=>10, :minor_gc_count=>8, :major_gc_count=>2, :total_allocated=>2052128, :heap_allocated_pages=>4952, :malloc_increase_bytes_limit=>33554432}
GC | 処理時間 | GCの回数 | マイナーGC | メジャーGC | 生成オブジェクト数 | ヒープのページ数 |
---|---|---|---|---|---|---|
Enabled | 31.01 sec | 4,091 | 2,920 | 1,171 | 2,052,126 | 2,491 |
Disabled | 15.53 sec | 10 | 8 | 2 | 2,052,128 | 4,952 |
- なんと、GCの回数が減って、実行時間が31秒から15秒と約半分になりました。
- GC Enabledのときは4000回ほどのGCに15秒くらいかかったんでしょうか。
- 代わりに
heap_allocated_pages
つまり「ヒープに確保されたページ数」が2倍近くに増えました。
また、オブジェクト生成1万回毎にGC Count
、Heap Pages
を計測すると次のグラフが得られました。
GC Enabled
だと定期的にGCが発生しているお陰でヒープサイズはほとんど変動していません。一方GC Disabled
はヒープサイズがうなぎのぼりです。これではいつかメモリから溢れてしまいそうです。
どうやらGCのお陰で、たくさんのオブジェクトを生成してもヒープサイズが小さく保てています(GC有効だと正常に終了するけどGC禁止だとメモリオーバーフローで終了する、みたいなパラメータを探りたかったけど上手く出来ませんでした。残念。)。一方でGCは処理時間に明確な影響を与えるような重い処理だということも分かりました。
Mark-Sweep-Compactの仕組みを少しだけ覗く
Rubyのコードを実行したときにGCが動いていることは確認できました。次はGCがどんな仕組みで動いているのか気になります。
RubyのデフォルトのGCであるMark-Sweep-Compactの概要は、Peter Zhu氏のセッションの資料の11ページ目から図解されています。図だけでも分かりやすいと思います。音声の公開は期待して待ちましょう。
図でイメージが湧いたところで、コードを見てみましょう。
RubyのGCはこちらにあります。1ファイルで約1万行。すごい。けど頑張れば、時間をかければ読めなくもないかも。C分かりませんが。
名前通りの3つのフェーズに分けられそうです。
- Mark : 到達可能なオブジェクトを見つける
https://github.com/ruby/ruby/blob/ce8f7da49e2fea995993b49aa7a26f7640c2e258/gc/default/default.c#L5815 - Sweep : マークされていないオブジェクトを解放
https://github.com/ruby/ruby/blob/ce8f7da49e2fea995993b49aa7a26f7640c2e258/gc/default/default.c#L4134 - Compact : メモリの断片化を解消
https://github.com/ruby/ruby/blob/ce8f7da49e2fea995993b49aa7a26f7640c2e258/gc/default/default.c#L5662
確かにセッション資料のような構成になってました。ここまで分かれば、Mark-Sweep-Compactのフェーズごとにコードを追っていくことが出来るかもしれません。
次回あたりもうすこし詳細に追ってみようと思います!
最後に
ガベージコレクションは60年以上前に誕生して以来、改良されながら多くの言語で使われています。今後も重要な技術で有り続けるものだと思うので、この機におさらいしつつもう少し深堀り出来たらと思って書きました!
次回に続きたい。
Discussion