メインコンテンツへ飛ぶ

Electron と V8 メモリケージ

· 読むのにかかる時間 1 分

Electron 21 以降では V8 メモリケージが有効になり、一部のネイティブモジュールに影響を及ぼします。


更新 (2022/11/01)

Electron 21+ のネイティブモジュール使用に関する進行中の議論については、electron/electron#35801 をご覧ください。

Electron 21 では、Electron にて V8 のサンドボックス化ポインタ が有効になります。これは Chrome の Chrome 103 での同様の決定 に従ったものです。 これはネイティブモジュールにいくつかの影響を与えます。 また、以前には関連する技術である ポインタ圧縮 を Electron 14 で有効にしていました。 当時はあまり話題になりませんでしたが、ポインタ圧縮は V8 の最大ヒープサイズに影響を及ぼします。

この 2 つの技術を有効にすることで、セキュリティ、パフォーマンス、メモリ使用量に大きなメリットがあります。 しかし、それらを有効化するにあたっていくつかの欠点もあります。

サンドボックス化ポインタを有効にする主な欠点は、外部 ("ヒープ外") のメモリを指す ArrayBuffer が許可されなくなる ことです。 つまり、V8 でこの機能に依存しているネイティブモジュールは、Electron 20 以降でも引き続き動作するようにリファクタする必要があります。

ポインタ圧縮を有効にする主な欠点は、V8 ヒープのサイズが最大 4GB に制限される ことです。 正確な詳細は少し複雑です。例えば、ArrayBuffer は V8 ヒープの他の部分とは別にカウントされますが、それ自体の制限 はあります。

Electron のアップグレードのワーキンググループ は、ポインタ圧縮と V8 メモリケージのメリットはデメリットを上回ると考えています。 主な理由は次の 3 つです。

  1. Electron が Chromium に近づきます。 V8 の設定など複雑な内部の詳細について Electron が Chromium からあまる乖離しなければ、誤ってバグやセキュリティ上の脆弱性を導入する可能性は低くなります。 Chromium のセキュリティチームは侮れない素晴らしさであり、彼らの成果を確実に生かしたいのです。 さらに、あるバグが Chromium で使用されていない設定にしか影響しない場合、そのバグ修正は Chromium チームにとって優先されないでしょう。
  2. パフォーマンスが改善します。 ポインタ圧縮により、V8 ヒープサイズを最大 40% 削減し CPU と GC の性能を 5%-10% 向上させます。 Electron アプリケーションの大部分は、4GB のヒープサイズ制限にぶつかることはなく、外部バッファを必要とするネイティブモジュールも使用しないため、これらは性能面で大きなメリットとなります。
  3. よりセキュアになります。 Electron アプリの中には信頼できない JavaScript を実行しているものがあります (できれば セキュリティに関する推奨事項 に従ってください!)。そういったアプリでは V8 メモリケージを有効にすることで、V8 の厄介な脆弱性を持つ巨大クラスからそれらを保護できます。

最後に、どうしても大きなヒープサイズが必要なアプリのための回避策をご紹介します。 例えば、ポインタ圧縮を無効にしてビルドしたアプリに Node.js のコピーを同梱し、メモリ負荷の大きい作業を子プロセスに移行させることが可能です。 またやや複雑ではありますが、ポインタ圧縮を無効にしたカスタム版の Electron を作成することも可能です。その場合、特定のユースケースに対して別のトレードオフが必要になります。 そして最後に、そう遠くない将来、wasm64 により WebAssembly で構築されたアプリが 4GB を超える巨大なメモリをウェブと Electron の両方で利用できるようになる予定です。


FAQ

アプリがこの変更の影響を受けるかどうかは、どうすればわかりますか?

Electron 20 以降で外部メモリを ArrayBuffer でラップしようとすると、実行時にクラッシュします。

アプリでネイティブ Node モジュールを使用していない場合は安全です。純粋な JS からこのクラッシュを引き起こす方法はありません。 この変更は、V8 ヒープ外でのメモリ割り当て (例えば mallocnew の使用) を行い、その外部メモリを ArrayBuffer でラップしているネイティブ Node モジュールにのみ影響します。 これはかなり稀なユースケースですが、一部のモジュールではこの手法が使われており、そのようなモジュールは Electron 20 以降と互換性を持たせるためのリファクタが必要です。

どうすればアプリが使用している V8 ヒープメモリの量を測定して、4GB の制限に近づいているか調べられますか?

