ServoのDOMバインディングの話

Servoのリポジトリ内にDOMバインディングのデザイン覚え書きを投入したので、現時点での設計について、そろそろ日本語で説明しておく(英語版は結構前からServoのWikiに書いています)。

この記事の中で使うSpiderMonkey API用語は、結構古いものだったりするので、そこに注意(ServoはFirefox 17とかその辺りのSpiderMonkeyを使ってる)。

ブラウザにおけるDOMバインディングとは

この分野については、エデンの園でおきたこと - steps to phantasienという名記事があるので参照されたし。WebKit Chromium port時代のBlinkの話だけど、だいたいどのブラウザでも似たような問題を抱えていて、それぞれ微妙に異なるアプローチで解決している。

改めて私の言葉で要約してみると、「現代の実用的なブラウザエンジンというものは、自身の保有するDOM構造をJavaScriptなどの言語から操作可能にしている。使う側からすれば便利極まりない仕組みだけれど、ブラウザエンジン側にしてみると結構悩みの種。なぜなら、ブラウザエンジンは一般的にC/C++のような自前で参照関係を解決する種類のメモリ管理を必要とする世界であるのに対して、その構造を操作することになるスクリプト言語はだいたいエンジンが専用GC機構を持ち、その機構によってメモリの生存期間が決まるような言語仕様になっているから、これを巧くつなぐ仕組みが必要になる」ということ。

(関係ないけど、モダンなブラウザエンジンのDOMバインディングについて日本語で情報を求めると、だいたいomoさんが、その昔に書いた物にぶち当たる。本当にお世話になっています)

さて、こうした面倒くさい問題に対して、各ブラウザエンジンは幾つかのアプローチで対処している。OSSな実装で言うと:

といった感じ。

IEは、Exploring IE9's Enhanced DOM Capabilities - IEBlog - Site Home - MSDN Blogsによると、IE8まではCOMベースのバインディングを使用していたものの、IE9Chakraと密にバインディングするようにしたらしい。とにかくアーキテクチャ的にもIE9以降は違う感じはわかる。

Servo のアプローチ

上に挙げたアプローチはどれも巧いこと回っているものの、どうせならJSエンジン側のGCで全部を管理するようにしてしまいたい。Cycle Collectorを再実装したくはないし、余計な謎マクロとか色々実装したくはない。

というわけで、本題。「ServoのDOMバインディングは、どのようにオブジェクトの生存に関わる問題を解決しているのか」。答えは単純で「SpiderMonkey GCに全てを任せている」。もちろん世代別GCにも対応済。やったね!

Servoのアプローチその昔: dom.js

先述のエデンの園でおきたことでは、ServoはSpiderMonkey GCにDOMの生存管理を任せる為に、dom.jsを使うらしいと2012年末当時の観測結果として書いてある。が、現在はこの方法を使っている訳ではありません。

とりあえずdom.js路線について自分の知っている範囲の説明をする。実は、この時代は自分も知らない。今よりも、もっと実験実験してたプロジェクトの頃だし、自分もコミットチャンスを見つけてやり始める前。具体的に実装が動いていたのかもわからない。が、どちらにせよ、2012年末頃の時点では、dom.jsでDOMのデータ構造そのものをJavaScriptでself-hostしようとしていた、らしい。

原理的には一番オーソドックスにSpiderMonkey GCの恩恵を受けられる訳だし、self-hostでおなじみの効果として、DOMデータ構造そのものに対してJITが立ち入れるので、理論上はJITコンパイラによる更なる最適化のチャンスが増える。現代のフロントエンドWebプログラミングの最大の速度障壁はDOMの遅さであるので、そこの速度向上がワンチャンなので、一見すると、筋のよいアプローチに見える。

が、実際には、この試みは断念される。過去のmeeting noteを色々参照したところ、理由は単純にパフォーマンス + 複雑性の問題の様子。JS側と(文脈的にレイアウト用の)DOMの2つのレイヤーツリーを持って、常に同期し続けなければならない。ましてや並列性を狙ったエンジンなので、コードが本当に複雑になるのは想像に難くない。

