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」)

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

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

配列の場合はどうなりますか?

JavaScript の配列は、実際には数値キーを持つオブジェクトです。これは簡略化された表現です。JavaScript ランタイムは配列に似たオブジェクトを最適化し、内部的には配列として表現します。

用語

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

オブジェクトグラフ

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

オブジェクトグラフ

値がガベージになるタイミング

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

ゴミグラフ

JavaScript のメモリリークとは

JavaScript のメモリリークは、ページの DOM ツリーから到達できない DOM ノードが、JavaScript オブジェクトによって参照されている場合に最もよく発生します。最新のブラウザでは、誤ってリークを作成することはますます難しくなっていますが、それでも考えられているよりも簡単です。次のように DOM ツリーに要素を追加したとします。

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

後で、表示リストから要素を削除します。

displayList.removeAllChildren();

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

肥大化とは

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

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

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

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

ガベージ コレクションの詳細を理解するために、V8 ガベージ コレクタについて詳しく見てみましょう。V8 は世代別コレクタを使用します。メモリは、新しいメモリと古いメモリの 2 つの世代に分かれています。若い世代内の割り当てと収集は高速で頻繁に行われます。古い世代内の割り当てと収集は遅く、頻度も低くなります。

世代コレクタ

V8 は 2 世代コレクタを使用します。値の存続期間は、割り当てられてから割り当てられたバイト数として定義されます。実際には、値の存続期間は、その値が生き残った若い世代のコレクションの数で近似されます。値が十分に古くなると、古い世代にテナントが設定されます。

実際には、新しく割り振られた値は長く存続しません。Smalltalk プログラムの調査では、若い世代の収集後に残存する値は 7% に過ぎないことが示されています。ランタイム全体で行われた同様の調査では、新しく割り振られた値の平均 90 ~ 70% が、古い世代にテナントが設定されることはありませんでした。

若い世代

V8 のヤング ジェネレーション ヒープは、from と to という 2 つのスペースに分割されています。メモリは to スペースから割り振られます。割り当ては非常に高速ですが、to スペースがいっぱいになると、若い世代のコレクションがトリガーされます。ヤング ジェネレーションの収集では、まず送信元スペースと転送先スペースが入れ替わります。古い転送先スペース(現在は送信元スペース)がスキャンされ、すべてのライブ値が転送先スペースにコピーされるか、古い世代にテナントで移行されます。一般的なヤング ジェネレーションの収集には、10 ミリ秒(ms)ほどかかります。

直感的に、アプリケーションが行う各割り当てにより、スペースが使い果たされ、GC の停止が発生する可能性が高くなることを理解する必要があります。ゲーム デベロッパー向けのメモ: 16 ミリ秒のフレーム時間(60 フレーム/秒を達成するために必要)を確保するには、アプリケーションでゼロ割り当てを行う必要があります。これは、単一のヤング ジェネレーション コレクションがフレーム時間の大部分を消費するためです。

ヤング ジェネレーション ヒープ

古い世代

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

V8 GC の概要

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

Gmail の修正

この 1 年間で、Chrome DevTools には多くの機能とバグ修正が追加され、これまで以上に強力になりました。また、ブラウザ自体の performance.memory API にも重要な変更が加えられ、Gmail や他のアプリケーションがフィールドからメモリ統計情報を収集できるようになりました。こうした優れたツールを活用することで、かつては不可能と思われていたタスクが、犯人を追い詰めるエキサイティングなゲームへと変わりました。

ツールと手法

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

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

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

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

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

大規模なメモリ測定

Gmail では、JavaScript を計測して performance.memory API を使用して、約 30 分ごとにメモリ情報を収集しています。Gmail ユーザーの多くは、アプリを数日間開いたままにするため、チームは時間の経過に伴うメモリの増加と、全体的なメモリ フットプリントの統計情報を追跡できました。Gmail に計測ツールを導入してランダムに選ばれたユーザーからメモリ情報を収集したところ、数日以内に、平均的なユーザーの間でメモリの問題がどの程度広がっているかを把握するのに十分なデータが収集されました。ベースラインを設定し、受信データのストリーミングを使用して、メモリ消費量削減の目標達成に向けた進捗状況を追跡しました。最終的には、このデータはメモリの回帰を検出するためにも使用されます。

フィールド測定は、トラッキング目的以外にも、メモリ フットプリントとアプリケーション パフォーマンスの相関関係に関する詳細な分析情報を提供します。「メモリ量が多いほどパフォーマンスが向上する」という一般的な考えとは対照的に、Gmail チームは、メモリ フットプリントが大きいほど、Gmail の一般的な操作のレイテンシが長くなることを発見しました。この発見を基に、チームはメモリ消費量を抑制することにこれまで以上に意欲的になりました。

大規模なメモリ測定

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