レンダラープロセスでは、performance.memory.usedJSHeapSize を使用すると V8 ヒープの使用量をバイト単位で返します。 メインプロセスでは、同じように process.memoryUsage().heapUsed を利用できます。

V8 メモリケージとは何ですか?

ドキュメントによっては「V8 サンドボックス」と呼んでいますが、この用語は Chromium で起こる 他の種類のサンドボックス と混同しやすいので、「メモリケージ」という用語にしておこうと思います。

V8 のエクスプロイトの多くは、以下のようなものです。

  1. V8 の JIT エンジンのバグを見つけます。 JIT エンジンはコードを解析し、実行時の遅い型チェックを省略して高速なマシンコードを生成します。 時々この解析を間違ってしまう、論理エラーが起こります。本当は必要な型チェックを省略してしまうのです。例えば、x が文字列だと考えていたのに実際はオブジェクトだったということが起こります。
  2. この混乱を悪用して、例えば ArrayBuffer の先頭へのポインタなど、V8 ヒープ内のメモリの一部を上書きします。
  3. これで好きな場所を指す ArrayBuffer ができたので、プロセス中の 任意の メモリ、たとえ V8 が通常アクセスできないメモリでも読み書きできてしまいます。

V8 メモリケージは、このような攻撃を無条件に防ぐために考案された技術です。 これを実現する方法は、V8 ヒープにポインターを保存しない ことです。 代わりに、V8 ヒープ内の他のメモリへの参照はすべて、ある予約領域の先頭からのオフセットとして格納されます。 このため、例えば V8 における型の混乱を利用して ArrayBuffer の基底アドレスを変更したとしても、最悪ケージ内のメモリの読み書きができるだけです。 V8 メモリケージがどのように機能するかについてはもっと多くの文献がありまので、ここではこれ以上の詳細には触れません。読み始めるのに最も適しているのは、Chromium チームによる 上位設計ドキュメント でしょう。

Node ネイティブモジュールをリファクタして Electron 21 以降をサポートしたいです。 どうすればいいですか?

V8 のメモリケージに対応するためにネイティブモジュールをリファクタするには、2 つの方法があります。 1 つ目は、外部で作成したバッファを JavaScript に渡す前に コピー して V8 メモリケージに入れることです。 これは一般的に簡単なリファクタですが、バッファが大きいときには遅くなることがあります。 2 つ目は、V8 のメモリアロケータ を使って最終的に JavaScript に渡す予定のメモリを確保する方法です。 これは少し複雑ですが、コピーを回避でき、大きなバッファでのパフォーマンスが向上します。

具体例として、外部の配列バッファを使用する N-API モジュールを以下に示します。

// 外部で確保されたバッファを作成します。
// |create_external_resource| は malloc() によってメモリを確保します。
size_t length = 0;
void* data = create_external_resource(&length);
// Buffer でラップ -- これはメモリケージが有効だと失敗します!
napi_value result;
napi_create_external_buffer(
env, length, data,
finalize_external_resource, NULL, &result);

これはメモリケージが有効な場合、データがケージの外で確保されているためクラッシュします。 代わりにデータをケージへコピーするようにリファクタすると、以下のようになります。

size_t length = 0;
void* data = create_external_resource(&length);
// データを V8 が確保したメモリにコピーして、新しい Buffer を作成します
napi_value result;
void* copied_data = NULL;
napi_create_buffer_copy(env, length, data, &copied_data, &result);
// この新しいコピーにアクセスする際には、|copied_data| が
// そのポインタとなります!

これにより、V8 メモリケージ内にある新たに確保されたメモリ領域へデータがコピーされます。 任意で、N-API は新しくコピーされたデータへのポインタも提供できます。これは、後からデータを修正したり参照したりする必要がある場合に利用できます。

V8 のメモリアロケータを利用するリファクタリングは少し複雑で、malloc を使う代わりに V8 によって割り当てられたメモリを使うように create_external_resource 関数を修正する必要があります。 これは create_external_resource の定義が制御下にあるかどうかによって、多少は実現可能かもしれません。 考え方としては、まず napi_create_buffer など V8 でバッファを作成して、その V8 が確保したメモリにリソースを初期化することになります。 Buffer オブジェクトへの napi_refリソースのライフタイム の間保持することが重要です。さもないと、V8 は Buffer をガベージコレクトし、解放後に使用してしまうエラーを引き起こす可能性があります。