Gmail 規模でのメモリの効果的な管理

Loreena Lee
Loreena Lee

はじめに

JavaScript はガベージ コレクションを使用して自動メモリ管理を行いますが、アプリケーションで効果的なメモリ管理の代替手段にはなりません。JavaScript アプリケーションでも、ネイティブ アプリケーションと同じメモリ関連の問題(メモリリークや肥大化など)が発生しますが、ガベージ コレクションの一時停止にも対処する必要があります。Gmail のような大規模なアプリケーションでは、小規模なアプリケーションで直面する問題と同じ問題に直面します。ここでは、Gmail チームが Chrome DevTools を使用してメモリの問題を特定、隔離、修正した方法をご紹介します。

Google I/O 2013 セッション

この資料は Google I/O 2013 で発表されました。下の動画をご覧ください。

Gmail に問題が発生しました...

Gmail チームは深刻な問題に直面していました。リソースに制約のあるノートパソコンやデスクトップ パソコンで数ギガバイトのメモリを消費する Gmail のタブがますます頻繁に聞かれ、ブラウザ全体がダウンするという結論が頻繁に寄せられるようになりました。CPU が 100% に固定される、応答しないアプリ、Chrome の悲しいタブに関するストーリー(「He's dead, Jim.」)。チームは、問題を解決するどころか、問題を診断する方法さえもわからなくなっていました。彼らは問題がどれほど広まっているかわからず、利用可能なツールが大規模なアプリケーションにスケールアップされませんでした。チームは Chrome チームと協力してメモリの問題をトリアージする新しい手法を開発し、既存のツールを改善し、現場からメモリデータを収集できるようにしました。ツールの説明に入る前に、JavaScript のメモリ管理の基本を説明しておきましょう。

メモリ管理の基本

JavaScript でメモリを効果的に管理する前に、基礎を理解しておく必要があります。このセクションでは、プリミティブ型とオブジェクト グラフについて説明し、メモリ肥大化全般と JavaScript のメモリリークの定義を示します。JavaScript のメモリはグラフとして概念化できます。そのため、このグラフ理論が JavaScript のメモリ管理とヒープ プロファイラの役割を果たします。

プリミティブ型

JavaScript には次の 3 つのプリミティブ型があります。

  1. 数値(例: 4、3.14159)
  2. Boolean(trueまたはfalse)
  3. 文字列(「Hello World」)

これらのプリミティブ型は他の値を参照できません。オブジェクト グラフでは、これらの値は常にリーフノードまたは終端ノードです。つまり、これらの値に発信エッジはありません。

コンテナタイプはオブジェクトのみです。JavaScript では、オブジェクトは連想配列です。空でないオブジェクトは、他の値(ノード)への出側エッジを持つ内部ノードです。

配列とは

JavaScript の配列は、実際には数値キーを持つオブジェクトです。JavaScript ランタイムでは配列のようなオブジェクトが最適化され、内部で配列として表現されるため、これは単純化です。

用語

  1. 値 - プリミティブ型、オブジェクト、配列などのインスタンス。
  2. 変数 - 値を参照する名前。
  3. プロパティ - 値を参照するオブジェクト内の名前。

オブジェクト グラフ

JavaScript のすべての値はオブジェクト グラフの一部です。グラフはルート(ウィンドウ オブジェクトなど)から始まります。GC ルートはブラウザによって作成され、ページがアンロードされたときに破棄されるため、GC ルートの存続期間を管理することはできません。グローバル変数は、実際にはウィンドウのプロパティです。

オブジェクト グラフ

値がゴミになるタイミング

ルートから値へのパスがない場合、値はガベージになります。つまり、ルートからスタック フレームに存在するすべてのオブジェクト プロパティと変数を徹底的に検索しても、値に到達できず、ガベージになってしまいます。

ガベージグラフ

JavaScript のメモリリークとは

JavaScript のメモリリークは、ページの DOM ツリーからアクセスできない DOM ノードがあるにもかかわらず、JavaScript オブジェクトによって参照されている場合によく発生します。最新のブラウザでは、不注意によるリークの発生がますます困難になっていますが、それでも人々が想像するほど簡単なことではありません。次のように DOM ツリーに要素を追加するとします。

email.message = document.createElement("div");
displayList.appendChild(email.message);

表示リストからこの要素を削除します。

displayList.removeAllChildren();

email が存在している限り、メッセージが参照する DOM 要素は、ページの DOM ツリーからデタッチされても削除されません。

Bloat とは

最適なページ速度を達成するために必要な以上のメモリを使用すると、ページが肥大化します。間接的に、メモリリークも肥大化の原因になりますが、これは設計によるものではありません。サイズ制限のないアプリケーション キャッシュは、メモリ肥大化の一般的な原因です。また、ホストデータ(画像から読み込まれたピクセルデータなど)によってページが肥大化することがあります。

ガベージ コレクションとは

ガベージ コレクションは、JavaScript でメモリを再利用する方法です。そのタイミングはブラウザが判断します。収集中は、ページ上のすべてのスクリプトの実行が一時停止されますが、GC ルートからオブジェクト グラフの走査によってライブ値が検出されます。到達可能でない値はすべてガベージとして分類されます。ガベージ値のメモリが、メモリ マネージャーによって回収される。

V8 ガベージ コレクタの詳細

ガベージ コレクションの仕組みをより深く理解するために、V8 ガベージ コレクタについて詳しく見ていきましょう。V8 では世代別コレクタを使用します。記憶は若者と古い世代に分けられます。若い世代における割り当てと収集は、迅速かつ頻繁に行われます。古い世代での割り当てと収集は低速で、頻度も低くなります。

世代別コレクタ

V8 では、2 世代のコレクタを使用します。値の経過時間は、値が割り当てられた後に割り当てられたバイト数として定義されます。実際には、多くの場合、値の経過時間は、その値が存続する新しい世代のコレクションの数で近似されます。値が十分に古い場合、古い世代で保持されます。

実際には、新しく割り当てられた値は長くは持続しません。Smalltalk プログラムの研究によると、若い世代のコレクションの後、価値の 7% しか生き残らないことが示されました。ランタイムに関する同様の調査では、新しく割り当てられた値の平均 90% ~ 70% が古い世代に保持されることがないことがわかっています。

若い世代

V8 の新世代のヒープは、from と to という名前の 2 つのスペースに分割されています。メモリは空間から割り当てられます。割り当ては非常に高速ですが、スペースがいっぱいになると、若い世代のコレクションがトリガーされます。若い世代のコレクションは、最初に元のスペースとスペースをスワップし、古いスペース(現在はスペースから)がスキャンされ、すべてのライブ値がスペースにコピーされるか、古い世代に存続します。一般的な若い世代のコレクションは 10 ミリ秒(ms)程度かかります。

直観的には、アプリケーションで割り当てを行うたびに、空き容量が残り少なくなって GC の一時停止が発生することを理解しておく必要があります。ゲーム デベロッパーは、16 ミリ秒のフレーム時間(60 フレーム/秒を達成するために必要)を確保するには、1 つの新しい世代のコレクションがフレーム時間の大半を消費するため、アプリケーションは割り当てをゼロにする必要があります。

若い世代のヒープ

旧世代

V8 の旧世代のヒープでは、コレクションにマーク コンパクト アルゴリズムを使用します。古い世代への割り当ては、若い世代から古い世代に値が存続するたびに行われます。古い世代のコレクションが発生するたびに、若い世代のコレクションも行われます。数秒ほどでアプリケーションが一時停止します。実際には、古い世代のコレクションは頻繁に発生しないため、これは許容されます。

V8 GC の概要

ガベージ コレクションによる自動メモリ管理は、デベロッパーの生産性に優れていますが、値を割り当てるたびに、ガベージ コレクションの一時停止に近づきます。ガベージ コレクションの一時停止は、ジャンクを発生させ、アプリケーションの操作性を損なう可能性があります。JavaScript によるメモリ管理の仕組みを理解したところで、次はアプリケーションに適した選択をしましょう。

Gmail の修正

この 1 年間で、さまざまな機能やバグ修正が Chrome DevTools に組み込まれ、これまで以上に便利になっています。さらに、ブラウザ自体も performance.memory API に重要な変更を行い、Gmail やその他のアプリケーションでフィールドからメモリ統計情報を収集できるようになりました。かつては不可能と思われていた優れたツールを備えたこのゲームは、すぐに犯人探しのゲームに成長しました。

ツールと手法

フィールド データと performance.memory API

Chrome 22 以降、performance.memory API はデフォルトで有効になっています。Gmail のような長時間実行されるアプリケーションの場合、実際のユーザーから得られるデータは非常に重要です。この情報により、1 日に 8 ~ 16 時間 Gmail を費やして 1 日に数百通のメールを受信するパワーユーザーと、1 日に数分間 Gmail を使用する平均的なユーザーと、週に 10 件程度のメールを受信するユーザーを区別できるようになります。

この API は、次の 3 つのデータを返します。

  1. jsHeapSizeLimit - JavaScript ヒープの制限されるメモリ量(バイト単位)。
  2. totalJSHeapSize - JavaScript ヒープが割り当てたメモリの量(空き領域を含む)。
  3. useJSHeapSize - 現在使用されているメモリの量(バイト単位)。

この API は Chrome プロセス全体のメモリ値を返すことに注意してください。これはデフォルトのモードではありませんが、特定の状況下では、Chrome で同じレンダラ プロセスで複数のタブを開くことがあります。つまり、performance.memory が返す値には、アプリが含まれているタブだけでなく、他のブラウザタブのメモリ使用量も含まれている可能性があります。

大規模なメモリの測定

Gmail では、JavaScript を計測可能にし、performance.memory API を使用してメモリ情報を約 30 分に 1 回収集しました。多くの Gmail ユーザーは一度に何日もアプリを放置していたため、チームはメモリ使用量の推移と全体的なメモリ使用量の統計情報を追跡できました。無作為に抽出されたユーザーからメモリ情報を収集するために Gmail をインストルメント化してから数日で、チームは平均的なユーザーにおいてメモリの問題がどれほど広まっているかを理解するのに十分なデータを得ました。ベースラインを設定し、受信データのストリームを使用して、メモリ消費量の削減という目標に向けた進捗状況を追跡しました。最終的には、このデータはメモリリグレッションの検出にも使用されます。

このフィールド測定では、トラッキング目的だけでなく、メモリ使用量とアプリケーション パフォーマンスの相関関係も詳細に把握できます。「メモリが多いほどパフォーマンスが向上する」という一般的な考えに反して、Gmail チームは、メモリ使用量が大きいほど、Gmail の一般的な操作のレイテンシが長くなることを確認しました。この事実を武器に、彼らは記憶の消費を抑制したいとこれまで以上に意欲的になりました。

大規模なメモリの測定

DevTools のタイムラインでメモリの問題を特定する

パフォーマンスの問題を解決する最初のステップは、問題が存在することを証明し、再現性のあるテストを作成して、問題のベースライン測定を取ることです。再現可能なプログラムがなければ、問題を確実に測定できません。ベースラインの測定値がなければ、パフォーマンスがどの程度向上したかわかりません。

DevTools の [Timeline] パネルは、問題の存在を証明するのに理想的な候補です。ウェブアプリやページの読み込みや操作の際にどこで時間を費やしているかについて、全体像を把握できます。リソースの読み込みから JavaScript の解析、スタイルの計算、ガベージ コレクションの一時停止、再ペイントまで、すべてのイベントがタイムライン上にプロットされます。メモリの問題を調査するため、タイムライン パネルにはメモリモードもあります。このモードでは、割り当てられたメモリの合計、DOM ノードの数、ウィンドウ オブジェクトの数、割り当てられたイベント リスナーの数を追跡できます。

問題の存在を証明する

まず、メモリリークが疑われる一連のアクションを特定します。タイムラインの記録を開始し、一連の操作を行います。一番下にあるゴミ箱ボタンを使用して、完全なガベージ コレクションを強制実行してください。数回のイテレーションの後にのこぎりの形のグラフが表示される場合は、有効期間が短いオブジェクトを多数割り当てています。しかし、一連のアクションによってメモリが保持されることが予想されておらず、DOM ノード数が開始時のベースラインまで減少しない場合は、リークが疑われる正当な理由があります。

のこぎり波グラフ

問題が存在することを確認したら、DevTools のヒープ プロファイラから問題の原因を特定するヘルプを得ることができます。

DevTools のヒープ プロファイラを使用したメモリリークの検出

Profiler パネルには、CPU Profiler と Heap Profiler の両方があります。ヒープ プロファイリングは、オブジェクト グラフのスナップショットを取得することで機能します。スナップショットが取得される前に、新旧両方の世代でガベージ コレクションが行われます。つまり、スナップショットが取得されたときに存在していた値のみが表示されます。

ヒープ プロファイラの機能は多すぎてこの記事では十分ではありませんが、詳細なドキュメントは Chrome デベロッパー サイトでご覧いただけます。ここでは、ヒープ割り当て プロファイラに焦点を当てます。

ヒープ割り当てプロファイラの使用

ヒープ割り当て プロファイラは、Heap Profiler の詳細なスナップショット情報と、[Timeline] パネルの増分更新およびトラッキングを組み合わせたものです。[Profiles] パネルを開き、[Record Heap Allocations] プロファイルを起動し、一連の操作を行ってから、分析のために記録を停止します。割り当てプロファイラは、記録中にヒープ スナップショットを定期的に(50 ミリ秒ごと)に取得し、記録終了時に最後のスナップショットを作成します。

ヒープ割り当てプロファイラ

上部のバーは、ヒープ内で新しいオブジェクトが見つかったことを示します。各棒の高さは、最近割り当てられたオブジェクトのサイズに対応し、棒の色は、それらのオブジェクトが最終的なヒープ スナップショットにまだ存在するかどうかを示します。青色の棒はタイムラインの最後にまだ存在しているオブジェクトを示し、グレーの棒は、タイムライン中に割り当てたがガベージ コレクションの対象となったオブジェクトを示します。

上記の例では、アクションは 10 回実行されました。このサンプル プログラムでは 5 つのオブジェクトがキャッシュされるため、最後の 5 つの青いバーが表示されることになります。左端の青いバーは潜在的な問題を示しています。上のタイムラインのスライダーを使用して特定のスナップショットを拡大し、その時点で割り当てられたオブジェクトを確認できます。ヒープ内の特定のオブジェクトをクリックすると、ヒープ スナップショットの下部にその保持ツリーが表示されます。オブジェクトへの保持パスを調べると、オブジェクトが収集されなかった理由を理解するのに十分な情報が得られ、必要なコードを変更して不要な参照を削除できます。

Gmail のメモリ クライシスの解決

Gmail チームは、上述のツールと手法を使用して、いくつかのカテゴリのバグを特定できました。無限のキャッシュ、実際には決して起こらないことが起こるのを待っている無限に増加するコールバックの配列、意図せずターゲットを保持するイベント リスナーなどです。これらの問題を修正することで、Gmail の全体的なメモリ使用量が大幅に削減されました。99% のユーザーはメモリ使用量が以前より 80% 少なく、中央値のユーザーのメモリ消費量は 50% 近く減少しました。

Gmail のメモリ使用量

Gmail のメモリ使用量が少なくなるため、GC の一時停止レイテンシが短縮され、全体的なユーザー エクスペリエンスが向上しました。

また、Gmail チームがメモリ使用量に関する統計情報を収集したところ、Chrome 内のガベージ コレクションの回帰を発見できました。具体的には、Gmail のメモリデータで割り当てられた合計メモリとライブメモリの差が劇的に増加し始めたときに、断片化の 2 つのバグが発見されました。

行動を促すフレーズ

以下の点を確認してください。

  1. アプリのメモリ使用量 メモリの使用量が多すぎる可能性があります。これは一般的な考えに反し、アプリケーションの全体的なパフォーマンスに悪影響を及ぼす可能性があります。正確な数値を把握することは困難ですが、ページをさらに使用している場合は、パフォーマンスに大きな影響を与えることを確認してください。
  2. ページの漏洩は発生していませんか? ページにメモリリークがあると、ページのパフォーマンスだけでなく、他のタブにも影響が及ぶ可能性があります。オブジェクト トラッカーを使用して、漏洩を特定します。
  3. ページの GC はどのくらいの頻度で行われていますか? GC の一時停止は、Chrome デベロッパー ツール[Timeline] パネルで確認できます。ページで頻繁に GC が行われている場合は、割り当ての頻度が高すぎ、若い世代のメモリを使い切っている可能性があります。

おわりに

私たちは危機の中からスタートしました。JavaScript と V8 のメモリ管理の中核となる基本について説明しました。Chrome の最新ビルドで利用できる新しいオブジェクト トラッカー機能など、ツールの使い方を学びました。Gmail チームはこの知識を武器にメモリ使用の問題を解決し、パフォーマンスの向上を確認できました。ウェブアプリでも、同じことができます。