Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

monoruby の GC — 機構と実装

monoruby のガベージコレクタの現行実装を、コードに即して解説するドキュメント。 本書は「いま実際に動いているもの」を対象とする。

補足: CLAUDE.md は GC を「mark-and-sweep」と一言で書いているが、現行実装は より正確には 非移動(non-moving)・単一スレッド・stop-the-world の 世代別 mark & sweep(CRuby の RGenGC に相当)である。世代別化は既に有効で、 オブジェクトは実際に old 世代へ昇格し、マイナー/メジャー GC が使い分けられる。 alloc.rs に残る一部コメント(「old_bits is always empty」「not enabled yet」等)は 実装より古い名残りである。実挙動は本書と該当コードを正とする。

主な実装ファイル:

対象ファイル
アロケータ・ページ・GC 本体monoruby/src/alloc.rs
RValue のヘッダ / マーク / 書き込みバリアmonoruby/src/value/rvalue.rs
セーフポイント・ルート走査・execute_gcmonoruby/src/executor.rs
GC poll のコード生成monoruby/src/codegen/arch/{x86_64,aarch64}/…
GC モジュールのビルトインmonoruby/src/builtins/gc.rs

1. 全体像

  • 非移動 (non-moving): オブジェクトは一度確保したセルから動かない。コピーや コンパクションを行わないので、生ポインタ(*const RValue)を保持したまま GC を 跨いでも安全。ページ・フリーリスト・スイープ機構をそのまま世代別化に流用できる。
  • 単一スレッド・stop-the-world: monoruby の VM は 1 本の OS スレッドで走る。 GC は VM セーフポイントで同期的に実行され、並行 GC やインクリメンタル GC は 持たない。
  • 世代別 (generational): 弱い世代別仮説(多くのオブジェクトは若くして死ぬ)に 基づき、マイナー GC ではマーク対象を「若い世代 + old→young 参照」に限定する。 長命オブジェクトを多数抱えるワークロード(Rails 系・optcarrot 等)でのマーク コストを削減する。
  • 保守的ではない (precise): ルートは明示的に列挙してマークする(スタックの 値スキャンではない)。JIT コンパイル済みコードのセーフポイントでは、生きた レジスタをスタックに退避してからマークする。

コンパイルパイプライン全体における GC の位置づけは CLAUDE.md の “Custom GC (alloc.rs)” と本書を対応させて読むとよい。

オブジェクトの状態遷移(世代間の移動と OLD / WB_ARMED / age 各フラグの変化)を 1 枚にまとめた図が gc_state_transitions.svg にある(§6・§7 の図解版)。


2. ヒープのレイアウト

2.1 アロケータ

thread_local! { pub static ALLOC: RefCell<Allocator<RValue>> }   // alloc.rs

Allocator<RValue> はスレッドローカルなシングルトン(alloc.rs:155)。 RValue は 64 バイト固定(GCBOX_SIZEAllocator::newassert_eq!(64, GCBOX_SIZE))。

主なフィールド(alloc.rs:299 付近):

フィールド意味
current_page / head_page / pages現ページ / 最上位ページ / 割り当て済みページ一覧
used_in_current現ページのバンプ位置
free / free_list_countフリーリスト先頭と要素数
free_pages空きになって再利用待ちのページ(常駐したまま)
released_pages / total_released_pages空きページのうち OS に返したもの(madvise)と、その累計
total_gc_counter / minor_gc_count / major_gc_countGC 回数の各カウンタ
minors_since_major直近メジャー以降のマイナー回数(kind 判定に使用)
old_countold 世代オブジェクト数(昇格で +1、メジャーで 0 リセット)
old_major_threshold適応的メジャー閾値(old_count がこれに達したら次はメジャー)
promotingマーク中に昇格候補を収集するか(実マーク中のみ true)
promoted今サイクルで昇格したオブジェクト(マーク後に remembered/armed へ分類)
mark_queueマーク済み・未走査オブジェクトのキュー(幅優先走査 + 先読み)
rememberedremembered set(old→young 参照を持つ old オブジェクト)
pages_since_gc前回の収集以降に THRESHOLD まで充填したページ数(gc_trigger_pages() で GC レーンを立てる。§4.1)
heap_framesヒープに退避したフレームバッファの登録表(§9)

2.2 アリーナとページ

const SIZE: usize        = 64;
const GCBOX_SIZE: usize  = size_of::<RValue>();          // 64
const PAGE_LEN: usize    = 64 * SIZE;                     // 4096 セル/ページ
const DATA_LEN: usize    = 64 * (SIZE - 1);               // 4032 データセル
const THRESHOLD: usize   = 64 * (SIZE - 2);               // 3968(ページ圧力を数える位置)
const ALLOC_SIZE: usize  = PAGE_LEN * GCBOX_SIZE;         // 262144 = 256KB
const MAX_PAGES: usize   = 8192;
  • アリーナは起動時に ALLOC_SIZE * MAX_PAGES(= 2GB)を 1 回だけ予約する (Allocator::newSystem.alloc)。実 RSS はページを使うぶんだけ増える (予約は仮想アドレス空間)。ページは 256KB 境界に整列。
  • ページからポインタへの逆引きはアドレスマスクで O(1): get_page(ptr) = ptr & !(ALLOC_SIZE - 1)(alloc.rs:1375)。これにより任意の *const RValue から所属ページ(とマークビット)を即座に求められる。