具体的に何のパフォーマンスが悪かったのかは追跡できなかったのだけど、私の推論としては、おそらくは2つのDOMツリーを作る過程で発生するメモリコピーが要因ではないかと感じる。Webページを1つパースすると、だいたい数百〜数千の単位でDOMの領域のデータ構造がつくられる。これがJSオブジェクトで作成され、しかも同様のツリー構造をもう一回作るとなると、かかるコストは結構なものになるのではないかと。

何はともあれ、この路線は挫折することになる。

SpiderMonkey APIを通してDOMを管理する

というわけで、現在の路線はSpiderMonkeyAPIに従って、ラッパーオブジェクトの生死に合わせて、RustなDOMオブジェクトの生存を管理する方法を取っている。

全体のデザインはjdmによるもので、ここに書くものはコードから読み解いて、本人に直接確認した内容の覚え書きみたいなもんです。

基本的なライフサイクルについて日本語で書くと以下の通り(英語で書いたのを殆どそのままです):

前提: RustのDOMの構造

Rustには、別の構造体のデータ構造を継承した構造体をビルトインで定義する方法が無いので、Cでクラスなデータ構造をやるような手法が用いられている。また、後述するGCからのトレースの為、GC管理対象のDOMオブジェクトをJS<T>という型のメンバとして保持している:

// BarがFooを継承している場合の構造体の図
struct Bar {
    foo: Foo, // 先頭に基底クラスを格納する
    field1: JS<Hoge>, // SpiderMonkey GCの管理対象
    field2: int, // SpidrMonkey GCの管理対象でないもの
}

こうした構造体全てに対して、WebIDLから継承関係に応じたTraitを実装することで、どの型からどの型に対してのキャストが可能なのかを属性として付与している。詳しくは dom::bindings::codegen::InheritTypes.rsを見てください。

DOMの生成時

ServoがRustのオブジェクトを生成するとき、bindingコード(CodegenRust.pyによってWebIDLから生成されたglueコード)は、ラッパーオブジェクトとなるJSObjectを生成し、これに対するポインタを自身のReflectorフィールドに格納する。このラッパーオブジェクトはもちろんDOMと完全に一対一になるように生成され(でないとJSの比較演算子が変なことになる)、生成と同時に、対応するDOMオブジェクトへのポインタを自分の中に格納する。これで対応関係の出来上がり。

SpiderMonkey GCの管理下に入るDOMオブジェクトは全てが最基底な位置にReflectorというフィールドを保持しており、ここがラッパーオブジェクトとの対応関係を作っているわけですね。

ちなみに、このようなライフサイクルを作る都合上、Servoでは全てのDOMオブジェクトの生成時に、対応するラッパーオブジェクトの全てを同時に生成している。GeckoやBlinkの場合は、JS側からDOMにアクセスしたタイミングでラッパーオブジェクトを作っているが、それらのアプローチと比べるとServoの方が初期段階ではメモリを使っているはず。と、同時に、Servoのアプローチの方が世代別GCの影響が色濃く出るだろう。ページロードの瞬間からすべてのDOMに対応するJSオブジェクトが生成されているのだから。

DOMのオブジェクトグラフのトレース

このように生成したDOMのオブジェクトグラフ上をSpiderMonkey GCが歩けるようにするために、Rustの言語機能をかなりトリッキーに使ってる。

  1. SpiderMonky GCはマーキングフェーズ時に、それぞれのbindingコード内に定義された各ラッパーオブジェクトの定義元のJSClass.trace()を呼び出す。
  2. JSClass.trace()は、InhertTypes.rsの定義に従って、Foo::trace()を呼び出す。
  3. Foo::trace()は今度はFoo::encode()を呼び出す。このFoo::encode()#[deriving(Encodable)]によって、コンパイラ自動生成されたもの。現在の設計ではDOMをEncodableとして扱う目的が無い + 下手なマクロよりも確実に展開されるという前提に立っているのでこのアプローチを採用している。
  4. Foo::encode()は、メンバとして格納しているJS<T>JS<T>::encode()を呼び出す。
  5. JS<T>::encode()からdom::bindings::trace::trace_reflector()を呼び出す。
  6. trace_reflector()は、対応するラッパーオブジェクトをJSTracerに伝える = GCに伝える。
  7. 1~6までの操作を、オブジェクトグラフの終点まで延々と繰り返す
  8. 最後に、Rustのオブジェクトグラフを全部通り抜けた結果、どのオブジェクトが死ぬべきかがわかるというわけ

