Gmail スケールの効率的メモリ管理術

HTML5 Rocks

はじめに

JavaScriptはガベージコレクションによる自動メモリ管理の機能を備えますが、アプリケーションが効率的なメモリ管理を全くせずに済むわけではありません。それどころか、JavaScriptで書かれたアプリケーションは、メモリリークやメモリの肥大化等、ネイティブアプリが抱えるのと同様のメモリ関連の問題や、さらにはガベージコレクションによる処理停止の問題にも対処しなければなりません。Gmailのような大規模なアプリケーションにおいても、より小規模なアプリケーションと同じ問題に直面します。以降の記事を読むことで、メモリの問題を特定/切り分け/修正するために、GmailチームがChrome DevToolsをどのように使用したのかを学ぶことができます。

Google I/O 2013 セッション

この記事の内容はGoogle I/O 2013で披露したものと同じです。こちらの動画をご覧ください。

Gmailで起きた問題

Gmailチームは深刻な問題に直面していました。Gmailのタブが数ギガバイトのメモリを占有し、ノートPCやデスクトップPCの限られたリソースを圧迫している、といった話が方々で聞かれるようになったのです。たいていの場合、話の結末はブラウザーごと落ちてしまうというものでした。CPUの稼働率が100%のまま下がらないだとか、アプリが無反応になるだとか、ChromeのいわゆるSadタブ(“He’s dead, Jim.”)が表示されたとか、そういった話です。Gmailチームは途方に暮れ、問題を解決するどころか、どこから手をつければよいかわからない状況でした。どれくらい広範囲に問題が及んでいるのか、検討もつきませんんでしたし、手元のツールは大規模なアプリケーションに対応していませんでした。そこで、GmailチームはChromeチームと共同で、メモリ問題の一時切り分けを行うための技術を開発し、既存のツールを改良し、フィールドからメモリのデータを収集できるようにしました。では、ツールの説明に入る前に、JavaScriptのメモリ管理についておさらいをしましょう。

メモリ管理の基礎

JavaScriptでメモリ管理を効率的に行うには、基本原則を理解していなければいけません。この節では、JavaScriptの基本型、オブジェクトグラフ、一般的なメモリの肥大化やメモリリークについて解説します。JavaScriptにおいて、メモリはグラフに概念化することができます。このことから、 JavaScriptのメモリ管理やヒーププロファイラ においてグラフ理論 が使用されています。

基本型

JavaScriptは3つの基本型を持ちます。

  1. Number (例: 4, 3.14159)
  2. Boolean (true もしくは false)
  3. String (“Hello World”)

基本型の値は他の値を参照することはできません。オブジェクトグラフにおいて、これらの値は常にリーフノード、もしくは終端ノードになります。つまり、これらの値は外向きのエッジを持ち得ません。

Object型は唯一のコンテナ型です。JavaScript のオブジェクトは連想配列です。空でないオブジェクトは、他の値 (ノード) へのエッジを持つ内部ノードです。

配列は?

JavaScript の配列は、数値のキーを持つオブジェクトです。実際、JavaScriptエンジンは簡略化のため、Array-likeオブジェクトを内部的に配列として扱います。

用語

  1.  - 基本型、Object型、配列等のインスタンス
  2. 変数 - 値を参照するときに使う名前
  3. プロパティ - オブジェクト内で値を参照するときに使う名前

オブジェクトグラフ

JavaScript 内ではすべての値がオブジェクトグラフに属します。グラフにはルートが存在します。例えばwindow オブジェクトはルートです。 GCのルートの寿命を管理することはできません。なぜなら、ルートはページのロード時にブラウザによって生成/破棄されるからです。グローバル変数は、実のところwindowのプロパティです。

値はいつガベージになるか?

ルートからのパスがなくなった時点で、値はガベージコレクションの対象になります。言い換えると、ルートおよびスタック上に存在するすべてのオブジェクトのプロパティと変数から、あらゆる経路を辿っても到達できない場合、そのオブジェクトはガベージと見なされます。

JavaScriptのメモリリークとは?

JavaScript のメモリリークの典型的な例として、すでにページのDOMツリーから参照されなくなったDOMノードが、あるオブジェクトから参照されているケースがあります。モダンWebブラウザは、うっかりリークを作り込んでしまわないような作りになっていますが、それでもまだリークは容易に発生し得ます。例えば、以下のようにDOMツリーに要素を追加したとしましょう。

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

そして、後ほどその要素をdisplayListから削除したとしましょう。

displayList.removeAllChildren();

たとえそのDOM要素がページのDOMツリーから参照されなくなったとしても、email オブジェクトが存在し続ける限り、その要素はmessageプロパティにより参照されているので、ガベージコレクションの対象にはなりません。