Page<T>(alloc.rs:1449)の構造:

struct Page<T> {
    data:      [T; DATA_LEN],       // 4032 セル
    mark_bits: [u64; SIZE - 1],     // 63 ワード = セル1つにつき1ビットのマークビットマップ
    old_bits:  [u64; SIZE - 1],     // 63 ワード = old 世代ビットマップ(mark_bits と並行)
}

size_of::<Page<T>>() <= ALLOC_SIZEAllocator::new で保証される。 data の後ろにビットマップ 2 枚が同居する(セル本体の外にマークを置く mark-external 方式なので、生存中のオブジェクト内容を汚さない)。


3. 割り当て(Allocator::alloc)

alloc.rs:779。順序は以下:

  1. フリーリストが空でなければそこから 1 セル pop(self.free)。直前の GC で スイープされたセルの再利用。
  2. 空でなければ現ページのバンプ割り当て
    • used_in_current == THRESHOLD(3968)に達したら on_page_pressure()pages_since_gc += 1gc_trigger_pages() に達したら poll ワードの GC レーンを 立てる(GC を要求;§4)。
    • used_in_current == DATA_LEN(4032)でページ満杯 → free_pages から再利用、 なければ new_page() で新規ページ。新ページは clear_old_bits() で old ビットマップを 0 初期化(マイナー GC のシード整合性のため)。

JIT インライン高速パス

フリーリストからの pop は JIT がインライン展開できるよう、アロケータが 生アドレスを公開している:

  • free_list_head_addr()(self.free) — alloc.rs:658
  • free_list_count_addr() / total_allocated_addr() — 統計の同期用

JIT コードはセーフポイント外でのみこれらを触る(Rust 側が ALLOC を借用中や gc() 実行中は触らない)ため、単一スレッド前提でエイリアスは生じない。


4. GC のトリガとセーフポイント

GC は「アロケーションの延長で即実行」はしない。JIT の生きたレジスタが未退避の まま GC ルート走査に入るのは危険なため、フラグを立てて次のセーフポイントで 実行する。

4.1 poll ワードの GC レーン(poll_flag.rs)

VM/JIT が参照する poll ワード(u32、8bit×4 レーン。全体像は doc/safepoint.md §3)の byte 0 が GC レーン。これを立てる経路:

経路実装操作
ページ圧力on_page_pressure(alloc.rs)pages_since_gcgc_trigger_pages()(下記)に達したら set_gc()
malloc 圧(§8)request_gc_if_malloc_overset_gc()
GC.startrequest_gc(true)set_gc() + メジャー強制
GC.stressset_stress(true) / 各収集の末尾set_gc()(再武装)

すべて冪等な fetch_or で、シグナル(SIGNAL レーン)・プリエンプト(PREEMPT レーン)とは byte が分かれているため互いを踏み潰す競合は原理的にない。GC 判定は GC レーン単体で行うので、 純粋なプリエンプト tick やシグナル到着が偽の full GC を起こすことはない(§4.3 手順 2)。 収集の完了時は ack_gc_request(alloc.rs)が GC レーンの byte だけを落とし、 pages_since_gc を 0 に戻す(並行して立った他レーンは保存される)。--no-gc 時の 空収集も同じ経路で要求を無効化するため、レーンが立ちっぱなしで poll が空回りすることはない。

収集間隔はヒープに比例する(gc_trigger_pages)

ページ圧力の閾値は固定値ではなく、稼働中ページ数の一定割合:

gc_trigger_pages() = max(PAGES_PER_GC_TRIGGER, 稼働ページ数 / GC_HEAP_FRACTION)
                   = max(8, pages / 16)

収集 1 回のコスト(ルート走査・remembered set 走査・ビットマップ走査)は生存量で 決まるのに対し、固定予算はそれを一定量の確保にしか償却しない。生存量が増え続ける プログラムでは総 GC コストが O(生存量 × 総確保量) になってしまうため、予算をヒープに 比例させてコレクタ/ミュータタ比を有界に保つ。

  • 128 ページ(32MB)未満のヒープでは max の下限が効き、従来と完全に同一挙動 (optcarrot・aobench・sudoku 等はページ数・収集回数・RSS すべて不変)。
  • 代償は浮遊ゴミで、ヒープの最大 1/16 が回収を先送りされる。plb2 bedcov(生存 270 万 オブジェクト)では マイナー 202 → 83 回、GC 時間 −35%、実行時間 −12%、RSS +23% (それでも同プログラムの CRuby の RSS より小さい)。
  • 収集で全滅したページは pages を離れて free_pages に移るため、生存量が落ちた ヒープは自動的に予算も縮む。

4.2 poll のコード生成

execute_gc_inner(codegen/arch/x86_64/jit_module.rs:255)が poll を出力:

cmpl [rip + poll_flag], 0
jne  gc          ; いずれかのレーンが立っていれば slow path へ
exit:
; gc: (別ページ)
;   write_back(生きたレジスタを退避)
;   call exec_gc      ; = execute_gc()
;   testq rax, rax
;   jne  exit         ; nil 以外(=正常)なら復帰
;   jmp  error        ; None(=例外/シグナル)なら伝播