パフォーマンスの問題を解決する最初のステップは、問題が存在することを証明し、再現可能なテストを作成し、問題のベースライン測定を行うことです。再現可能なプログラムがなければ、問題を信頼性を持って測定することはできません。ベースラインの測定値がないと、パフォーマンスがどの程度改善されたかわかりません。

DevTools のタイムライン パネルは、問題が存在することを証明するのに最適な候補です。ウェブアプリやページの読み込みと操作に費やされた時間の詳細な概要を確認できます。リソースの読み込みから JavaScript の解析、スタイルの計算、ガベージ コレクションの一時停止、再描画までのすべてのイベントがタイムラインにプロットされます。メモリの問題を調査するために、タイムライン パネルにはメモリモードもあります。このモードでは、割り当てられたメモリの合計量、DOM ノードの数、ウィンドウ オブジェクトの数、割り当てられたイベント リスナーの数を追跡できます。

問題が存在することを証明する

まず、メモリリークが発生していると思われる一連のアクションを特定します。タイムラインの記録を開始し、一連の操作を行います。下部にあるゴミ箱ボタンを使用して、完全なガベージ コレクションを強制的に実行します。数回の反復処理後に鋸歯状のグラフが表示される場合は、存続期間の短いオブジェクトが大量に割り当てられています。ただし、一連の操作でメモリが保持されることが想定されず、DOM ノード数が開始時のベースラインまで減少しない場合は、リークが発生している疑いがあります。

鋸歯状のグラフ

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

DevTools ヒープ プロファイラでメモリリークを見つける

[Profiler] パネルには、CPU プロファイラとヒープ プロファイラの両方が用意されています。ヒープ プロファイリングは、オブジェクトグラフのスナップショットを取得することで機能します。スナップショットが取得される前に、若い世代と古い世代の両方がガベージ コレクションされます。つまり、スナップショット取得時に有効だった値のみが表示されます。

ヒープ プロファイラには多くの機能があるため、この記事ですべてを説明することはできませんが、詳細なドキュメントは Chrome デベロッパー サイトでご確認いただけます。ここでは、ヒープ割り当てプロファイラについて説明します。

ヒープ割り当てプロファイラを使用する

ヒープ割り当てプロファイラは、ヒープ プロファイラの詳細なスナップショット情報と、タイムライン パネルの増分更新と追跡を組み合わせたものです。[プロファイル] パネルを開き、[ヒープ割り当ての記録] プロファイルを開始して一連のアクションを実行し、記録を停止して分析します。割り当てプロファイラは、記録中に定期的(50 ミリ秒ごと)にヒープのスナップショットを取得し、記録終了時に最後のスナップショットを取得します。

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

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

上記の例では、アクションが 10 回実行されました。サンプル プログラムでは 5 個のオブジェクトがキャッシュされるため、最後の 5 本の青い縦線は予想どおりです。ただし、左端の青いバーは問題が発生する恐れがあることを示しています。そこで上記のタイムラインのスライダーを使用して、この特定のスナップショットを拡大し、その時点で割り当てられていたオブジェクトを確認します。ヒープ内の特定のオブジェクトをクリックすると、ヒープ スナップショットの下部に、保持ツリーが表示されます。オブジェクトへの保持パスを調べると、オブジェクトのガベージ コレクションが行われなかった理由を把握できるだけの情報が提供されるので、必要に応じてコードを変更し、不要な参照を削除します。

Gmail のメモリ不足を解決する

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

Gmail のメモリ使用量

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

また、Gmail チームがメモリ使用量の統計情報を収集したことで、Chrome 内のガベージ コレクションの回帰が明らかになりました。具体的には、Gmail のメモリデータで、割り当てられた合計メモリとライブメモリの差が急増し始めたときに、2 つのフラグメンテーション バグが見つかりました。

行動を促すフレーズ

次の質問を自問してください。

  1. アプリが使用しているメモリ量はどのくらいですか? メモリ使用量が多すぎる可能性があります。一般的な考えとは異なり、メモリ使用量が多すぎると、アプリの全体的なパフォーマンスが低下します。適切な数値を正確に把握することは難しいですが、ページで使用している追加のキャッシュがパフォーマンスに測定可能な影響を及ぼしていることを確認してください。
  2. ページに漏洩はありませんか? ページでメモリリークが発生すると、ページのパフォーマンスだけでなく、他のタブにも影響する可能性があります。オブジェクト トラッカーを使用して、リークを絞り込むことができます。
  3. ページの GC の頻度はどのくらいですか?GC の停止は、Chrome デベロッパー ツールの [タイムライン パネル] で確認できます。ページで GC が頻繁に実行されている場合は、割り当てが頻繁に行われ、ヤング ジェネレーションのメモリが頻繁に使用されている可能性があります。

まとめ

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