メモリの肥大化とは?

ページに見合ったメモリ量よりも多くのメモリを使用すると、肥大化が発生します。メモリリークも結果的に肥大化を引き起こしますが、それは意図的ではありません。メモリ肥大化のよくある例として、サイズ制限の無いアプリケーションキャッシュが挙げられます。また、ページがロードしたデータ、例えば画像などにより、肥大化が発生することもあります。

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

ガベージコレクションは JavaScript のメモリ再利用の仕組みです。実行タイミングはブラウザによって決定されます。ひとたびガベージコレクションが発生すると、オブジェクトグラフがルートから順に走査される間、ページのスクリプト実行は中断されます。到達不可能と判断されたオブジェクトはすべてガベージに分類され、メモリマネージャにより再利用されます。

 

V8のガベージコレクション詳細

ガベージコレクションがどのように発生するか、さらに深く理解するために、V8のガベージコレクタを詳細に見てみましょう。V8は世代別GC方式を採用しています。メモリの領域は新旧2つの世代に分けられます。新世代のメモリ領域では、メモリのアロケーションとガベージコレクションは高速かつ頻繁に実行されます。一方、旧世代のメモリ領域では、逆に低速かつ低頻度で実行されます。

世代別GC

V8は2世代のガベージコレクターを使います。値の年齢とは、その値がアロケートされた時点から今まで何バイトアロケートされたか、で定義されます。実際には、ある値の年齢は、しばしば何回分のガベージコレクションを生き延びたか、によって簡易的に表現されます。そして、ある値が十分古くなれば、旧世代の領域に移されます。


現実には、新たにアロケートされた値はそれほど長く生き続けません。Smalltalkプログラムの研究によると、アロケートされた値のうち、ガベージコレクションを生き延びるのはたかだか7%です。他の研究によると、新たにアロケートされた値のうち、70から90%は新世代のメモリ領域で回収され、旧世代のメモリ領域に移されないことが判明しました。

新世代GC

V8の新世代のヒープメモリはfromto の2つの領域に分けられます。メモリはまず、to の領域からアロケートされます。アロケートはとても高速で、 to 領域がいっぱいになった時点で、ガベージコレクションが起動されます。ガベージコレクタは、まずfrom 領域と to領域をスワップします。古い to領域 (つまり、今の from 領域) はスキャンされ、すべての参照されている値は to 領域に移動されるか、もしくは旧世代の領域に移動されます。新世代のガベージコレクションの実行時間は、10 ミリ秒のオーダーです。

直感的に理解できると思いますが、アプリケーションがメモリアロケートを繰り返すと、いずれto 領域が枯渇してGCが発生します。特にゲーム開発者は注意してください。(60 fps を実現するために) 16ミリ秒のフレーム時間を保証するには、あなたのアプリケーションは一回たりともメモリをアロケートすることはできません。なぜなら、一回の新世代ガベージコレクションでフレーム時間のほとんどを使ってしまうからです。

旧世代GC

V8の旧世代GCはマークコンパクト アルゴリズムを採用しています。旧世代のメモリ領域は、値が新世代のメモリ領域から旧世代の領域に移された時点で、アロケートされます。旧領域のメモリ領域でガベージコレクションが実行されると、必ず新世代のGCも実行されます。ひとたび処理が走ると、アプリケーションは秒のオーダーで実行が止まります。現実には、旧世代のGCはそれほど頻繁に発生しないので、これだけ処理時間がかかっても許容されるのです。

V8のGCまとめ

ガベージコレクションによる自動メモリ管理は、開発者の生産性という点ではすばらしいですが、値をアロケートするたびにGC発生のリミットに近づきます。GCによる処理停止は、アプリケーションの印象を損ないます。以上で、JavaScriptがどのようにメモリを管理するか、学ぶことが出来たので、ご自身のアプリケーションにおいて、正しい選択ができるようになったと思います。

Gmailにおける施策

Chrome DevTools は、ここ数年の間、おびただしい数の機能追加とバグ修正により、かつてないほどパワフルなツールになりました。それに加え、ブラウザ自体にも performance.memory API が追加されたので、Gmailのようなアプリケーションは、メモリ使用に関する統計的なデータをフィールドから収集できるようになりました。これらの素晴らしいツールのおかげで、以前は不可能と思われていた障害要因の切り分け調査が可能となりました。

ツールとテクニック

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