この poll は呼び出し先エントリ(callee entry)とループのバックエッジという セーフポイントで実行される(vm_execute_gc;vmgen/init_method.rs / vm_loop_start ほか。 call-site には poll を置かない — doc/threads.md §8.3)。aarch64 backend も 同等のゼロ判定(ldr; cbz)を出力する。

4.3 execute_gc(executor.rs:3743)

セーフポイントから呼ばれる extern "C" 関数。順に:

  1. watchdog::poll() — ハングウォッチドッグのカウントダウンをリセット。
  2. poll_flag::consume_preempt()PREEMPT レーンを消費する。GC 判定は GC レーンで 独立に行う(§4.1)。
  3. 保留シグナルの処理 — SIGNAL レーンをクリアしてから PENDING_SIGNALS ビットマップを drain し、最小番号のシグナルを Signal.trap ハンドラ呼び出し / 既定例外(SIGINT ⇒ Interrupt 等)に変換(doc/signal.md)。
  4. GC レーンが立っているときだけ GC 本体を実行:parent_fiber を辿って **ルート Executor(最上位ファイバ)**へ行き、 ALLOC.with(|a| a.borrow_mut().gc(&Root { globals, executor }))。 drain できなかった保留シグナルがある poll では収集を次の poll へ延期する (doc/signal.md §4.1)。
  5. プリエンプトビットが立っていて scheduler::preempt_ok() なら scheduler::pass (タイムスライス切替。doc/threads.md §8.4)。

5. オブジェクトヘッダとフラグ

RValue 先頭の Header は union(rvalue.rs):

union Header { next: Option<NonNull<RValue>>, meta: Metadata }

struct Metadata {          // rvalue.rs:2373
    flag:  u16,
    ty:    Option<ObjTy>,  // 1 バイト
    ty_flags: u8,          // ObjTy 固有のメタデータ(HASH: 小ハッシュ表現ビット)
    class: Option<ClassId>,
}
  • フリーリスト上のセルは next(次の空きセル)として解釈され、生存セルは meta
  • ty_flags は ObjTy 固有のメタデータバイト。JIT の型判定は両アーキテクチャとも 1 バイト読み(x86-64 cmpb / aarch64 ldrb)なので、隣接バイトが任意の値でも 問題ない。HASH オブジェクトはここにインライン表現のビット(hash.rs の HashFlags)を置く。dup/リテラルコピー(Header::newborn / CellHeader::NewbornOf)はこのバイトを保存する。世代別 GC の age は従来どおり flag の上位バイトに置く。

flag: u16 のビット割り当て(rvalue.rs:2447 以降)

