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_gc | monoruby/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_SIZE、Allocator::new で
assert_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_count | GC 回数の各カウンタ |
minors_since_major | 直近メジャー以降のマイナー回数(kind 判定に使用) |
old_count | old 世代オブジェクト数(昇格で +1、メジャーで 0 リセット) |
old_major_threshold | 適応的メジャー閾値(old_count がこれに達したら次はメジャー) |
promoting | マーク中に昇格候補を収集するか(実マーク中のみ true) |
promoted | 今サイクルで昇格したオブジェクト(マーク後に remembered/armed へ分類) |
mark_queue | マーク済み・未走査オブジェクトのキュー(幅優先走査 + 先読み) |
remembered | remembered 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::new、System.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_SIZE が Allocator::new で保証される。
data の後ろにビットマップ 2 枚が同居する(セル本体の外にマークを置く
mark-external 方式なので、生存中のオブジェクト内容を汚さない)。
3. 割り当て(Allocator::alloc)
alloc.rs:779。順序は以下:
- フリーリストが空でなければそこから 1 セル pop(
self.free)。直前の GC で スイープされたセルの再利用。 - 空でなければ現ページのバンプ割り当て。
used_in_current == THRESHOLD(3968)に達したらon_page_pressure()でpages_since_gc += 1、gc_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:658free_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_gc が gc_trigger_pages()(下記)に達したら set_gc() |
| malloc 圧(§8) | request_gc_if_malloc_over | set_gc() |
GC.start | request_gc(true) | set_gc() + メジャー強制 |
GC.stress | set_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" 関数。順に:
watchdog::poll()— ハングウォッチドッグのカウントダウンをリセット。poll_flag::consume_preempt()で PREEMPT レーンを消費する。GC 判定は GC レーンで 独立に行う(§4.1)。- 保留シグナルの処理 — SIGNAL レーンをクリアしてから
PENDING_SIGNALSビットマップを drain し、最小番号のシグナルをSignal.trapハンドラ呼び出し / 既定例外(SIGINT ⇒Interrupt等)に変換(doc/signal.md)。 - GC レーンが立っているときだけ GC 本体を実行:
parent_fiberを辿って **ルート Executor(最上位ファイバ)**へ行き、ALLOC.with(|a| a.borrow_mut().gc(&Root { globals, executor }))。 drain できなかった保留シグナルがある poll では収集を次の poll へ延期する (doc/signal.md§4.1)。 - プリエンプトビットが立っていて
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-64cmpb/ aarch64ldrb)なので、隣接バイトが任意の値でも 問題ない。HASH オブジェクトはここにインライン表現のビット(hash.rs のHashFlags)を置く。dup/リテラルコピー(Header::newborn/CellHeader::NewbornOf)はこのバイトを保存する。世代別 GC の age は従来どおりflagの上位バイトに置く。
flag: u16 のビット割り当て(rvalue.rs:2447 以降)
| ビット | マスク | 意味 |
|---|---|---|
| 0 | 0b0000_0001 | LIVE(生存;確保時 flag = 1) |
| 1 | 0b0000_0010 | FROZEN |
| 2 | 0b0000_0100 | CHILLED(Symbol#to_s 由来の準 frozen 文字列) |
| 3 | 0b0000_1000 | OLD(old 世代へ昇格済み) |
| 4 | 0b0001_0000 | WB_UNPROTECTED(shady 用に予約。現状未使用 — §7.3) |
| 5 | 0b0010_0000 | 空き(旧 REMEMBERED。「remembered set 登録済み」は専用ビットではなく OLD ∧ ¬WB_ARMED で導出する) |
| 6 | 0b0100_0000 | WB_ARMED(old かつ未 remembered = 書き込みバリアの slow path 対象) |
| 7 | 0b1000_0000 | CHILLED_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_ARMED、remembered = 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.startはGC_FORCE_MAJORを立てるので、次の収集は無条件にメジャー。
6.2 マークビットマップの準備
| kind | 操作 |
|---|---|
| Major | clear_mark()(mark_bits=0)のみ。全オブジェクトが収集候補に戻り、ルートから再マーク・全スイープ。old_bits と remembered set は保持する(下記)。 |
| Minor | seed_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_old → young_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 マークフェーズ
self.promoting = trueにしてからroot.mark(self)(ルートは §8)。RValue::mark→Allocator::mark(alloc.rs)は、ページのビットマップだけを 見てマークビットを立て、未マークだったセルをmark_queue(VecDeque)に積む。 オブジェクト本体(ヘッダ)はここでは読まない。- Minor のみ
mark_remembered():remembered set の各 old オブジェクトの 子だけをmark_childrenで辿る(親 old は既にシードマーク済み)。これにより 「old からしか参照されていない young オブジェクト」に到達する。走査後、若い子が いなくなった entry は set から外してarm_barrier(自己クリーニング)。 - 上記 1・3 の直後に
drain_mark_queue()。キューが空になるまで先頭から取り出し、 各オブジェクトについて (a)check_live(死んだセルなら forensics 付きで abort)、 (b) 加齢と昇格(§6.4)、(c)mark_childrenの順に処理する。ここで到達した子も またmarkでキューに積まれるので、1 回の drain でグラフの残り全体に届く。 マークビットを読む処理(remember_promoted・filter_remembered・スイープ)より 前に必ず drain されている必要がある。 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_queue は MARK_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 を作る — この時点でそのセルへの共有参照は存在しない(キューに積んだ
&T は mark から戻った時点で消えている)ので、ヘッダ書き込みはエイリアスしない。
以前は「マーク走査が握る &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::mark の is_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::mark は threads / 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_iter1 | each 系(結果を捨てる) |
invoke_block_iter1_rooted | 同上。イテレート元を先に materialise してルート付けする |
invoke_block_map1 | map 系(結果を 1 個ずつ push) |
invoke_block_flat_map1 | flat_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/deallocでMALLOC_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.start | builtins/gc.rb + __request_gc | request_gc(full_mark) で収集を要求したあと、ループ後方辺(セーフポイント)を跨いで GC.count が進むまで回るので、CRuby 同様に回収を終えてから返る。builtin の中で直接 gc() を呼べないのは、JIT 呼び出し元の生きたレジスタがセーフポイント以外では退避されておらずルート走査から見えないため。full_mark: false はマイナーを許す(強制しない)。 |
GC.disable / GC.enable | Globals::gc_enable(false/true) | GC の有効/無効を切り替え、直前の disable 状態を bool で返す。GC_ENABLED(§4 の malloc 経路が参照)も同期。 |
GC.count | total_gc_counter | 総 GC 回数。 |
GC.stat | stat(CRuby 4.0 のキー順) | ページ数・スロット数・累計確保/解放オブジェクト数・old 世代・malloc 量・フェーズ別時間まで実カウンタ。圧縮とファイナライザ、CRuby 固有の old malloc 会計だけが 0(概念が無いため)。 |
GC.total_time / GC.measure_total_time | gc_time_ns | gc() の実測ナノ秒。measure_total_time = false の間は計測自体を行わない(GC::Profiler が有効なら計測は続く)。 |
GC.stress | Allocator::stress | 収集の最後に poll フラグをトリガ帯へ戻すので、以降すべてのセーフポイントで収集する。CRuby の「確保ごと」は JIT が確保の高速路をインライン化する都合で再現できないが、ルート漏れの炙り出しという用途は同じ。 |
GC.config | builtins/gc.rb + __allow_full_mark | :rgengc_allow_full_mark は実ノブで、false の間 decide_gc_kind はメジャーを選ばない(明示的な GC.start は依然メジャーを強制する)。:implementation は読み取り専用。 |
GC.auto_compact / GC.compact | — | NotImplementedError。monoruby の収集器はオブジェクトを移動しないので、CRuby が圧縮非対応環境で返すのと同じ答えを返す。 |
GC::Profiler | Allocator::profile | 有効な間、収集ごとに GcProfileRecord(invoke time / 所要時間 / live バイト / ヒープ総バイト / 総スロット / メジャーか)を積む。result は CRuby と同じ表形式、raw_data は同じキー、total_time は秒の Float。 |
コマンドラインでは --no-gc で GC を無効化できる。GC 無効時は gc() が即 return
するため、request_gc_if_malloc_over は GC_ENABLED を見て要求自体をスキップする
(さもないとフラグがトリガ帯に張り付いて poll が空回りする)。
13. デバッグ・検証用フィーチャ
| フィーチャ | 効果 |
|---|---|
gc-log | 終了時に GC 統計を出力(old 数の実 popcount 等)。 |
gc-debug | GC 中の各種アサート・ダンプ。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.rs の malloc_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)。