performance.memory APIは Chrome 22 でデフォルトで有効になりました。Gmail のような長時間実行されるアプリケーションにおいては、実際のユーザーからのデータはとても貴重です。収集されたデータにより、我々は、パワーユーザ (一日に8〜16時間Gmailを使用し、数百件のメールを受け取る) と、一般的なユーザ (一日に数分しかGmailを使用せず、10数件のメールしか受け取らない) を区別することができます。

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

  1. jsHeapSizeLimit - JavaScript のヒープメモリのサイズ限界値 (バイト)
  2. totalJSHeapSize - ヒープメモリのうち、すでにアロケートされたメモリのサイズ (バイト)
  3. usedJSHeapSize - アロケートされたメモリのうち、現在使用中のメモリのサイズ (バイト)

一点、注意してほしいのは、この API が返すメモリサイズは、Chrome のプロセス全体のメモリである、ということです。デフォルトでは一つのタブにつき一つのレンダープロセスが作成されますが、ある条件下では、一つのレンダープロセスを複数のタブで共有する場合があります。つまり、performance.memory API が返す値は、アプリ実行中のタブ以外のタブで使用されているメモリも含む場合があるのです。

メモリの定量的な観測

Gmail では、JavaScript のコードに変更を加え、performance.memory API を使って、約30分毎にメモリの情報を収集するようにしました。多くの Gmail のユーザーは、アプリを何日も起動したままの状態にしておくので、我々はメモリ使用量の統計的データと併せて、メモリ使用量が時間経過によりどのように変化するか、といった情報も収集することができました。そのようにして数日間、ランダムに抽出されたユーザーのメモリ情報を収集することで、Gmail チームはメモリの問題が発生している範囲を把握するのに十分なデータを入手できました。まずは現状を把握し、引き続きデータを収集することで、メモリ使用量削減のゴールに向けて、どのくらい進捗しているか追跡することが可能となったのです。最終的には、このデータはメモリ関連のリグレッションの検出にも使えるでしょう。

メモリの追跡のみならず、フィールドデータの観測により、メモリ使用量とアプリケーションのパフォーマンスの関係についても、鋭い洞察が得られました。一般的にメモリ量が多いほど、良いパフォーマンスが得られると信じられていますが、我々は、多くのGmailの動作において、メモリー使用量が増えるほど、遅延が大きくなることを発見しました。この予想外の発見により、我々は以前にも増して、メモリ使用量の抑制に熱心に取り組むようになりました。

DevToolsのタイムラインを用いたメモリ問題調査

すべてのパフォーマンス問題に関して言えることですが、まず最初にすべきことは、問題の存在自体を証明すべく、再現性のあるテスト手順を確立し、現状の計測結果を取得することです。再現プログラム無しでは、安定した計測データを得ることはできませんし、また、初期の計測結果無しでは、どれだけパフォーマンスが改善したか知り得ません。

Chrome DevTools の タイムラインパネル は、問題の存在を証明するための格好の手段となります。タイムラインを使うことにより、Web ページがロード/操作される際に、どこにどれだけの時間が費やされているかを詳細に把握することが出来ます。また、リソースの読み込み、JavaScriptのパース、スタイルの計算、ガベージコレクションの実行、再描画等の、すべてのイベントはタイムライン上に表示されます。そして、メモリ問題を調査するために、タイムラインパネルはメモリモードを備えており、アロケートされたメモリの合計値や、DOMノード数、ドキュメント数、イベントリスナー数を追跡することができます。

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

まずは、一連の操作によりメモリリークが発生していないか確かめましょう。タイムラインのレコーディングを開始して、その操作を行ってください。画面下のゴミ箱のアイコンを押下することで、ガベージコレクションを発生させることができます。これを何度か繰り返すことで、 ノコギリ型 のグラフが見られた場合、短命なオブジェクトを大量にアロケートしていることを表します。しかし、もしその操作がメモリの保持を伴わないものであるにもかかわらず、DOMノード数が初期状態に戻らなかった場合、メモリリークの疑いがあります。

ひとたびリークの存在が確認できれば、次に Chrome DevTools のヒーププロファイラを使って、リークの原因を特定することができます。

DevTools のヒーププロファイラを使ったメモリリークの発見

プロファイラパネルには、CPUプロファイラとヒーププロファイラがあります。ヒーププロファイラではオブジェクトグラフのスナップショットを取得することができます。スナップショットを取得する前に、新旧両世代のガベージコレクションが実行されるので、スナップショットで見れるのは、プログラムで参照されているオブジェクトだけです。

