はじめに
GC の設計には、昔からなかなか崩せない緊張関係があります。高スループット、低レイテンシ、低メモリ使用量。この 3 つはどれも欲しいのですが、普通はどれかを伸ばすと別のどれかが苦しくなります。
停止時間を短くしたければ、より多くの仕事を並行フェーズへ移したくなります。しかしそのためには、普段のオブジェクトアクセスに余分な仕組みを載せる必要が出てきます。逆にスループットを優先するなら、平常時のコストはできるだけ安くしたい。その結果、重い仕事は GC 時にまとめて支払うことになり、停止が長くなりやすい。さらにメモリ使用量を抑えようとすると、回収・圧縮・OS への返却をもっと積極的にやらなければいけません。
.NET の Satori GC が面白いのは、既存のどれかの方向をそのまま強化したわけではない点です。まず問いの立て方を変えています。もっとも頻繁に起きる回収は、本当にグローバルな問題として処理しなければならないのか。ここが出発点です。
GC は何をしているのか
マネージド言語における GC の仕事は、突き詰めると次の 3 つです。
- 生きているオブジェクトを見つける
- もう到達不能になったオブジェクトを回収する
- 必要なら生存オブジェクトを移動して、断片化を減らし、割り当て効率を保つ
厄介なのは 3 つ目で、オブジェクトを動かすなら、それを指している参照も全部正しく保たなければなりません。この整合性を守るため、GC は場面によってはユーザースレッドを止めます。プログラム全体を止めるこの挙動が STW です。
世代別 GC
GC が毎回ヒープ全体をなめなくて済むのは、「大半のオブジェクトはすぐ死ぬ」という経験則を使っているからです。
そのため現在の GC はたいてい世代別になっています。
- Gen 0: 生成されたばかりで、もっとも早く死にやすいオブジェクト
- Gen 1: 数回の回収を生き延びたが、まだ本当に古いとは言えないオブジェクト
- Gen 2: 長寿命であることがほぼ確定したオブジェクト
要するに、もっとも頻繁な回収をもっとも若い層へ閉じ込めるわけです。短命なオブジェクトの大半が Gen 0 で死んでくれれば、GC は毎回それより古い層まで触らなくてよくなります。
ライトバリアとカードテーブル
世代別 GC にはもう 1 つ重要な問題があります。古いオブジェクトが新しいオブジェクトを参照しているかもしれない、という点です。
たとえば Gen 2 のオブジェクトの中に Gen 0 への参照が入っていたのに、GC がそれを知らずに Gen 0 だけ回収したら、本当は生きているオブジェクトを誤って落としかねません。
そこで必要になるのが、参照の変更を追跡する仕組みです。代表的なのがライトバリアとカードテーブルです。
- オブジェクト参照を書き込むときに、小さな追跡処理を差し込む
- その書き込みが起きた領域を「あとで重点的に見るべき場所」として記録する
こうしておけば、若い世代を回収するときに旧世代全体を毎回スキャンせずに済みます。
「高スループット・低レイテンシ・低メモリ」の三つ巴
GC の難しさは、要するにコストの置き場所の問題です。
低レイテンシを狙うなら、停止中にやる仕事を減らしたい。その代わり、実行中にも追加の仕組みが必要になりやすい。高スループットを狙うなら、平常時の経路はできるだけ軽くしたい。その代わり、回収時にまとめて高いコストを払いやすい。低メモリを狙うなら、回収・圧縮・返却をもっと積極的にやる必要がある。
つまり GC ごとの差は、最終的には「どこでコストを払う設計にするか」に集約されます。
Satori の狙い
Satori は、.NET Runtime から切り離された単なる試作品ではありません。実際のランタイムのインターフェイスに直接接続する GC です。つまり、現実の制約を受けた上で設計されています。
狙いはかなり明確です。
- 調整項目をなるべく減らし、自動適応を重視する
- 長い停止を避ける
- 現実のランタイム機能を削らずに成立させる
ここで重要なのは、Satori が難しい機能を捨てて単純化しているわけではないことです。内部ポインタ、ファイナライゼーション、弱参照、dependent handle、collectible types、正確/保守的ルートスキャンといった現実の要件を前提にしています。
つまり Satori は、現実逃避ではなく「現実の制約の中で GC の仕事をどう分解し直すか」をやっているわけです。
Page と Region
Satori のヒープ構成は Page と Region を中心に組み立てられています。
-
Page: より大きな予約単位 -
Region:Pageの中にある、より細かい管理単位
Satori において Region は単なる物理分割ではありません。次の役割を兼ねています。
- 割り当て単位
- スレッドローカル所有権の単位
- Gen 0 のローカル回収単位
- 移動と整理の計画単位
- 空きメモリ返却時の処理単位
また、Page の周りにはカードテーブルやリージョンマップのような補助メタデータがあり、Region 側には「marked」「escaped」「pinned」などを表すビットマップがあります。Satori が後でスレッドローカル回収やエスケープ追跡を効率よく回せるのは、こうしたメタデータの設計があるからです。
Gen 0 をローカルな問題にする
Satori の一番大きな発想は、「新しく作られて、短命で、しかもほとんど現在のスレッドだけで使われるオブジェクト」まで毎回グローバルな問題にする必要はない、という点にあります。
Satori は小さいオブジェクトを割り当てるとき、通常の高速なスレッドごとの割り当てコンテキストを使います。ただし、スレッドは常にグローバルヒープから取得するのではなく、まず自分が握っている Region の中で割り当てます。
その Region にまだ空きがあれば、そのまま割り当てが続きます。もし Region がほぼ埋まっていても、Satori の最初の反応は「使い切った、グローバル GC に渡して新しいのを取ろう」ではありません。まずその Region がローカルにクリーンアップできる候補かどうかを確認します。
これが成り立つ背景には、現実のプログラムで非常によく見られるパターンがあります。
- オブジェクトは作られたばかり
- 寿命が短い
- ほとんど現在のスレッドの中だけで使われる
こうした条件を満たす Region であれば、回収をグローバルな問題にする必要はありません。当該スレッドがその Region の Gen 0 回収を自分で済ませられます。これが Satori のスレッドローカル Gen 0 です。
普通の Gen 0 回収は、結局はプロセス全体の都合を考えなければいけません。どのスレッドがどういう状態か、旧世代から若い世代への参照がどこにあるか、どのカードを見るべきか、どのグローバル構造を同期するか。Satori のスレッドローカル Gen 0 は、この問題を「現在のスレッド」と「現在の Region」にまで絞り込みます。
エスケープ追跡
もちろん、すべてのオブジェクトがずっとスレッドローカルのままでいるわけではありません。
たとえば、
- グローバルキャッシュへ入れる
- 別スレッドから見えるオブジェクトにぶら下げる
- キューへ積んで別スレッドに渡す
といったことをすると、そのオブジェクトは外へ「逃げた」と見なされます。これがエスケープです。
Satori は、スレッドローカルであることを盲信しません。オブジェクトがエスケープしたら、そのオブジェクト自身だけでなく、現在の Region 内でそこから到達可能な部分グラフも把握し続けます。
ただしエスケープが増えすぎたら、その Region をもうスレッドローカル Gen 0 と見なすのは割に合いません。そこで Satori はエスケープ量にしきい値を設け、しきい値を超えた Region はローカル回収から外し、より普通のグローバルな世代管理へ戻します。
このしきい値は両方の極端を避けるためにあります。
- エスケープが起きた瞬間にスレッドローカル Gen 0 を諦めると、まだ十分にローカル回収が有利な場面まで失ってしまう
- 逆に、どれだけエスケープが増えてもスレッドローカルを維持しようとすると、ローカル回収の旨味がどんどん薄れていく
Satori はこの 2 つの極端の間のバランスポイントを狙っています。
ではスレッドローカル回収では何をしているのか
スレッドローカル回収が速い理由は、やることが少ないからではなく、対象範囲が小さいからです。
大まかには次のような流れになります。
-
そもそも今回回収する価値があるかを判定する
逃げたオブジェクトが多すぎないか、前回の回収が近すぎないか、生存率が高すぎないかを見る。 -
より小さなルートセットからマークする
見るべきルートは、現在のスレッドのスタックと、そのRegionから外へエスケープしたオブジェクト経由で到達できる部分だけでよい。 -
整理計画を立てる
生存オブジェクトをどこへ寄せるか、後でどの参照を書き換えるかを決める。 -
参照を更新して局所的にコンパクションする
対象はヒープ全体ではなく、その小さなRegionの中だけ。
要するに、Satori は「もっとも頻繁な回収」を、プロセス全体の同期問題から、小さな Region の局所問題へ落としているわけです。
具体例
たとえば 1 本のリクエストを 1 本のスレッドが処理しているとします。リクエスト処理中には、コンテキスト、パース結果、中間オブジェクト、短命な文字列やリストが大量に作られます。
これらが主にそのスレッドの中だけで使われ、処理が終わったら捨てられるなら、Satori はそれらをスレッドローカルな Region の中で片付けられます。
逆に、その途中で一部のオブジェクトがグローバルキャッシュへ入ったり、別スレッドに渡されたりすれば、その Region はエスケープの多い領域として扱われるようになり、しきい値を超えればグローバル GC 側へ戻されます。
ここでも本質は同じです。局所性が高い間は局所性を最大限に活用し、局所性が崩れたらグローバル処理へ切り替える。それが Satori の基本戦略です。
グローバル GC
もちろん Satori はスレッドローカル回収だけで完結する GC ではありません。エスケープした Region、より古いオブジェクト、グローバルなメモリ圧力、Region 間の移動や整理といった話は、グローバル GC が担当します。ポイントは、ローカル回収だけで完結しようとしているのではなく、もっとも頻繁かつもっとも局所化しやすい仕事をまず引き剥がし、本当にグローバルでなければ処理できない仕事だけをグローバル GC に残すことです。
全体の流れとしては、他の GC と同じく、
- 生存オブジェクトをマークする
- どの
Regionを整理・移動するかを決める - 参照を更新する
- 必要なら移動・圧縮する
という段階を踏みます。
Satori の狙いは、これらのフェーズを消すことではなく、できるだけ多くの部分をアプリケーションと並行に実行することです。絶対的なゼロ停止を約束しているわけではありません。ヒープサイズに比例する重い仕事をブロッキングフェーズに積み上げないようにしているのです。
可動性をデフォルトにする GC との違い
ZGC や Shenandoah のような低レイテンシ GC は、もっと強い前提を採ります。オブジェクト移動を普段から成立させたいので、そのためのコストを通常のコードパスに載せます。
ZGC は、ポインタ自体に GC 状態を埋め込み、リードバリアを組み合わせることで、並行リロケーションを成立させます。世代別 ZGC ではこれに加えて書き込み側の追加処理も入ります。
Shenandoah は少し違って、各オブジェクトに間接参照の層を持たせることで、並行の移動・圧縮を成立させます。平常時に払うコストの形は ZGC と違いますが、「より強いランタイム機構を常時使う代わりに、低レイテンシを得る」という構図は同じです。
Satori はそこをデフォルトにしません。オブジェクトが常に並行移動可能であることを前提にせず、Region レベルの移動はあくまで選択的な機能であり、常時オンの義務ではありません。その代わり、最も頻繁に起きる Gen 0 回収を局所化します。
このトレードオフはかなり明快です。
- あらゆるオブジェクトが常に並行移動可能でなければならないなら、通常の実行パスは高くつきやすい
- 移動を必要なときだけ使う設計にすれば、通常の実行パスを安く保てる可能性が高い
Satori は後者を選んでいます。これが Satori と ZGC / Shenandoah の最大の設計差です。
アプリケーションスレッドにも手伝わせる
Satori には、アプリケーションスレッド自身に GC の進行を少し手伝わせる仕組みもあります。
もし割り当て速度が並行回収の進行速度を大きく上回ると、最後には重い停止で帳尻を合わせるしかなくなります。そこで Satori は、必要なときだけ、割り当てを行っているスレッド自身にも少量の GC 作業を肩代わりさせます。
これによって、
- 割り当てが回収を完全に置き去りにするのを防げる
- 最後に大きな停止をまとめて払う事態を避けやすい
という利点が得られます。つまり Satori は、並行処理のオーバーヘッドをオブジェクトアクセスのたびに均等にばらまくのではなく、本当に遅れそうなときだけアプリケーションスレッドに手伝いを頼む形を取っています。これがスループットと低レイテンシを両立させる重要なテクニックのひとつです。
低メモリ使用量をどう実現するのか
低レイテンシ GC が低メモリ使用量も達成するのは難しい。これは自然なことです。並行性を増やすと、補助メタデータ、余分なバッファ、保守的な予約など、メモリを消費する要素も増えやすいからです。
Satori がここを抑えられるのは、次の 3 点があるからです。
1. 短命オブジェクトを早く死なせる
短命でスレッドローカルなオブジェクトは、できるだけスレッドローカル Gen 0 の段階で死なせる。これにより、より古い世代が短命オブジェクトで汚れにくくなり、後続のグローバル GC が扱うライブセットも小さくなります。
2. 空きメモリを OS へ返す
Satori には、空になった Region を整理し、必要なくなったコミット済みメモリを OS へ返す専用の流れがあります。具体的には次のような処理を行います。
- 十分に空いた
Regionを特定する - 隣接する空き
Regionの結合を試みる - コミット状態を維持する必要がなくなったメモリを OS へ返す
重要なのは、この処理がレートリミット付きであることです。スキャンと返却のペースを制御し、わずかなフットプリント削減のためにシステムをスラッシングさせないようにしています。
3. メタデータを増やしすぎない
Satori は、オブジェクトヘッダ付近の未使用領域を活用して、一時的なリンク情報や転送先情報を持たせる作りを採っています。つまり、便利だからといって外側に巨大な補助テーブルを無制限に増やすのではなく、既存レイアウトをかなり節約的に使っています。
では、なぜ 3 つを同時に狙えるのか
ここまでの話をまとめると、Satori が高スループット・低レイテンシ・低メモリ使用量を同時に狙える理由は次の 3 本柱に整理できます。
低レイテンシ
もっとも頻繁な回収をグローバル問題にせず、スレッドローカル Gen 0 として小さな Region に閉じ込める。これにより STW の頻度と大きさを減らしやすい。
高スループット
ZGC や Shenandoah のように、オブジェクトアクセスのたびに重い仕組みを必ず通す設計をデフォルトにしない。その代わり、局所性の高い Gen 0 回収を低コストにし、必要なときだけペーシングで補う。
低メモリ使用量
短命オブジェクトを早めに回収し、空いた Region を整理して OS へ返し、しかもメタデータも節約する。この 3 つを同時にやっているので、単に「低レイテンシだけどメモリは大きい」という形で終わりにくい。
他の GC と比べると何が違うのか
Workstation GC / Server GC
どちらも同じ系統の GC で、世代別、ライトバリア、カードテーブル、フォアグラウンド Gen 0/Gen 1 回収、より重い Gen 2 回収という基本の流れは共通です。
- Workstation GC はより単純で、リソース消費を抑えやすい
- Server GC は各論理プロセッサにより強い GC 資源を割り当て、スループットを上げる
違いは並行度と資源の使い方であって、「もっとも頻繁な若い世代の回収をグローバルでやる」点は変わりません。Satori はまさにその部分を変えようとしています。
DATAS
DATAS は新しい GC 構造というより、既存の Server GC をより賢く使うためのポリシーレイヤーです。具体的には次のような問いに答えようとします。
- このアプリケーションにどれだけのヒープ予算を与えるべきか
- ヒープをいくつ使うべきか
- Gen 0 の成長をどう制御すべきか
- ヒープサイズを長寿命データの実際の量にどう近づけるか
つまり DATAS はポリシーを変えるのであって、メカニズムを変えるわけではありません。既存の Server GC をより賢くしますが、「もっとも頻繁な若い世代の回収がグローバルパスで実行される」という事実は変わりません。
Satori が取り組んでいるのは別のレベルの問題です。もっとも頻繁な若いオブジェクトの回収を、そもそもグローバルな操作にしないで済ませられないか、という問いです。
G1
G1 も Region ベースですが、Region の使い方が違います。G1 のコアモデルは次のようなものです。
- ヒープを多数の固定サイズ
Regionに分割する -
Region間の参照を remembered set で追跡する - スナップショット方式の並行マーキングとライトバリアで並行マーキングを支える
- 回収時には、選ばれた
Regionから生存オブジェクトを別の場所にコピーする
つまり G1 の Region は主にグローバルなバランスと停止目標制御のための単位です。どの Region をコレクションセットに入れるか、どこを混合コレクションの対象にするか、どこから生存オブジェクトを退避するか。すべてグローバルなスケジューリングを中心に回っています。
Satori でも Region を使いますが、その抽象化をさらに押し進めています。Region は単なるスケジューリング単位ではなく、スレッドローカル所有権、エスケープ追跡、局所回収の境界でもあります。これが Satori と G1 の設計思想上のもっとも大きな違いです。
ZGC / Shenandoah
この 2 つの低レイテンシ GC は、別の路線を選んでいます。
共通しているのは、通常の実行パスにより強いランタイム機構を載せる代わりに、より安定した並行リロケーション能力を得ようとする点です。ただし具体的な仕組みは異なります。
ZGC のコア設計は、ポインタ自体に GC 状態を直接エンコードし、リードバリアと組み合わせるものです。世代別 ZGC では、さらに書き込み側のバリア機構も追加されています。目標は非常に明確で、並行リロケーションをデフォルトにすることで、STW 時間がヒープサイズとともに増大しないようにすることです。
Shenandoah はオブジェクト間接参照 + 並行退避・圧縮に近い設計です。各オブジェクトに間接参照の層を 1 つ余分に持たせ、並行移動を成立させます。平常時のコストプロファイルは ZGC と同一ではありませんが、原則は同じです。より強いランタイム機構を使う代わりに、より強い低レイテンシ特性を得るという構図です。
Satori はこの路線をデフォルトにはしません。オブジェクトが常に並行移動可能であることを前提にせず、リロケーションを選択的にし、もっとも頻繁な Gen 0 回収を局所化します。つまり Satori は、ZGC のように毎回のオブジェクト読み取りで追加チェックを払う必要も、Shenandoah のように毎オブジェクトに間接参照のコストをデフォルトで払う必要もありません。
したがって Satori と ZGC / Shenandoah の違いは、どちらがより攻めているかという話ではありません。コストを通常のアクセスパスに置くか、それとも回収の構造とスコープの設計に置くかという違いです。
まとめ
Satori が本当に面白いのは、「もっとも頻繁な回収は本当にグローバルである必要があるのか?」という問いを中心に GC を組み直しているところです。
その結果として、
- 短命オブジェクトはスレッドローカル Gen 0 でなるべく早く処理する
- エスケープが増えたら素直にグローバルパスへ戻す
- 移動は選択的にして、通常時のコストを必要以上に増やさない
- しかも空きメモリ返却とメタデータ節約でフットプリントも抑える
という構造になっています。
だから Satori は、既存 GC の単なる延長線上にも、ZGC / Shenandoah の単純な .NET 移植にも見えません。かなり独自の設計です。
もしこの方向が十分に成熟すれば、.NET にとって「もう 1 個実験的な GC が増える」以上の意味を持つと思います。GC の設計空間そのものを広げる可能性があるからです。