メインコンテンツまでスキップ

C++用の高性能ガベージコレクション

· 約14分
Anton Bikineev、Omer Katz([@omerktz](https://twitter.com/omerktz))、およびMichael Lippautz([@mlippautz](https://twitter.com/mlippautz))、C++メモリウィスパラー

これまでに、JavaScript用ガベージコレクション、ドキュメントオブジェクトモデル(DOM)、およびこれらすべてがV8でどのように実装および最適化されているかについて何度か 記述して きました。しかし、ChromiumのすべてがJavaScriptというわけではありません。というのも、V8が組み込まれているブラウザおよびそのBlinkレンダリングエンジンのほとんどがC++で記述されているからです。JavaScriptはレンダリングパイプラインによって処理されるDOMとのインタラクションに使用できます。

DOM周辺のC++オブジェクトグラフがJavaScriptオブジェクトと非常に絡み合っているため、数年前にChromiumチームはOilpanと呼ばれるガベージコレクターに切り替えて、この種のメモリを管理することにしました。OilpanはC++で記述されたガベージコレクターで、クロスコンポーネントトレースを使用してV8に接続でき、絡み合ったC++/JavaScriptオブジェクトグラフを一つのヒープとして扱います。

この投稿は、Oilpanのコア原則およびそのC++ APIの概要を提供するシリーズブログ投稿の最初のものです。この投稿ではサポートされているいくつかの機能を紹介し、それがガベージコレクターのさまざまなサブシステムとどのように相互作用するかを説明し、スイーパーでオブジェクトを並行して回収する方法に深く掘り下げます。

最も興味深いのは、Oilpanが現在Blinkに実装されていますが、ガベージコレクションライブラリの形式でV8に移行していることです。その目標は、すべてのV8エンベッダーおよび一般のC++開発者が簡単にC++ガベージコレクションを利用できるようにすることです。

背景

OilpanはMark-Sweepガベージコレクターを実装しています。ガベージコレクションは、マネージドヒープ内のライブオブジェクトをスキャンするマークフェーズと、マネージドヒープの死んだオブジェクトを回収するスイープフェーズの2つのフェーズに分かれています。

V8での並行マークの紹介を行う際に、すでにマークの基本を説明しました。簡単に言うと、すべてのオブジェクトをスキャンしてライブなオブジェクトを探すことは、ノードがオブジェクトでエッジがオブジェクト間のポインターであるグラフのトラバーサルと見なすことができます。トラバーサルは、ルート(レジスタ、ネイティブ実行スタック(今後は単にスタックと呼びます)、およびその他のグローバル)から開始されます。この詳細はこちらで説明しています。

この点では、C++はJavaScriptと違いはありません。ただし、JavaScriptとは異なり、C++オブジェクトは静的に型指定されているため、実行時にその表現を変更することはできません。Oilpanを使用して管理されるC++オブジェクトは、この事実を活用して、ビジターパターンを介して他のオブジェクト(グラフ内のエッジ)へのポインターの記述を提供します。Oilpanオブジェクトを記述する基本的なパターンは以下のとおりです。

class LinkedNode final : public GarbageCollected<LinkedNode> {
public:
LinkedNode(LinkedNode* next, int value) : next_(next), value_(value) {}
void Trace(Visitor* visitor) const {
visitor->Trace(next_);
}
private:
Member<LinkedNode> next_;
int value_;
};

LinkedNode* CreateNodes() {
LinkedNode* first_node = MakeGarbageCollected<LinkedNode>(nullptr, 1);
LinkedNode* second_node = MakeGarbageCollected<LinkedNode>(first_node, 2);
return second_node;
}

上記の例では、LinkedNodeGarbageCollected<LinkedNode>を継承していることによって、Oilpanによって管理されています。ガベージコレクターがオブジェクトを処理する際に、そのTraceメソッドを呼び出して出て行くポインターを発見します。型Memberはスマートポインターで、std::shared_ptrに文法的に似ていますが、Oilpanによって提供され、マーク中のグラフトラバーサル時に一貫した状態を維持するために使用されます。これにより、Oilpanは管理されているオブジェクト内でポインターがどこに存在しているのかを正確に把握することができます。

熱心な読者はおそらく気づいたでしょうし、恐れているかもしれませんが、上記の例で first_nodesecond_node がスタック上で生の C++ ポインタとして保持されていることです。Oilpan はスタック操作に対する抽象化を提供せず、ルートを処理する際に管理されたヒープ内のポインタを見つけるために保守的なスタックスキャンのみに依存しています。これはスタックを単語ごとに反復し、その単語を管理ヒープ内のポインタとして解釈することで機能します。これにより、スタックに配置されたオブジェクトへのアクセスによるパフォーマンスペナルティを Oilpan は課しません。その代わり、スタックを保守的にスキャンするゴミ収集時間にコストを移します。レンダラに統合された Oilpan は、興味深いスタックがないことが保証される状態になるまでゴミ収集を遅らせようとします。ウェブはイベントベースであり、イベントループでタスクを処理することで実行されるため、そのような機会は豊富にあります。

Oilpan は、多くの成熟したコードを持つ大規模な C++ コードベースである Blink で使用され、以下をサポートしています。

  • ミックスインを介した多重継承およびそれらのミックスインへの参照(内部ポインタ)。
  • コンストラクタを実行中にゴミ収集をトリガーする機能。
  • Persistent スマートポインタを通じて非管理メモリからオブジェクトを保持する機能(これらはルートとして扱われます)。
  • 順次(例:ベクター)および連想(例:セットやマップ)コンテナをカバーするコレクション、さらにコレクションバックアップの圧縮。
  • 弱参照、弱コールバック、および エフェメロン
  • 個々のオブジェクトを解放する前に実行されるファイナライザーコールバック。

C++のためのスイーピング

Oilpan におけるマークの仕組みに関する詳細なブログ投稿をお楽しみに。この記事では、マークが完了しており、Oilpan が Trace メソッドを使って全ての到達可能なオブジェクトを発見したと仮定します。マークフェーズが終了すると、全ての到達可能なオブジェクトのマークビットが設定されます。

スイープフェーズでは、死んでいる(マーク中に到達できなかった)オブジェクトが解放され、その基礎となるメモリがオペレーティングシステムに返却されるか、次の割り当てに利用可能になります。以下では、Oilpan のスイーパーがどのように機能しているか、その使用方法と制約の観点、さらに高い回収スループットを達成する方法について説明します。

スイーパーはヒープメモリを反復処理し、マークビットをチェックすることで死んでいるオブジェクトを見つけます。C++ のセマンティクスを保つために、メモリを解放する前に、各死んだオブジェクトのデストラクタを呼び出す必要があります。非自明なデストラクタはファイナライザーとして実装されています。

プログラマーの観点からは、デストラクタが実行される順序に定義はありません。スイーパーが使用する反復処理は構築順序を考慮しないためです。この制約により、ファイナライザーは他のヒープ上のオブジェクトに触れてはならないという制限が課されます。これは、ファイナライゼーション順序を必要とするユーザーコードを書く際の一般的な課題です。管理対象言語では通常、ファイナライゼーションセマンティクスに順序をサポートしません(例:Java)。Oilpan は Clang プラグインを使用して、オブジェクトの破壊中にヒープオブジェクトにアクセスしていないことを含む多くの点を静的に検証します。

class GCed : public GarbageCollected<GCed> {
public:
void DoSomething();
void Trace(Visitor* visitor) {
visitor->Trace(other_);
}
~GCed() {
other_->DoSomething(); // error: Finalizer '~GCed' accesses
// potentially finalized field 'other_'.
}
private:
Member<GCed> other_;
};

興味がある方へ:Oilpan は、オブジェクト破壊前にヒープへのアクセスが必要な複雑な使用ケース向けに、事前ファイナライゼーションコールバックを提供しています。ただし、これらのコールバックは各ゴミ収集サイクルでデストラクタよりも多くのオーバーヘッドを課すため、Blink では慎重に使用されています。

インクリメンタルおよび並行スイーピング

管理された C++ 環境におけるデストラクタの制約に関して説明したので、Oilpan がスイープフェーズをどのように実装し、最適化しているかをさらに詳しく見てみましょう。

詳細に入る前に、ウェブ上でプログラムが一般的にどのように実行されるかを思い出すことが重要です。どのような実行、例えば JavaScript プログラムやゴミ収集も、イベントループ 内でタスクをディスパッチすることでメインスレッドから駆動されます。レンダラーは、その他のアプリケーション環境と同様に、メインスレッドの作業を支援する並行して実行されるバックグラウンドタスクをサポートしています。

最初は単純な方法から始まり、Oilpan は元々、メインスレッド上でアプリケーションを妨げるゴミ収集終了の一時停止の一部として実行されるストップザワールドスイーピングを実装していました。

Stop-the-world sweeping

ソフトリアルタイム制約のあるアプリケーションにおいて、ゴミ収集を扱う際の決定的な要因はレイテンシです。ストップザワールドスイーピングは、ユーザーが目にするアプリケーションのレイテンシを引き起こす可能性のある重大な一時停止時間を誘発することがあります。次のステップとしてレイテンシを削減するため、スイーピングはインクリメンタル化されました。

Incremental sweeping

増分方式では、スイープ操作が分割され、追加のメインスレッドタスクに委任されます。理想的には、これらのタスクは完全にアイドル時間に実行され、通常のアプリケーション実行に干渉しないようになります。内部的には、スイーパーはページという概念に基づいて作業を小さな単位に分割します。ページには2つの興味深い状態があります。スイーパーがまだ処理する必要があるスイープ待ちのページと、スイーパーがすでに処理したスイープ済みのページです。メモリアロケーションはスイープ済みのページのみを考慮し、利用可能なメモリチャンクのリストを維持する空きリストからローカルアロケーションバッファ (LAB) を補充します。アプリケーションが空きリストからメモリを取得する際には、まずスイープ済みのページでメモリを探し、それからスイープ待ちのページの処理を手伝うため、スイープアルゴリズムをアロケーションにインライン化します。そして、利用可能なメモリがない場合のみ、OSに新しいメモリを要求します。

Oilpanは何年も増分スイープを使用してきましたが、アプリケーションとその結果としてのオブジェクトグラフが大きくなるにつれて、スイープがアプリケーションのパフォーマンスに影響を与えるようになりました。増分スイープを改善するために、メモリ回収を並行して行うためのバックグラウンドタスクを活用し始めました。スイーパーを実行するバックグラウンドタスクと新しいオブジェクトを割り当てるアプリケーションとの間でデータ競合を排除するために、2つの基本的な不変条件が使用されています:

  • スイーパーはアプリケーションに到達不可能であると定義される、死んだメモリのみを処理します。
  • アプリケーションはスイープ済みのページのみを使用して割り当てを行います。これらのページはスイーパーによって処理されなくなっていると定義されています。

これらの不変条件により、オブジェクトとそのメモリを争う競合が発生しないことが確保されます。しかし、C++ではデストラクタがファイナライザとして実装されているため、強く依存しています。Oilpanは、開発者を支援し、アプリケーションコード内のデータ競合を排除するために、ファイナライザがメインスレッド上でのみ実行されるよう強制します。この問題を解決するために、Oilpanはオブジェクトのファイナライゼーションをメインスレッドに延期します。具体的には、並行スイーパーがファイナライザ(デストラクタ)を持つオブジェクトを見つけた場合、それをファイナライゼーションキューにプッシュし、このキューは別のファイナライゼーションフェーズで処理され、そのフェーズはアプリケーションを実行するメインスレッド上で常に実行されます。並行スイープを使用した全体的なワークフローは次のようになります:

バックグラウンドタスクを使用した並行スイープ

ファイナライザがオブジェクトのすべてのペイロードにアクセスする必要がある場合、対応するメモリを空きリストに追加する操作はファイナライザの実行後まで遅延されます。ファイナライザが実行されない場合は、バックグラウンドスレッド上で実行されるスイーパーが回収したメモリを即座に空きリストに追加します。

結果

バックグラウンドスイープはChrome M78で導入されました。我々の実世界ベンチマークフレームワークでは、メインスレッド上のスイープ時間が25%-50%削減され(平均で42%)、以下に選ばれた項目を示します。

メインスレッドのスイープ時間(ミリ秒単位)

メインスレッド上で費やされる残りの時間はファイナライザの実行に使用されます。Blink内で大量にインスタンス化されるオブジェクトタイプのファイナライザを削減する現在進行中の作業があります。ここで興味深いのは、これらの最適化がすべてアプリケーションコード内で行われることで、ファイナライザがない場合はスイープが自動的に調整される点です。

C++のガベージコレクション全般やOilpanライブラリの更新についてのさらなる投稿をお楽しみにしてください。V8のすべてのユーザーが利用できるリリースに近づくにつれて情報を提供していきます。