DOMの破棄

こうしてトレースした結果から、ライフサイクルを終えたと判別されたDOMは破棄される必要が有る。

この破棄ロジックは非常に簡単でラッパーオブジェクトの破棄タイミングに、JSClass.finalize()が呼び出され、そこからbindingコード内のFooBinding::_finalize()が呼びだされる。このメソッド内では、ラッパーオブジェクト側に格納しているRustオブジェクトのポインタが、Rustのowning pointerにキャストされ、空の変数に代入される。Rustの流儀に則り、ここでライフタイムが終わる = ポインタが指し示すオブジェクトも安全にデストラクトされるというわけ。

SpiderMonkey GCのExact GC Rootingへの対応

ここまでは基本的な動きについて話してきた。この他に、SpiderMonkeyは世代別GCを実装するにあたり、Exact (precise) GC方式に移行しているので、それへの対応が必要になっている。これに辺り、Servoでは、4種類のJS Managedな型を導入している

  • JS<T>は、前段でも述べた通り、RustなDOMオブジェクト内に保持されるDOM型のメンバを表す型。内部的には、格納対象のRustオブジェクトへの生ポインタを保持しているだけだったりする。GCによって再帰的にトレースされる対象。
  • Temporary<T>は、DOM型を返す必要があるメソッドの返り値として用いられる(典型的なのはDocument::getElementById()とか)。これは内部に対応するラッパーオブジェクトへのポインタを含んでいるので、SpiderMonkey GCのConservative Stack Scannerによってスキャンされる対象になり、結果、この型の値が生きている間、GCのrootとなる。ただし、SpiderMonkey GCはRootの選定にあたりLIFOの順番を保証する必要が有るのだけれど、メソッド間で値が移動するため、その順序が崩れてしまう場合がある。そのため、Root<T>という後述の型を導入することで、うまくそこを回避(保証)することにしている
  • Root<T>は、Temporary<T>同様にラッパーオブジェクトへのポインタを保持している。これも同様にConservative Stack ScannerによってスキャンされることでGC Rootとなるのだけれども、先述のLIFOの順番を守るようになっている。トリックとしては、RootCollectionというリストを用意して、Root<T>の生成時にこれに登録していく。スコープを抜ける際に、生成されたRoot<T>LIFOで破棄されるはずなので、破棄タイミングでアサーションを仕込んでRootCollectionからも取り出すようにして、LIFOな順番を保証するようにしている(ただ、Rustの言語挙動にかなり依存している面は否めない……)
  • JSRef<T>は、Root<T>に対する単純な参照。なんども無駄にRoot<T>を生成したくはないしね!

とまあ、ざっとこんな感じになる。 こうやって、ServoはSpiderMonkey GCにDOMの生存管理を丸っと任せているというわけ。

Servo DOM bindingの今後

第1に、SpiderMonkeyを現在使用しているGecko 17相当から、Gecko 3x相当までアップグレードする必要が有る。性能試験という意味でも、先述した世代別GCの効果測定の意味でも、流石に古いままではテスト対象として微妙だからだ。

第2に、SpiderMonkeyに対してGC Rootを伝える方法の改善。今のようにStack Scannerが辿るよりも、明確にどれがRootであるのかを示すようにした方が良いのは言うまでもない。

第3に、JSObjectの中にRustオブジェクトを格納する。たしか、一発でアロケーションできるようにしたいとかそんな感じだったはず。しばらく全然進捗ないし、自分も昔一回見たっきりなので、効用までは詳しく追ってません……

第4に、DOM APIの実装。そもそも実装してるAPIがまだまだ足りないので、全然ベンチマークも取れないという問題が有るので、ゴリゴリ実装して行かなければ。

だいたいこんな感じ。こうやって構築したDOMツリーに対して、レイアウト側からどのようにさわっているかについては、そのうち書ければ書きたい。