ヒーププロファイラはたくさんの機能を備えるので、すべてをこの記事で取り上げることはできませんが、Google Developers のサイトに詳細なドキュメントがありますので参照ください。ここでは、最新のChromeにエクスペリメント機能として入った Object Tracker を紹介するにとどめます。まずは、以下の手順で Object Tracker を有効にしてください。

  1. 最新のChrome Canaryを使用していることを確認してください。
  2. about:flags のページを開いて、“Enable Developer Tools experiments” が有効になっていることを確認してください。設定変更を反映するにはChrome を再起動する必要があります。  
  3. Developer Tools を開き、右下の歯車のアイコンをクリックしてください。画面左のメニューに Experiments というアイテムが現れるので選択し、“Enable heap objects tracking profile type” のチェックボックスを有効にしてください。設定変更を反映するには、DevTools をいったん閉じて開く必要があります。
  4. プロファイルパネルに新しいプロファイルタイプ “Track Allocations” が追加されているはずです。

Object Tracker を使用する

Object Tracker はヒーププロファイラのスナップショット情報と、タイムラインパネルの時系列による追跡機能とを併せ持ちます。レコーディング開始して、一連の操作を行い、レコーディングを停止することで解析可能となります。Object Tracker はレコーディング開始後、定期的に (50 ミリ秒毎に!) ヒープのスナップショットを取得し、レコーディング停止時に最終のスナップショットを取得します。

画面上部のバーは、新しいオブジェクトがいつヒープに出現したかを表します。バーの高さは、その時点でアロケートされたオブジェクトのサイズを表し、バーの色はそれらのオブジェクトが最終のヒープスナップショットに存在するかどうかを表します。青色のバーはオブジェクトがタイムラインの終端時点でもまだ存在し続けていることを示します。一方、灰色のバーはオブジェクトがタイムライン上のある時点でアロケートされたけれど、すでにガベージコレクタによりメモリ回収されたことを示します。

上の例では、アクションが 10 回実行されます。サンプルプログラムは5つのオブジェクトをキャッシュするので、最後の5本の青色のバーは想定通りです。しかし、一番左の青色のバーは潜在的な問題を示しています。タイムラインのスライダを問題のバーの上に動かすことで、その時点でアロケートされたオブジェクトのスナップショットを見ることが出来ます。ヒープ内の特定のオブジェクトをクリックすると、スナップショットの下部にそのオブジェクトの参照ツリーを表示します。オブジェクトがどのようなパスで参照されているか調べることで、なぜそのオブジェクトがガベージコレクションの対象にならなかったのか判明したら、コードを変更して不要な参照を取り除いてください。

Gmail のメモリ危機を解消

上記のツールとテクニックを使い、Gmailチームは数種類のバグを特定できました。無制限なキャッシュ作成、絶対に発生しないイベントを待つコールバックの無限に伸びる配列、そして気付かずにターゲット自身の参照を保持していたイベントリスナーです。これらのバグを修正することで、Gmail の全体のメモリ使用量は劇的に改善しました。99%のユーザにおいて、メモリ使用量が以前と比べて80%減少し、中央値ではメモリ使用量は50% 近く減少しました。

Gmail のメモリ使用量が減ることで、GC発生による遅延は緩和され、結果的に全体的なユーザー体験の向上につながりました。

そして、副次的な効果として、Gmail チームの収集するメモリの統計データから、Chrome 内部のガベージコレクションのデグレを発見することができました。Gmail の収集データから、アロケートされたメモリと使用メモリとの差分の値が急上昇したことが確認され、これにより2つのフラグメンテーションバグが検出されました。

チェックリスト

以下を自問自答してください。

  1. あなたのアプリケーションはどのくらいメモリを使用しますか?

あなたのアプリケーションはメモリを使いすぎている可能性があります。よく誤解されていることですが、多すぎるメモリはアプリケーションの全体のパフォーマンスに悪い影響を及ぼします。正確な数値を知ることは難しいですが、過剰なページキャッシュがパフォーマンスに影響を及ぼしていないか検証してください。

  1. あなたのページにリークはありませんか?

あなたのページでメモリリークが発生すると、あなたのページのパフォーマンスだけではなく、他のタブにも影響を及ぼす可能性があります。Object Tracker を使ってリークの原因を特定してください。

  1. あなたのページではどれくらい頻繁にGCが発生しますか?

GC が発生したかどうかは Chrome Developer Toolsタイムラインパネルで確認できます。 もし頻繁にGCが発生しているのであれば、メモリをアロケートする頻度が高すぎるか、もしくは新世代GC のメモリ領域でメモリのアロケート/回収がひっきりなしに起きているかもしれません。

おわりに

Gmail で起きたメモリ危機からはじまって、JavaScript と V8 におけるメモリ管理の基礎を説明しました。そして、最新のChromeで利用可能な Object Tracker を含むツールの使い方を学びました。Gmail チームはこれらの知識を使い、メモリ使用量の問題を解決し、パフォーマンスを改善しました。次はあなたのWeb アプリケーションで、同様の改善を実現してください!

Comments

0