🙌

Rubyのガベージコレクションを理解したい(1)

に公開

はじめに

Rubykaigi2025 3日目のセッションでPeter Zhu氏Modular Garbage Collectors in Rubyを拝聴しました。Ruby3.4に含まれるModular GCによりRuby標準と異なるGCをロード可能になったという内容でした。あと、独自のGCを実装する話も。

https://rubykaigi.org/2025/presentations/peterzhu2118.html

https://www.ruby-lang.org/ja/news/2024/12/12/ruby-3-4-0-rc1-released/

https://bugs.ruby-lang.org/issues/20860

独自のGCを実装...だいぶ低レイヤな話題で、私が仕事として遭遇する機会はあまり無さそうです。ですがエンジニアとして「普段お世話になっている機能を自作して使う」というのはなかなかアツい話題に感じます。また自作とまではいかなくとも、今後GCのパラメータを調整して最適化したり、アルゴリズムを選択する機会はあるかもしれません。

となるとまずはGCそのものについて、またRubyのデフォルトであるMark-Sweep-Compactについて理解したいなーと思いました。

そもそもGCとは

ガベージコレクション = Garbage Collection = GCです。
不要になったメモリ領域をよしなに開放してくれる機能です。GCがあれば、プログラマは不要なメモリ領域を判断したり、メモリを開放し忘れる心配をすることなく、プログラミングに集中できます。
個人的にGCといえばJavaを連想してしまいますが、実はその歴史はなかなか古く1960年にJohn McCarthy氏により発表されたようです。注釈にGarbage Collectionという言葉が登場していて最高です。

https://www-formal.stanford.edu/jmc/recursive.pdf

先述した↑のPDFはMark-Sweep(マークアンドスイープ)によるGCのようです。他のアルゴリズムによるGCもあってReference Counting(参照カウント)Semispace Copying(コピーGC)などはPeter Zhu氏のセッションでもRubyのGCに無いものとして紹介されていました。
セッション資料には分かりやすい図解もあるのでぜひご参照ください。

https://blog.peterzhu.ca/assets/rubykaigi_2025_slides.pdf

ちなみにJavaのGCはGarbage-First (G1) Garbage Collectorがデフォルトで、他のアルゴリズムも選択出来るようになっています。やっぱりJavaのGCには気合のようなものを感じます。すごい。

https://docs.oracle.com/en/java/javase/24/gctuning/available-collectors.html

GCを観察してみる

さて、ここまでの話はちょっと調べれば分かることです。もうちょっと具体的にGCを知るためには、実際に動かして観察してみるのが良いでしょう。RubyのGCに関する情報はここにあります。

https://docs.ruby-lang.org/ja/latest/class/GC.html

GC.statというメソッドでGC内部の統計情報が分かります。これを使ってGCの仕事っぷりを見てみましょう。

次のプログラムは、長さ10万の文字列を100万個生成して、処理時間とGCの実行回数、生成したオブジェクト数を確認するものです。

benchmark_gc.rb
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を禁止します。変更箇所を抜粋するとこんな感じ。

benchmark_gc_disable
- 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 CountHeap Pagesを計測すると次のグラフが得られました。

GC Enabledだと定期的にGCが発生しているお陰でヒープサイズはほとんど変動していません。一方GC Disabledはヒープサイズがうなぎのぼりです。これではいつかメモリから溢れてしまいそうです。

どうやらGCのお陰で、たくさんのオブジェクトを生成してもヒープサイズが小さく保てています(GC有効だと正常に終了するけどGC禁止だとメモリオーバーフローで終了する、みたいなパラメータを探りたかったけど上手く出来ませんでした。残念。)。一方でGCは処理時間に明確な影響を与えるような重い処理だということも分かりました。

Mark-Sweep-Compactの仕組みを少しだけ覗く

Rubyのコードを実行したときにGCが動いていることは確認できました。次はGCがどんな仕組みで動いているのか気になります。
RubyのデフォルトのGCであるMark-Sweep-Compactの概要は、Peter Zhu氏のセッションの資料の11ページ目から図解されています。図だけでも分かりやすいと思います。音声の公開は期待して待ちましょう。

https://x.com/peterzhu2118/status/1913572538229330254

図でイメージが湧いたところで、コードを見てみましょう。

RubyのGCはこちらにあります。1ファイルで約1万行。すごい。けど頑張れば、時間をかければ読めなくもないかも。C分かりませんが。
https://github.com/ruby/ruby/blob/master/gc/default/default.c

名前通りの3つのフェーズに分けられそうです。

  1. Mark : 到達可能なオブジェクトを見つける
    https://github.com/ruby/ruby/blob/ce8f7da49e2fea995993b49aa7a26f7640c2e258/gc/default/default.c#L5815
  2. Sweep : マークされていないオブジェクトを解放
    https://github.com/ruby/ruby/blob/ce8f7da49e2fea995993b49aa7a26f7640c2e258/gc/default/default.c#L4134
  3. Compact : メモリの断片化を解消
    https://github.com/ruby/ruby/blob/ce8f7da49e2fea995993b49aa7a26f7640c2e258/gc/default/default.c#L5662

確かにセッション資料のような構成になってました。ここまで分かれば、Mark-Sweep-Compactのフェーズごとにコードを追っていくことが出来るかもしれません。
次回あたりもうすこし詳細に追ってみようと思います!

最後に

ガベージコレクションは60年以上前に誕生して以来、改良されながら多くの言語で使われています。今後も重要な技術で有り続けるものだと思うので、この機におさらいしつつもう少し深堀り出来たらと思って書きました!
次回に続きたい。

Discussion