ビットマスク意味
00b0000_0001LIVE(生存;確保時 flag = 1)
10b0000_0010FROZEN
20b0000_0100CHILLED(Symbol#to_s 由来の準 frozen 文字列)
30b0000_1000OLD(old 世代へ昇格済み)
40b0001_0000WB_UNPROTECTED(shady 用に予約。現状未使用 — §7.3)
50b0010_0000空き(旧 REMEMBERED。「remembered set 登録済み」は専用ビットではなく OLD ∧ ¬WB_ARMED で導出する)
60b0100_0000WB_ARMED(old かつ未 remembered = 書き込みバリアの slow path 対象)
70b1000_0000CHILLED_LITERAL(リテラル由来の chilled 文字列;警告文言の出し分け用)
8..15上位バイトage(生存回数;RGENGC_OLD_AGE で昇格。上位バイトは age 専用 — 下位バイトのフラグはここに置かないこと)

新規オブジェクトは flag == 1 なので、OLD / WB_ARMED はともに 0 (= young・バリア対象外)、age は 0 から始まる。

old オブジェクトの 2 状態は WB_ARMED 1 ビットで表す: armed = OLD ∧ WB_ARMEDremembered = OLD ∧ ¬WB_ARMED。 remembered set の実体(列挙)は Allocator::remembered(Vec)であり、ヘッダ側は バリアの高速パスが見る WB_ARMED だけを持つ。arm_barrier(WB_ARMED を立てる)と enter_remembered(WB_ARMED を落とす)は単一ビットの反転で、 書き込みバリアの高速パスはこの 1 ビット(WB_ARMED)テストだけで済む。 「OLD=0 なのに WB_ARMED=1」は発生しない不正状態である。


6. 世代別 GC 本体(Allocator::gc, alloc.rs:864)

6.1 マイナー / メジャーの選択(decide_gc_kind, alloc.rs:854)

old_count >= old_major_threshold  ||  minors_since_major >= MAX_MINORS_PER_MAJOR
    → Major     それ以外 → Minor
  • 適応的メジャー閾値 old_major_threshold: メジャー直後に max(old_count * OLD_GROWTH_FACTOR, OLD_OBJECT_FLOOR) へ再設定 (OLD_GROWTH_FACTOR = 2, OLD_OBJECT_FLOOR = 16384)。old 世代が安定していれば メジャーは稀(世代別の利得を保つ)、浮遊ゴミを昇格し続けるワークロードでは 頻繁にメジャーして RSS を抑える。CRuby の RGENGC_OLD_OBJECT_LIMIT_FACTOR に相当。
  • MAX_MINORS_PER_MAJOR = 64: 安全上限。適応閾値が発火しなくても、64 回に 1 度は 必ずメジャーして remembered set を作り直し、浮遊 old ゴミを回収する。
  • GC.startGC_FORCE_MAJOR を立てるので、次の収集は無条件にメジャー。

6.2 マークビットマップの準備

kind操作
Majorclear_mark()(mark_bits=0)のみ。全オブジェクトが収集候補に戻り、ルートから再マーク・全スイープ。old_bits と remembered set は保持する(下記)。
Minorseed_marks()。各ページで mark_bits ← old_bits をコピー(seed_mark_from_old)。old オブジェクトは最初からマーク済みとみなされ、再走査もスイープもされない。

メジャーは old 世代を維持する

メジャー GC は old を降格しない。old だったオブジェクトがメジャーを生き延びたら old のままで、remembered / armed の区別(ヘッダの OLD + WB_ARMED、および remembered)もそのまま引き継ぐ。

降格していた頃は、生きている old 世代全件が毎メジャーで 「aging への push → age 加算 → promote_to_oldyoung_child_exists の全子走査」 をやり直していた(age は飽和済みなので昇格自体は同じサイクル内で完了するが、 old 世代の参照辺をもう一度全走査するコストがそのまま乗る)。さらにメジャー直後の マイナーは old 世代が空の状態から始まるため、シードマークの利得も失われていた。

維持に伴い、死んだ old セルのビットを畳む場所が変わる:

  • sweep:各ビットマップ語で old_bits &= mark_bits とし、落ちたビット数を old_count から引く(retire_dead_old)。解放セルは free list 経由で 若いオブジェクトとして再利用されるので、古いビットが残っていると次のマイナーで seed-mark され、新しいオブジェクトの子が一度も走査されない。
  • salvage_empty_pages:全セルが死んだページは sweep の前に pages から外れる ため、ここで old_count を減らし clear_old_bits() する。
  • 適応的メジャー閾値 old_major_threshold の再計算はスイープ後に行う (生きている old 世代を基準にするため)。

remembered set は、マイナーの mark_remembered に加えて、メジャーでは reclassify_remembered(§6.4)が young の子を失った entry を落として arm_barrier する。コストは remembered set のサイズ(生きた old→young 辺)に比例し、 old 世代全体には比例しない。

6.3 マークフェーズ

  1. self.promoting = true にしてから root.mark(self)(ルートは §8)。
  2. RValue::markAllocator::mark(alloc.rs)は、ページのビットマップだけを 見てマークビットを立て、未マークだったセルを mark_queue(VecDeque)に積む。 オブジェクト本体(ヘッダ)はここでは読まない。
  3. Minor のみ mark_remembered():remembered set の各 old オブジェクトの 子だけmark_children で辿る(親 old は既にシードマーク済み)。これにより 「old からしか参照されていない young オブジェクト」に到達する。走査後、若い子が いなくなった entry は set から外して arm_barrier(自己クリーニング)。
  4. 上記 1・3 の直後に drain_mark_queue()。キューが空になるまで先頭から取り出し、 各オブジェクトについて (a) check_live(死んだセルなら forensics 付きで abort)、 (b) 加齢と昇格(§6.4)、(c) mark_children の順に処理する。ここで到達した子も また mark でキューに積まれるので、1 回の drain でグラフの残り全体に届く。 マークビットを読む処理(remember_promotedfilter_remembered・スイープ)より 前に必ず drain されている必要がある。
  5. self.promoting = false

マークキュー(mark_queue)と先読み(MARK_PREFETCH_DISTANCE)

以前のマークは純粋な再帰で、スタック消費がオブジェクトグラフの深さに比例して いた。a = [a] を 7.5 万回、連結リスト、ivar チェーン、入れ子 Hash — いずれも 8MB のメインスタックを溢れさせ、GC の最中にプロセスが abort していた (thread 'main' has overflowed its stack)。その後しばらくは「32 段までは再帰、 それ以降はキュー」という折衷だった(全部キューに積むと bedcov で GC 時間 +9% と 測れたため)。

現在は全オブジェクトをキュー経由で幅優先に辿る。決め手はスタックではなく キャッシュミスで、マークの費用はほぼ「オブジェクトのヘッダを 1 行読む DRAM アクセス」そのものだった(splay: 1 マークあたり 60–90 ns)。再帰では子のヘッダを 読むまで次のアドレスが分からずミスが直列化するが、キューなら数個先のエントリの アドレスが既に手元にあるので、drain_mark_queueMARK_PREFETCH_DISTANCE (= 8)個先のヘッダを prefetch してからいまのオブジェクトを走査する。これで ミスが重なり、splay のマーク走査は 1 オブジェクト 28–35 ns(2 倍強の高速化)、 GC 時間全体で −50% になった(§6.4 の変更込み。12 反復の GC 合計 1415 → 714 ms)。 mark 側でヘッダを読まない(ビットマップのみ)ことが前提で、is_live の検査と 昇格判定はすべて drain 側に移してある。

キューの実体はヒープなので、500 万段の連結リストでも通り、ネイティブスタックの 使用量はグラフの深さに依らず 1 段で済む。

6.4 加齢と昇格(drain_mark_queue / remember_promoted)

加齢と昇格は drain_mark_queue がオブジェクトを取り出したその場で行う (§6.3 の 4-(b))。取り出した生ポインタからヘッダを書き換えてから、mark_children 用の &T を作る — この時点でそのセルへの共有参照は存在しない(キューに積んだ &Tmark から戻った時点で消えている)ので、ヘッダ書き込みはエイリアスしない。 以前は「マーク走査が握る &self と衝突しないよう、マーク後に aging 配列を なめ直す」2 パス構成だったが、それは生存者全員をもう一度ランダムアクセスする パスで、splay ではマーク時間の 20–30% を占めていた。いまは走査が読むヘッダ行の 上でそのまま加齢する。

  • 加齢: promoting かつ is_promotable() の生存者の age を +1 (age_and_check_promote)。age >= RGENGC_OLD_AGE(= 3)に達したものを昇格: old_bits をセット + ヘッダ OLD をセット + old_count += 1 + promoted に記録。 → 即時昇格ではなく「3 回生存したら昇格」。1 回の収集でたまたま生きていた 短命オブジェクトを old に上げてしまい浮遊ゴミ化するのを避ける。 メジャーは old も普通にマークするため、既に old のセル(old_bits)は加齢しない (major_mark フラグで、この余分なビットマップ読みをマイナー側に持ち込まない)。
  • remember_promoted(マーク完了後): remember-on-promote。昇格したオブジェクトが まだ young を参照している(young_child_exists)なら remembered set に追加 (バリア導入前から存在した old→young 辺をカバー)。young 参照が無ければ arm_barrier して以後の young ストアに備える。今サイクルの昇格が全部見えてから 走るので、子より先に昇格した親が無駄に remembered されることはない。

メジャーではこの後に reclassify_remembered(filter_remembered で死んだ entry を 落とした後)が走る。生き残った entry のうち young の子を失ったもの (この収集で子が昇格した/死んだ)を set から外し arm_barrier する — マイナーの mark_remembered に内蔵された自己クリーニングと同じ役割で、メジャーが set を 作り直さなくなった分をここで担保する。

: 以前はメジャーが remembered set を毎回ゼロから作り直していたため、 書き込みバリアの漏れがあってもメジャーごとに自己修復されていた。維持方式では それが無いので、バリア契約(§7.3 の is_promotable)の破れはマスクされずに 顕在化する。gc-stress + gc-verify で検証すること。

6.5 マイナー後の検証(gc-verify フィーチャ)

マイナー GC の後、シード無し・昇格無しでルートから全ライブグラフを独立に再マーク する(alloc.rs:964)。もしマイナーが到達可能なオブジェクトを解放していれば (バリア漏れ/remembered set 漏れ)、この走査が解放済みセルに到達し RValue::markis_live アサートが発火する。世代別 GC の健全性テスト。


7. 書き込みバリア

world 停止型・非移動なので、必要なのは old→young 辺を remembered set に記録する だけの単純なバリア。

バリアと remembered set が「なぜ必要か」(世代別 GC なし / remembered set なしの minor GC / 完全な minor GC の 3 通りでのマーク走査の比較と、バリアが必要な辺の 分類)を図解したものが gc_write_barrier.svg にある。

7.1 実体(RValue::write_barrier, rvalue.rs:1115)

#![allow(unused)]
fn main() {
pub(crate) fn write_barrier(&mut self, child: Value) {
    if self.is_wb_armed() && !child.is_packed_value() {
        self.enter_remembered_set();
    }
}
}
  • 高速パスはヘッダ 1 ビットのテスト(is_wb_armed = WB_ARMED ビット)。 young オブジェクトも、既に remembered な old オブジェクトも、このビットが 0 なので 即 return(アロケータに触れない)。
  • 子の世代は見ない(old→old を覚える過剰近似は無害)。即値(is_packed_value)は除外。
  • write_barrier_bulk(rvalue.rs:1128)は Array#concat / Hash#[]= などの 複数要素ストア用。個々の子を見ず、armed なら無条件に記録する過剰近似。

呼び出しは「参照型フィールド(ivar / 配列・ハッシュ要素 / struct スロット)へ child を格納した」。インタプリタ経路(set_ivar、Array/Hash ラッパ、 Value::set_struct_slot 等)と、JIT が出力するインラインバリア (emit_write_barrier_rdi)の両方でカバーされる。

7.2 状態遷移

young(flag=1) ──[age>=3 で昇格]──▶ old
   昇格時に young 子あり ─▶ enter_remembered (WB_ARMED=0) ── remembered set 登録
   昇格時に young 子なし ─▶ arm_barrier      (WB_ARMED=1) ── 以後の young ストアを待つ
   armed な old に young ストア ─▶ write_barrier ─▶ enter_remembered_set ── set 登録 + WB_ARMED=0
   minor 走査で young 子が消えた remembered ─▶ arm_barrier に戻す(自己クリーニング)

生きている old については「WB_ARMED=0 ⇔ Allocator::remembered に登録済み」が 不変条件(専用の REMEMBERED ビットは持たない — §5)。remembered set の大きさは 「生きた old→young 辺の数」に比例し続ける(かつて young 子を持っていた全昇格 オブジェクトには比例しない)。

7.3 昇格可能性(is_promotable, rvalue.rs:918)

昇格してよいのは「そのオブジェクトへの Value 格納経路がすべてバリア保護されている」 型のみ。現状 ty() で判定し、以下が true:

OBJECT | STRING | BIGNUM | FLOAT | ARRAY | STRUCT | HASH
  • OBJECT と各リーフ(String バイト列 / Bignum / ヒープ Float)は ivar 経由でしか Value を持たず、ivar ストアは全経路バリア済み。
  • Array/Struct の要素ストア、Hash ストアもインタプリタ・JIT 双方でバリア済み。
  • それ以外の型は昇格しない(マイナーで毎回走査される young のまま)。

WB_UNPROTECTED(bit4) は「shady(バリアで追えない)オブジェクトは昇格しない」 ための予約フラグだが、現状 is_promotable は型のみで判定し、このフラグは 参照されていない(set_wb_unprotected の呼び出し箇所は無い)。将来のための予約。


8. ルート(マーク開始点)

Root(executor.rs:3713)の mark(executor.rs:3719)が起点:

Root::mark → YIELDER.mark      (ブロック/ファイバの yielder)
           → Globals::mark     (globals.rs)
           → Executor::mark    (executor.rs:248)
           → scheduler::mark    (executor.rs:3729 — グリーンスレッドの root)

Executor::mark(executor.rs:248)が辿るもの:

  • temp_stack の全 Value(ビルトインが GC を跨いで生かしたい一時値の退避先)。
  • cfp 連鎖の各 lfp()(= すべての生きたスタックフレームのローカル変数・レシーバ等)。
  • lexical_class 上の DefinitionContext::Receiver(Value) (instance_eval/instance_exec 中のレシーバ)。
  • 保留例外 exception(MonorubyErr は packed Value を持つ;MonorubyErr::mark)。
  • マッチ処理の一時退避 sp_match_regex / sp_match_haystack
  • deferred_unwind(ensure で中断した MethodReturn/Throw が握る Value と Lfp)。

Globals::mark はクラステーブル・定数・グローバル変数・呼び出しサイト等の 恒久ルートをマークする。

グリーンスレッド(scheduler::mark)

green thread 導入後、GC ルートにはスケジューラの生存スレッド registryが加わった (scheduler::mark, scheduler.rs)。Scheduler::markthreads / current / main / ready / sleepers / io_waiters の全 Thread オブジェクトをマークし、in_scheduler 中は main の Executor(main_exec)も deref してマークする。各 Thread は impl GC for ThreadInner を通じて自分の handle Executor(→ その CFP チェーン)と proc/args/result/exception/joiners/pending/masks/last_status をマークする。

したがって GC は事実上複数の Executorをマークする:各 green thread の handle と、 main_exec 経由で辿る埋め込み側所有の main Executor。切替はセーフポイントでしか 起きないので、サスペンド中のどのスレッドのフレームも GC-complete (詳細は doc/threads.md §2・§3.4・§8)。

8.1 ビルトインの一時値 — 素の Vec<Value>ルートではない

ルート走査は上記の列挙がすべてなので、Rust 側のローカル(Vec<Value>HashMap<_, Value>、単なる Value 束など)は GC からまったく見えないvm.invoke_block / vm.invoke_method_inner / vm.invoke_proc はいずれも 任意の Ruby を走らせる = セーフポイントを跨ぐので、

原則: Ruby 呼び出しを跨いで生かしたい Value は、必ず temp_stack (temp_push / temp_array_new + temp_array_push / temp_array_extend_from_slice / with_temp_scope)に載せる。

引数ベクタのように「組み立てた直後に 1 回だけ invoke へ渡す」用途は、 その間にセーフポイントが無いので素の Vec で構わない。危険なのは invoke をループで回しながら結果を貯めるアキュムレータである。

Executor にはこの型の定型処理を安全側に閉じ込めたヘルパがある:

ヘルパ用途
invoke_block_iter1each 系(結果を捨てる)
invoke_block_iter1_rooted同上。イテレート元を先に materialise してルート付けする
invoke_block_map1map 系(結果を 1 個ずつ push)
invoke_block_flat_map1flat_map 系(Array なら展開して push)

実例(gc-stress CI の optcarrot abort): Array#flat_map#to_ary 対応を足した際に invoke_block_flat_map1 から素の let mut res: Vec<Value> に書き換えられ、 ブロック呼び出しを跨いで貯めた要素がすべて未ルートになっていた。通常ビルドでは GC の閾値に届かず表面化しなかったが、gc-stress(毎セーフポイント収集)では optcarrot の Palette.defacto_palette(512 要素の flat_map)が返す Array が 解放済み RValue で埋まり、次のマークで DEAD RVALUE reached in mark で abort した。 再現は 9 行で足りる:

src = [[1.0, 1.0, 1.0]] * 8
res = src.flat_map { |rf, gf, bf| (0...64).map { |i| [i * rf, i * gf, i * bf] } }

診断のコツ: RValue::mark の DEAD 検出点で、(a) Lfp::mark 側に 「いまマーク中のフレームの func_id とスロット番号」、(b) RValue::mark 側に 「いま children を辿っている親 RValue」を thread-local で持たせて出力すると、 どのメソッドのどの一時値が壊れているかが一発で分かる。今回は func=Video#initialize(driver.rb:67) slot=2 / parent=Array(len=512) まで出て、 そこから flat_map に到達した。

(b) の「直接の親」は常にバックトレースに出る — 失敗した mark を呼んだのは 親の mark_children フレームだからである。ただし §6.3 のマークキューに積まれた 地点より上の祖先はバックトレースから切れるので、DEAD 検出点は scanning from: <ptr> ty=...(Allocator::mark_referrer)としてその キュー entry を併せて出力する。scanning from: root set なら、走査はまだルート集合の 中にいた — つまり壊れた辺はオブジェクトの中ではなくルート側(ビルトインの 未ルート一時値、古いフレームスロット)にある。


9. スイープと空きページの回収

スイープ(sweep, alloc.rs:1269)

ページごとに mark_bits を 64 ビット単位で走査(sweep_bits)。未マークセルを free()(型に応じて ManuallyDrop::drop;rvalue.rs:785)してフリーリストに連結。 trailing_ones でマーク済みの連続領域を一気に飛ばす最適化がある。最後に self.free がフリーリスト先頭に、free_list_count が回収数になる。

free() は多重呼び出しに耐える(is_live() を先頭で確認)。フリーリスト上のセルは 次のスイープでもう一度 free されうるため。

空きページの回収(salvage_empty_pages, alloc.rs:1250)

スイープ前に、全セルが未マーク(all_dead)のページを pages から外して 中身をドロップし free_pages へ戻す。以後の割り当てで再利用される。

free_pages のうち予備(稼働中ページ数の 1/8、最低 2 枚: FREE_PAGE_RESERVE_FRACTION / FREE_PAGE_RESERVE_MIN)を超えた分は release_excess_free_pages が OS に返す(released_pages へ移し、Linux は madvise(MADV_DONTNEED)、macOS は MADV_FREE_REUSABLE)。アドレスはアリーナ予約内に 留まり、free_pages が尽きたときに take_free_page が(macOS では MADV_FREE_REUSE を打って)再び稼働に戻す。返したページの中身は不定だが、稼働に戻るページは clear_old_bits とバンプ割り当てが読む前にすべて書き直すので問題ない。

以前は空きページを常駐させたままだったため、ワークロードのピーク時のヒープが そのまま RSS に残っていた(lee: 生存 22 ページに対して常駐 ~140 ページ、30 MB)。 予備は 2 回の収集ぶんの成長(トリガー予算は稼働ページの 1/16)を賄うので、 定常状態のヒープは常駐ページを回し、縮んだヒープだけが再タッチのページフォルトを払う。


10. ヒープに退避したフレーム(heap_frames)

クロージャ等でスタックフレームがその生成メソッドより長生きする場合、フレームは move_frame_to_heap / heap_frame により Box<[u64]> としてヒープへ退避され、 Box::into_raw でリークされる。この生バッファを GC が回収できるよう、LFP アドレスを キーに heap_frames へ登録する(register_heap_frame, alloc.rs:563)。

  • マーク時、生きた LFP から到達したフレームに marked を立てる。
  • sweep_heap_frames(alloc.rs:603)が、2 サイクル連続で未マークだった フレームの Box<[u64]> を解放する(1 サイクルの猶予は昇格→ルート格納の窓を カバーするため)。
  • キーは 8 バイト整列の LFP アドレスなので、既定の SipHash ではなく Fibonacci ハッシュ 1 回(AddrHasher)で引く(gc-stress 下では毎確保ごとに引かれるため速度が効く)。

heap_frames が空のときは関連処理を丸ごとスキップし、コスト 0(optcarrot 等は フレーム退避が稀)。


11. malloc 連動トリガ(外部バッファ圧)

RValue アリーナの圧力だけでは、String#<< ループのように RValue をほとんど 作らずに malloc メモリだけ膨らむケースを検知できない。そこでグローバル アロケータ自身が外部バッファ量を追跡する:

  • RurubyAlloc(#[global_allocator], alloc.rs:7)が alloc/deallocMALLOC_AMOUNT を増減。
  • MALLOC_TRACK_LIMIT = 64MB 以上の確保は無視。これは JIT メモリ予約 (monoasm が起動時に 3 × 256MB を確保)のような一過性インフラ確保を除外するため。 無視すると閾値が GB 級に張り付き、通常の String/Array/Hash 成長で永遠に GC が 発火しなくなる。同じ判定で dealloc も gate するので MALLOC_AMOUNT は アンダーフローしない。
  • request_gc_if_malloc_over(alloc.rs)が MALLOC_AMOUNT >= MALLOC_GC_THRESHOLD で poll ワードの GC レーンを立てる(GC 要求;割り当てフリーで安全)。
  • 閾値 MALLOC_GC_THRESHOLD は各 GC 後に malloced + max(malloced/2, MALLOC_THRESHOLD) へ再設定(alloc.rs:986)。 加算のみだと巨大ヒープでも 256KB ごとに GC してしまうので、乗算項で比例させる。
  • この経路の収集はメジャー強制しない(一過性バッファは若くして死ぬのでマイナーで 回収でき、old のバッファゴミは §6.1 のメジャートリガが拾う)。

12. GC の制御(GC モジュール, builtins/gc.rs)

メソッド実装挙動
GC.startbuiltins/gc.rb + __request_gcrequest_gc(full_mark) で収集を要求したあと、ループ後方辺(セーフポイント)を跨いで GC.count が進むまで回るので、CRuby 同様に回収を終えてから返る。builtin の中で直接 gc() を呼べないのは、JIT 呼び出し元の生きたレジスタがセーフポイント以外では退避されておらずルート走査から見えないため。full_mark: false はマイナーを許す(強制しない)。
GC.disable / GC.enableGlobals::gc_enable(false/true)GC の有効/無効を切り替え、直前の disable 状態を bool で返す。GC_ENABLED(§4 の malloc 経路が参照)も同期。
GC.counttotal_gc_counter総 GC 回数。
GC.statstat(CRuby 4.0 のキー順)ページ数・スロット数・累計確保/解放オブジェクト数・old 世代・malloc 量・フェーズ別時間まで実カウンタ。圧縮とファイナライザ、CRuby 固有の old malloc 会計だけが 0(概念が無いため)。
GC.total_time / GC.measure_total_timegc_time_nsgc() の実測ナノ秒。measure_total_time = false の間は計測自体を行わない(GC::Profiler が有効なら計測は続く)。
GC.stressAllocator::stress収集の最後に poll フラグをトリガ帯へ戻すので、以降すべてのセーフポイントで収集する。CRuby の「確保ごと」は JIT が確保の高速路をインライン化する都合で再現できないが、ルート漏れの炙り出しという用途は同じ。
GC.configbuiltins/gc.rb + __allow_full_mark:rgengc_allow_full_mark は実ノブで、false の間 decide_gc_kind はメジャーを選ばない(明示的な GC.start は依然メジャーを強制する)。:implementation は読み取り専用。
GC.auto_compact / GC.compactNotImplementedError。monoruby の収集器はオブジェクトを移動しないので、CRuby が圧縮非対応環境で返すのと同じ答えを返す。
GC::ProfilerAllocator::profile有効な間、収集ごとに GcProfileRecord(invoke time / 所要時間 / live バイト / ヒープ総バイト / 総スロット / メジャーか)を積む。result は CRuby と同じ表形式、raw_data は同じキー、total_time は秒の Float。

コマンドラインでは --no-gc で GC を無効化できる。GC 無効時は gc() が即 return するため、request_gc_if_malloc_overGC_ENABLED を見て要求自体をスキップする (さもないとフラグがトリガ帯に張り付いて poll が空回りする)。


13. デバッグ・検証用フィーチャ

フィーチャ効果
gc-log終了時に GC 統計を出力(old 数の実 popcount 等)。
gc-debugGC 中の各種アサート・ダンプ。old_count と実 popcount の一致検証など。
gc-stress毎セーフポイントで無条件に強制 GC(実行時 GC.stress フラグとは独立。execute_gc が常に収集し、GC レーンを常時再アーム)。bin/test の nextest フェーズが使用(CI では x86-64 のみ)。世代別のバリア/remembered set 漏れや、Rust ローカルに保持したままの未ルート Value を最も強く炙り出す。
gc-verifyマイナー GC 後に独立フル再マークで健全性検証(§6.5)。

環境変数 MONORUBY_MALLOC_HARD_LIMIT(例 3G。K/M/G サフィックス可)を設定すると、 malloc 総量がこれを超える確保が要求された瞬間に、要求サイズとバックトレースを stderr へ出力して abort する(alloc.rsmalloc_hard_limit)。OOM でマシン/ ランナーごと死んでログが失われる環境(darwin CI)で、暴走アロケーションを 「名前付きで診断可能なクラッシュ」に変換するための装置。ポーリング型の監視では 捕捉できない単発の巨大確保も、アロケータ内の同期チェックなので確実に捕まる。 未設定なら無効(コストは relaxed load 1 回)。


14. まとめ

  • monoruby の GC は 非移動・単一スレッド・stop-the-world の世代別 mark & sweep
  • 256KB ページ + マーク/old の 2 枚のビットマップ(mark-external)で、非移動と 世代別を両立。ページはアドレスマスクで O(1) 逆引き。
  • 割り当てはフリーリスト → バンプ。ページ圧力の閾値到達で poll ワードの GC レーンを立て、 次のセーフポイントexecute_gc が同期収集する(JIT レジスタ退避のため即実行はしない)。
  • 世代別の心臓部は、3 回生存で昇格(aging)適応的メジャー閾値1 ビット高速パスの書き込みバリア + remembered set(自己クリーニング付き)。 マイナーは old をシードマークして young + old→young 辺だけを辿る。
  • メジャーも old 世代を維持する(降格して作り直さない)。old のビットと remembered/armed の区別はオブジェクトと寿命を共にし、死んだ old セルの後始末は スイープとページ回収が担う。old 世代が大きいほどメジャーが安くなる (old 61 万で 1 回あたり 74ms → 27ms)。
  • マークの走査は幅優先のマークキュー(mark_queue)一本で、数個先の エントリのヘッダを先読みしてキャッシュミスを重ねる。加齢・昇格も取り出した その場で行い、生存者を二度なめない。ネイティブスタックの使用量はグラフの 深さから切り離されている。
  • 外部 malloc 圧・シグナル・GC.start も同じ poll ワード経由で同一のセーフ ポイント収集に集約される(レーン分割は poll_flag.rs / doc/safepoint.md §3)。