Goは、オブジェクトが白(未マーク)から灰(キューに載せられている)を経て黒(完全にスキャン済み)に移行する三色同時ガベージコレクタを採用しています。マーク中の基本的な不変条件は、黒いオブジェクトは決して白いオブジェクトへのポインタを含んではならないということです。さもなければ、コレクタが到達可能なメモリを間違って解放してしまう可能性があります。この不変条件を世界を停止せずに守るために、Goは書き込みバリアを使用しています。これは、ヒープに対するポインタ書き込みのたびにトリガーされるコンパイラ挿入フックです。ミュテータゴルーチンがポインタ書き込みを実行すると、バリアは対象オブジェクトが白であるかをチェックします。もしそうであれば、書き込みを完了する前に即座に対象を灰色にし、原子性を持って不変条件を保持します。
私たちは、毎秒数百万のイベントを処理するリアルタイム分析パイプラインにおいて深刻なテールレイテンシを観察しました。このシステムは、ノードがストリーミングデータに基づいて子ノードへの参照を頻繁に更新する複雑なグラフ構造を使用しており、GoのGCサイクル中にポインタが大量に頻繁に変わることが原因で、すさまじいポインタのちらつきを引き起こしていました。
最初に考えた解決策: 私たちは、コレクションを遅らせるためにGOGCを200%に引き上げることでこれを軽減しようとしました。 長所: GCサイクルの頻度を減らし、時間の経過と共にバリアの実行回数を低下させました。 短所: これによりピークヒープサイズが大幅に増加し、メモリ制約のあるコンテナでOOMクラッシュのリスクが高まり、単にレイテンシのスパイクを解決するのではなく、先延ばしにしただけでした。
第二の解決策: 私たちは、ノード構造体を再利用して割り当てを減らすためにsync.Poolを使用したオブジェクトプールを試みました。 長所: 割り当て圧力と新しい白オブジェクトが作成される速度が低下しました。 短所: 書き込みバリアのオーバーヘッドは依然として高く、既存の(しばしば既にスキャンされた)黒いオブジェクト内のポインタを同じ速度で変化させ続けていたため、プールはポインタ更新時のバリア実行のコストを解決しませんでした。
第三の解決策: 私たちは、ノードの関係に直接ポインタの代わりに大きなスライスへの整数インデックスを使用するようにグラフをリファクタリングしました。 長所: 整数の割り当てはポインタの書き込みではなく、書き込みバリアメカニズムを完全に回避し、マーク中の関連するCPUコストを排除しました。 短所: スライスの手動メモリ管理を実装する必要があり(穴やコンパクションの処理)、コードがよりイディオマティックでなくなり、維持が難しくなりました。
選択された解決策: 私たちは、急速に変化するコアグラフに対してインデックスベースのアプローチを採用し、静的メタデータのためにはポインタを保持しました。これにより、グラフ接続性のセマンティクスを保持しつつ、書き込みバリアのホットパスが直接排除されました。
結果: GC中のテールレイテンシが90%低下し、15msから1.5msになり、全体的なスループットが40%向上しました。これは、GCアシスト作業がミュテータからCPUを奪うことが減ったためです。
なぜ書き込みバリアは修正されるオブジェクトではなく、ポインティングされるオブジェクトをシェードするのか?
候補者はしばしば、バリアがソースオブジェクト(書き込まれる方)を再スキャンが必要なものとしてマークすべきだと誤解します。しかし、ソースは既に灰色または黒です; 黒であれば、それを再スキャンするのは高コストであり、すべての向きポインタを追跡する必要があります。対照的に、ターゲット(新しいポインタ値)を灰色にすることで三色不変条件を満たします: ソースが黒で、ターゲットが白であれば、そのエッジは黒から灰に変わり、安全です。この区別は重要で、作業を最小限に抑える(新しいターゲットのみがキューに載せられる)ため、巨大なソースオブジェクトを再スキャンする必要はありません。
書き込みバリアはスタックの割り当てとどのように相互作用し、なぜスタックは再スキャンが必要かもしれないのか?
書き込みバリアは主にヒープポインタの書き込みを中断しますが、Goはスタックからヒープへのポインタも処理しなければなりません。もしゴルーチンが黒いスタックフレームに白いヒープオブジェクトへのポインタを書き込むと、書き込みバリアがターゲットをシェードするために実行されます。しかし、スタックが成長したり、縮小したり、コピーされたりする可能性があるため、すべてのスタックスロットに対して正確な黒/白状態を維持するのは複雑です。Goは、マーク中にアクティブであった場合、マークフェーズの終了時に再スキャンが必要なルートとしてスタックを扱うことでこれを解決します。候補者は、スタック上の書き込みバリアが同時実行のために不変条件を保証できない場合の必要なフォールバックがスタックの再スキャンであることを見逃しがちで、この最終的なストップ・ザ・ワールドフェーズは通常短時間ながらも正確性のために重要です。
Dijkstra書き込みバリアとYuasa書き込みバリアの違いは何で、Goはどちらを使用していますか?
Dijkstraバリアは、ポインタがインストールされるときにターゲットオブジェクトをシェードします(黒いミュテータ、白いターゲット)、これにより黒から白のエッジが存在することは決してありません。一方、Yuasaバリアは、古いポインタ値が上書きされるのを記録し、それをシェードし、「開始時のスナップショット」特性を保持します。Goは、Dijkstraの選択が簡単で、強い三色不変条件をすぐに保証するため、ハイブリッドDijkstraバリアを使用していますが、白いオブジェクトがシェードされた直後に到達不可能になると、浮遊ゴミが発生する可能性があります。候補者はしばしばこれを混同したり、Goが保守的なスタック処理のためにYuasaを使用していると信じたりしますが、Dijkstraの選択を理解することで、Goのバリアがログベースではなく書き込みと同期している理由を説明できます。