WebKitを探訪する

WebKitを探訪する

URLを開いてから、画面のボタンが動くまで。
一つの小さなページを手がかりに、エンジンの部品と仕事の流れを辿る。

01WebKitは、ページを動かすエンジン

リンクを開くと、文字や画像が表示される。 ボタンを押すと、文字が変わる。 その間に、コンピューターは何をしているのだろう。 ここでは、次の小さなページが動くまでを入口にして、WebKitの中を歩いていく。

<!doctype html>
<html lang="ja">
  <meta charset="utf-8">
  <title>読書メモ</title>
  <style>
    button { color: navy; }
  </style>
  <button id="read">読む</button>
  <script>
    const button = document.querySelector("#read");
    button.addEventListener("click", () => {
      button.textContent = "読んだ";
    });
  </script>
</html>

WebKitは、Webの文書やプログラムを処理し、表示や操作を支えるエンジンだ。 HTMLという文書の記述、CSSという見た目の規則、JavaScriptというプログラムを、それぞれの規則に従って扱う。 単にHTMLを画像に変換して終わるものではない。 クリックを受け、文書を変更し、通信し、再び画面を更新するところまでが続いている。

SafariはWebKitを使うアプリケーションの一つだ。 タブやアドレス欄などを持つブラウザー製品と、その中でWebの内容を処理するエンジンは、担当する範囲が違う。 ブラウザー以外のアプリもWebKitを組み込める。 Appleのプラットフォームでは、その入口の一つがWKWebViewというAPIだ。

1 アプリケーションからWebの内容へ
Safari
タブ、アドレス欄などの操作画面
Webを表示するアプリ
アプリ自身の画面と操作
Webコンテンツの処理を依頼
WebKit
文書を読み、スクリプトを動かし、表示と操作を支える
通信、文字、描画などの機能を利用
OSとプラットフォームの機能
ネットワーク、フォント、グラフィックス、メディア
まとまりは担当範囲、矢印は利用関係。Safariは組み込み先の一例で、WebKitの利用者はブラウザー製品に限られない。

図1の最下段にOSの機能があることも大切だ。 WebKitは、ネットワークやフォントや動画再生のすべてを、どのOSでも同じ独自コードだけで処理するわけではない。 Webの規則を実装する共通部分と、動作する環境の機能が協調している。 まずは「アプリがWebKitへ仕事を頼み、WebKitが必要な部品を使って処理する」と捉えると、次の分解を追いやすい。

この節の出典とコードの入口

02一つの上流から、複数の環境へ

このエンジンは、最初から現在の形で生まれたものではない。 WebKitにはKHTMLから継承したコードがあり、公式の回顧ではプロジェクトの開始を2001年6月25日としている。 2003年にはSafariが発表され、同年に1.0が公開された。 2005年のWWDCでのオープンソース公開は、開かれた開発の節目として記録されている。 これは、それ以前に関連コードが一切公開されていなかったという意味ではない。

2 共通の上流と、それぞれの届け先
  1. 2001WebKitプロジェクト開始。KHTMLを基に開発
  2. 2003Safariの発表と1.0の公開
  3. 2005開かれた開発体制の節目
  4. 2013ChromiumがBlinkへの派生を発表
共通の上流WebKit
共通部分とプラットフォーム向け実装を開発
各ポートが統合してリリース
Apple向け
Safari、WKWebView
WebKitGTK / WPE
Linux向けの統合
PlayStation向け
製品向けの統合
上段は歴史上の節目。下段の矢印は移植と統合の関係であり、同じ日に同じ機能を出荷するという意味ではない。

図2の2013年には、ChromiumプロジェクトによるBlinkの発表がある。 BlinkはWebKitから派生したエンジンだが、現在のWebKitを調べるときに、Chromiumの内部構成をそのまま当てはめることはできない。 共通の出発点と、現在の実装が同じであることは別だからだ。

一方、現在のWebKit自体にも、複数の届け先がある。 共通のエンジンを特定の環境へ適応させる実装と統合をポートと呼ぶ。 Apple向けのポート、Linux向けのWebKitGTKとWPE WebKit、PlayStation向けのポートなどがあり、共通コードを共有しながら環境ごとの仕事を進めている。 公式資料は主要な保守主体としてApple、Igalia、Sonyを挙げるが、それだけで全貢献者や担当範囲を言い尽くせるわけではない。

この構造から、記事を読むときの確認事項が一つ決まる。 「WebKitのソースにある」は、「手元のSafariで使える」と同じではない。 各ポートはそれぞれリリースし、ビルド条件や設定も異なる。 本記事のコード説明は、2026年9月7日の上流コミットを固定して読んだ結果であり、特定の出荷版を実測した説明ではない。

この節の出典とコードの入口

03コードの部品を見分ける

例のページを処理するには、文書を扱う部品、JavaScriptを動かす部品、アプリとの連絡を担う部品が必要になる。 ソースのSourceディレクトリを見ると、その役割が名前になって現れる。 コードは主にC++で書かれ、C、アセンブリー、AppleのAPIとの統合に使うObjective-Cなども含む。

WebCoreは、Webの文書と機能を実装する中心だ。 HTMLとCSSの解析、文書の構造、要素の振る舞い、位置や大きさの計算、描画、さまざまなWeb APIを扱う。 「読む」というボタンがどんな要素で、何という文字を持ち、どこに置かれるかは、この領域へつながる。

**JavaScriptCore(JSC)**は、JavaScriptの言語仕様であるECMAScriptを実装するエンジンだ。 例のスクリプトを解析して実行する仕事はこちらへつながる。 ただし、documentやボタンの操作までがJavaScriptという言語そのものに含まれるわけではない。 WebCoreが提供する機能とJSCの間には、あとで辿る接続部分がある。

3 Sourceディレクトリの役割分担
WebKit:組み込みと協調
アプリ向けAPI、ページの代理、プロセス間通信
ページの仕事につなぐ
WebCore
HTML / CSS / DOM
レイアウト、描画、Web API
JavaScriptCore
JavaScriptの解析と実行
最適化、GC、WebAssembly
WebCore ⇄ JSバインディング ⇄ JavaScriptCore
WTF
文字列、コンテナー、参照型など
bmalloc
メモリー領域の割り当て
platform / PAL
OS依存機能への接点
箱はコードの部品。WebCoreとJavaScriptCoreを別プロセスとして描いた図ではない。基盤の欄は主な役割を示し、完全な依存グラフではない。

図3の上にあるSource/WebKitは、プロジェクト全体の名前と同じなので少し紛らわしい。 このディレクトリでは、アプリ向けAPIやプロセス間の協調など、WebCoreを製品から利用するための統合を扱う。 旧来の単一プロセス統合を扱うWebKitLegacyとは区別され、複数プロセスの仕組みはWebKit2とも呼ばれる。 「2」はCSSやJavaScriptのバージョンではない。

下段は共通の土台だ。 **WTF(Web Template Framework)は文字列、コンテナー、参照を扱う型などを提供する。 bmallocはメモリー領域の割り当てを担う。 WebCoreのplatformPAL(Platform Abstraction Layer)**は、OS依存の機能との接点になる。 これらの名前を最初から覚える必要はないが、「どの部品が何を持っているのか」を確かめる地図として使える。

この節の出典とコードの入口

04仕事を実行する場所を分ける

部品の地図ができたら、次は実行場所を見る。 プロセスは、OSが実行とメモリー空間を管理する単位だ。 スレッドは、そのプロセス内で処理を進める実行の流れを指す。 コードをどのライブラリーに置くかと、処理をどのプロセスやスレッドで動かすかは、別の分け方になる。 WebCoreという部品があるからといって、WebCore専用のプロセスが必ず一つ起動するわけではない。

複数プロセス構成では、アプリ側をUIプロセスと呼ぶ。 Webページの内容やコードを扱うのがWebContentプロセスで、ソースではWebProcessという名前も現れる。 Networkプロセスは通信だけでなく、HTTPキャッシュやWebサイトの保存領域の管理も担う。 さらに、構成に応じて描画やメディア処理を分離するGPUプロセスがある。

4 同じWebKitの仕事を、複数のプロセスに分ける
UIプロセス:アプリ側
WebPageProxy
ページへの要求と通知の窓口
IPC:読み込み要求 ↓ / ページの状態通知 ↑
WebContentプロセス:Webの内容を処理
WebPage → WebCore::Page
Document、フレーム、レイアウト
JavaScriptCoreによるページのコード実行
IPC:リソース要求 ↓ / 応答データ ↑
Networkプロセス
取得と保存領域の管理
HTTPキャッシュ、通信、サイトのストレージ
IPC:描画やメディアの依頼(条件付き)
GPUプロセス
分離された描画やメディア処理
物理GPU自体ではなく、CPU上のプロセス
背景でまとめた区画がOSプロセス。プロセス間の矢印はメッセージの受け渡し(IPC)。GPUプロセスの利用範囲とページの配置は構成によって変わる。

図4の境界を跨ぐときには、**IPC(プロセス間通信)**で依頼や応答を送る。 別プロセスのC++オブジェクトを、手元の普通のポインターでそのまま呼ぶ関係ではない。 WebKitには非同期の送受信だけでなく、同期の通信を扱うAPIもある。

具体的な窓口として、UI側にWebPageProxy、WebContent側にWebPageがある。 前者はページへの要求や通知を扱う代理で、後者はWebCoreのPageへつながる。 似た名前が並んでいても、所属するプロセスと責務を見ると区別できる。

また、タブとプロセスの数には固定の一対一対応がない。 一つのページには埋め込まれた別の文書がありうるし、隔離の設定によってその配置も変わる。 GPUプロセスも物理的なGPUチップを指す名前ではない。 図の箱を「実行するソフトウェアの区画」として読むことが、部品名との混同を防ぐ。

この節の出典とコードの入口

06HTMLから、文書の木を組み立てる

ネットワークから届くものは、最初はバイト列だ。 文字コードに従ってデコードすると文字列になり、HTMLパーサーへの入力になる。 WebCoreではDecodedDataDocumentParserTextResourceDecoderを使う経路を確認できる。

HTMLの解析は、大まかには二つに分けられる。 まず開始タグや終了タグ、文字などのトークンへ分ける。 次に、そのトークンとHTMLの木構築規則を使って、文書のノードを組み立てる。 HTMLDocumentParserHTMLTreeBuilderが、その処理を辿る入口になる。

6 文字列から、操作できる文書の木へ
  1. バイト列受信した文書のデータ
  2. 文字列文字コードに従ってデコード
  3. トークン開始タグ、終了タグ、文字など
  4. DOM木構築の規則でノードを組み立てる
Document
  • html
    • head
    • body
      • button
        • Text「読む」
矢印は変換、インデントは親子関係。説明に必要な部分だけを示し、実装のダンプではない。DOMには要素以外にDocumentやTextもある。

こうしてできる**DOM(Document Object Model)**は、文書をノードの木として表し、プログラムから扱うためのモデルだ。 図6では、文書全体のDocument、タグに対応するElement、文字内容のTextが別々のノードとして現れる。 ボタンと「読む」という文字は、同じ一個のノードではない。

冒頭のHTMLではheadbodyのタグを明示していないが、DOMにはそれらの要素が作られる。 パーサーには省略を補う規則や、不正な入れ子から回復する規則がある。 したがってDOMは、ソースの字面をそのまま保存した木ではない。 HTMLソースを見ることと、開発者ツールで現在の要素を見ることに差が出る理由の一つだ。

文書と、それを収める場所も分けて考えよう。 iframeはページの中に別の文書を持てるため、一つのページに複数のDocumentが存在しうる。 その文書を収めるフレームの親子関係と、一つの文書内のDOMの親子関係は別の木だ。 LocalFrameは、同じプロセスにある文書、ローダー、表示を担うLocalFrameViewへの接点を持つ。

もう一つの木であるShadow DOMも、通常の子ノードの木と区別される。 ただし、それ自体が別プロセスや別オリジンの隔離境界になるわけではない。 「木が別」というだけで、実行場所や権限まで別だとは結論できない。

この節の出典とコードの入口

07CSSとDOMから、表示に必要な構造を作る

DOMにはボタンがあるが、それだけでは文字色も幅も決まらない。 CSSを読み、どの規則をどの要素に適用するかを調べる必要がある。

CSSにも解析の段階がある。 StyleSheetContents::parseStringや外部スタイルシートを扱う経路は、CSSの文字列をCSSParser::parseStyleSheetへ渡す。 そこで得たスタイルシートの規則と、各要素の情報を使って、Style::Resolverが要素のスタイルを求める。 CSSOMはCSSのスタイルシートなどをプログラムから扱うモデルで、CSSStyleSheetcssRulesinsertRuleもこの領域にある。

7 DOMとCSSが、スタイル解決で合流する
HTMLの解析
要素と親子関係
DOMを作る
CSS文字列
button { color: navy; }
CSSParserで規則へ解析
Style::Resolver
規則の適用、カスケード、継承などから要素のスタイルを求める
要素のスタイル → RenderTreeUpdater
描画に必要な構造
RenderObjectなど。ボックスを作らない要素も区別
矢印はデータの入力と変換。CSSの規則、要素の計算済みスタイル、表示用の構造は異なる成果物。

図7の合流点では、単に最後に書いた規則を選んでいるわけではない。 宣言の出所、重要度、カスケードレイヤー、セレクターの詳細度、継承などが値の決定に関わる。 「このボタンの文字はnavyにする」という結果と、「そのボタンをどの位置へ置くか」という結果も別だ。 参照コミットの描画オブジェクトでは、計算済みスタイルをStyle::ComputedStyleという型で扱っている。

表示のための構造もDOMとは別に作る。 WebCoreにはRenderObjectを基盤とする描画用の木があり、RenderTreeUpdaterがスタイル変更に応じてその作成、更新、破棄を行う。 違いを見るため、本文の例を少しだけ変えてみる。

<div style="display: contents">
  <button>読む</button>
</div>
<p style="display: none">下書き</p>
8 同じ内容でも、必要な木は異なる
DOM:文書の構造
  • div(contents)
    • button
      • Text「読む」
  • p(none)
    • Text「下書き」
ボックス:表示の構造
  • buttonのボックス
    • 文字「読む」

div自身とpのボックスは作らない

アクセシビリティ:操作の意味
  • 役割:ボタン
    • 名前:読む
    • 操作:押す

不要なノードをそのまま複製しない

body内の抜粋を使った模式例。空白Text、匿名ボックスなどは省略。アクセシビリティ欄は公開する意味の例で、特定OSの実際のツリーではない。

図8の左側では、非表示のpもDOMに残っている。 display: noneは、その要素と子孫の表示用ボックスを作らない指定であり、DOMから要素を削除する操作ではない。 一方、この例のdisplay: contentsdiv自身のボックスを作らず、子のボタンにはボックスを作れる。 これを置換要素なども含めた全要素に無条件で一般化することはできないが、二つの木が違う理由は見える。

逆に、描画用の構造には、HTMLに要素として書いていない匿名のオブジェクトが必要になる場合もある。 「DOMのノードごとに描画の箱を一つ作る」では足りないのだ。 図8の右側に置いたアクセシビリティの構造も、あとで見るように、さらに別の目的で情報を組み立てる。

この節の出典とコードの入口

08位置を決め、描いて、画面へ届ける

スタイルと表示用の構造が揃うと、次はレイアウトで位置や大きさを求める。 幅、高さ、周囲との関係、文字がどこで折り返すかなどを計算する仕事だ。 文字の幅にはフォントや字形が関わるので、単純な文字数だけでは決まらない。 WebCoreのFontCascadeには、フォントの選択や文字幅の測定、テキスト描画への接点がある。

位置が決まっても、まだ画素を描いたことにはならない。 paintでは、背景を塗る、文字を描く、画像を描くといった操作を、GraphicsContextなどを通して描画先へ渡す。 描画操作を画素へ変える仕事はラスタライズと呼ばれ、操作を組み立てる段階と実際の処理先が分かれる経路もある。

9 「読む」ボタンが、位置と画素を得るまで
スタイル
背景、文字色、幅などの値
レイアウト
幅 120 / 高さ 40
読む
位置 x=20, y=20
スタイルと幾何情報から準備
合成レイヤーの構造と描画先
RenderLayerCompositorなどが要件と対応を更新
必要な内容をpaint(同期時にも起こりうる)
描画操作 → 画素になる内容
背景を塗る / 字形を描く
GraphicsContextなどを経て描画先へ
内容とレイヤー状態を表示経路へ
プラットフォームの合成と提示
読む
他の描画内容と組み合わせて画面へ
矢印は成果物の受け渡しを表す。レイヤーの準備や同期はpaintと関わり合うため、全環境で固定された関数の呼び出し順ではない。寸法は説明用の例。

図9の中央に「合成レイヤーの構造と描画先」を置いたのは、合成がpaintの後だけに現れる仕事ではないためだ。 合成は、複数の描画内容をそれぞれの位置や重なりなどに従って組み合わせる処理だが、そのためのレイヤー構造は先に準備する必要がある。 RenderLayerCompositorは、スタイルやレイアウトの更新に伴って合成の要件やレイヤーの対応を更新する。 RenderLayerBackingがレイヤー内容のpaintに関わり、レイヤーを同期するときにpaintが起こる場合もある。

そのため、RenderLayerCompositorを「すべてのpaintが終わった後で、画面を完成させるだけの関数」と読むと役割を取り違える。 レイヤーの準備と、プラットフォーム側での最終的な合成や提示を分けておこう。 DOM要素、RenderObjectRenderLayerGraphicsLayer、GPUのテクスチャにも、固定の一対一対応はない。

表示経路はポートや設定によって変わる。 GPUプロセスを利用する経路にはRemoteRenderingBackendなどがあり、描画を頼む場所と処理する場所を分離できる。 「WebKitの描画は全部GPUで行う」とも、「paintは常にWebContentの同じスレッドで完了する」とも一括りにはできない。

最後に、ここまでの処理は一回限りではない。 文字やスタイルが変われば、必要な計算と描画を繰り返す。 WebCoreはneedsLayoutなどの状態で更新の必要性を管理するため、どんな変更でも文書全体を最初から作り直すわけではない。

この節の出典とコードの入口

09JavaScriptから文書を変更する

ここでボタンを押して、文字を「読んだ」へ変えてみよう。 イベントハンドラーの中で実行されるのは、button.textContent = "読んだ"という代入だ。 この一行は、JavaScriptという言語の実行と、Webの文書の操作をまたいでいる。

JSCは代入のような言語の処理を実行する。 一方、textContentがどのように文書の文字内容を変更するかは、DOMの機能だ。 WebCoreのC++オブジェクトと、JavaScriptから見えるオブジェクトをつなぐバインディングが、この境界を受け持つ。

10 textContentの代入は、言語とDOMの境界を渡る
JavaScriptCoreでコードを実行
button.textContent = "読んだ"
JavaScriptから見えるプロパティへ代入
JSバインディング
APIの呼び出しをWebCoreの実装へつなぐ
C++側のDOM操作へ
WebCoreのノードが変わる
文字内容を変更し、必要な更新へつなぐ
描画機会などで必要な処理
表示が変わる
スタイル、レイアウト、paintなどを必要に応じて更新
矢印は呼び出し、最後は表示更新へのつながり。DOM操作を一回行うたびに、直ちに画面へ提示するという意味ではない。

図10で、JavaScriptから見えている側はラッパーと呼ばれる。 APIの形はWeb IDLという記述でも定義され、その定義からビルド時に生成したC++コードと手書きのコードがバインディングを構成する。 Web IDL、生成された接続コード、実際のDOMの実装という順に辿ると、「JavaScriptで書けること」がC++の仕事へ変わる場所を見つけられる。

ラッパーとC++オブジェクトの対応にも条件がある。 異なるDOMWrapperWorldが一つのC++オブジェクトに対して別々のラッパーを持つ場合があるので、常に一対一と決めつけない。 ただし、このボタンの例を理解するには、まず「言語のエンジンと文書の実装を接続している」と分かれば十分だ。

文字内容が変更された時点と、利用者が新しい文字を画面で見る時点には間がある。 DOMの更新から、必要なスタイルやレイアウト、paintへつながり、その内容が表示される。 APIを呼ぶたびに即座に画面を一枚描く仕組みではないことを、次の実行順序の話で確かめよう。

この節の出典とコードの入口

10JavaScriptを、始めやすく速く動かす

その前に、JSCがスクリプトをどう実行するかを少し掘り下げる。 コードの文字列は、字句解析と構文解析を経て、実行用のバイトコードになる。 JSCには、それを解釈する方式と、実行時に機械語へ変換するJIT(Just-In-Time)コンパイルがある。

機械語にすれば、準備の費用が消えるわけではない。 変換や最適化にも時間とメモリーを使う。 一度しか動かない短い処理へ多くの最適化時間をかけても、その費用を取り戻せない場合がある。 繰り返し動く処理では、逆に費用をかけた最適化が役立つ。

11 実行を始める速さと、繰り返す速さを釣り合わせる
  1. 解析 → バイトコード実行用の命令列を作る
  2. LLIntバイトコードを解釈して実行
  3. Baseline JIT機械語に変換し、実行情報も集める
  4. DFG JIT実行情報を使って最適化
  5. FTL JITさらにコンパイル費用をかけて最適化
↑ 仮定が成立しなくなったら、状態を引き継いで低い段階へ戻る

JITが使えない構成もある。JavaScriptの意味を変えるための階段ではない。

下向きの並びは代表的な実行段階。昇格は実行情報と設定に依存し、全コードが全段を通るとは限らない。戻り道は最適化の仮定が崩れたときのOSR exitを表す。

図11の**LLInt(Low Level Interpreter)**はバイトコードを解釈して実行する。 Baseline JITDFG JITFTL JITは、実行状況に応じてより強い最適化を行う段階として登場する。 構成や実行情報によって使われ方は変わり、すべての関数がこの階段を最後まで上るわけではない。 JITを利用できない構成もある。

最適化では、「この値はこれまで数値だった」といった観測から仮定を置くことがある。 仮定が成立する場合には処理を簡略化できても、あとで違う値が来れば、そのままでは正しい意味を保てない。 そこでOSR exitなどにより、実行状態を引き継いで低い段階へ戻る道が用意されている。 この戻りは、それ自体がJavaScriptプログラムのエラーというわけではない。 速く動かしながら、言語の意味を保つための仕組みだ。

JSCにはWebAssemblyの実装もあるが、その実行方式をこのJavaScriptの四段階と同じものとして扱うことはできない。 関心がある場合はSource/JavaScriptCore/wasmが別の入口になる。

この節の出典とコードの入口

11処理の順番と、表示の機会をつなぐ

JavaScriptが動くことと、いつ動くかは別の問題だ。 クリック、タイマー、通信の応答などがある中で、ブラウザーは処理の順番を管理する。 その枠組みがイベントループだ。 HTMLのモデルでは、タスクの実行と、マイクロタスクのチェックポイントを区別する。

たとえばクリックのハンドラーを次のように変える。

button.addEventListener("click", () => {
  button.textContent = "処理中";
  Promise.resolve().then(() => {
    button.textContent = "読んだ";
  });
});

同期的な代入の後に、解決済みPromiseに登録した処理がマイクロタスクとして実行される。 ただし、DOMが一度「処理中」になったからといって、利用者がその文字を画面で見られるとは限らない。 画面への反映より先に「読んだ」へ変わるからだ。

12 Promiseの処理と、画面の提示は別の出来事
  1. クリックのタスクイベントハンドラーを実行し、同期コードを終える
  2. マイクロタスクのチェックポイントこの例で予約したPromiseの反応を処理
  3. 描画機会がある場合の更新requestAnimationFrameのコールバックなど
  4. 必要なレイアウトや描画変更を表示用の内容へ反映
  5. 提示プラットフォームを通じて画面へ
描画機会がなければ、次のタスクなどへ進む
上から下へ進む順序の模式例。描画更新は必要な描画機会に行われる。マイクロタスクのチェックポイントは図の箇所以外にもあり、この図はイベントループ全体の仕様ではない。

図12の描画更新には、requestAnimationFrameで予約したコールバックも関わる。 これは次の表示へ向けて更新を行う入口であり、表示完了を知らせる通知ではない。 WebCoreのPage::updateRenderingでは、そのコールバックより前にも、必要に応じて後にもレイアウトを扱う処理がある。 「rAF、レイアウト、表示」という図だけで、内部のすべての順序を説明したことにはならない。

また、タスクが一つ終わるたびに必ず画面を一回更新する、という規則でもない。 描画機会に従って必要な更新が行われる。 WebCoreのEventLoop::runperformMicrotaskCheckpointは、タスクとマイクロタスクの実装を辿る入口になる。

マイクロタスクは、別のOSスレッドの名前ではない。 Promiseを使うだけで重い計算が別の場所へ移るわけでもない。 長い処理やマイクロタスクの連続がページの仕事を占めれば、入力応答や描画の機会にも影響する。 そこで、計算の担当を分ける手段が次に登場する。

この節の出典とコードの入口

12計算をWorkerへ分担する

ボタンを押した後に、大量の読書記録を集計したいとしよう。 その計算をページ側で長く実行すると、入力や表示の仕事と競合しうる。 Workerは、ページのWindowとは異なるグローバル環境とイベントループでスクリプトを実行する仕組みだ。 ここでは、new Worker("worker.js")で作る通常のDedicated Workerを考える。

13 計算を渡し、結果をページへ戻す
ページ側:Window
入力と画面
documentを操作できる
利用者の操作を受ける
Dedicated Worker側
計算
独自のグローバル環境とイベントループ
ページのdocumentは操作できない
ページ → postMessage(計算対象)→ Worker
ページ ← postMessage(計算結果)← Worker
結果を受けたページが反映
DOMの更新と表示
Workerは計算を担当し、ページが表示を担当
二つの区画は実行環境。通常のDedicated Workerは別スレッドを使う。矢印はメッセージであり、別プロセスであることを意味しない。

図13では、ページが計算対象を送り、Workerが結果を返している。 postMessageでデータを受け渡すのであって、Workerがページのdocumentを直接操作するわけではない。 DOMを変更して表示へつなぐのは、結果を受け取ったページ側の仕事だ。

参照した実装では、通常のDedicated Workerを作るWorkerMessagingProxyCreateNewThreadを指定する。 つまり、ここで使っている例は、ページの計算を別スレッドへ分ける経路として説明できる。 それだけで別プロセスに置かれるとは言えないが、Promiseのコールバックを予約することとは実行の分け方が違う。

共通のWorkerThreadクラスだけを見ると、メインスレッドで実行する分岐も見つかる。 ただし、その分岐をもって「通常のnew Workerも同じメインスレッドで動く」と判断してはいけない。 SWServerWorkerにはテスト指定や特定のページ識別子があるときにそのモードを選ぶ処理があり、呼び出し元の条件を辿る必要がある。 共通クラスに選択肢があることと、いま説明しているAPIがその選択肢を使うことは別だ。

Workerは計算を分ける道具であって、仕事そのものをなくす道具ではない。 何を送って、何を計算し、どれだけの結果を返すかまで含めて、分担を考えることになる。

この節の出典とコードの入口

13入力を受け、操作の意味を伝える

冒頭では自然に「ボタンを押す」と書いたが、座標だけを受け取っても、どの要素に届くべき入力なのかは分からない。 ポインター入力では、画面上の位置から対象を調べるヒットテストが関わる。 WebCoreのEventHandlerには、その結果を使って処理する経路がある。

対象が決まった後には、DOMのイベント配送がある。 祖先側から辿るキャプチャー、対象での処理、条件に応じたバブリングを区別する。 ヒットテストとイベントの伝播は、同じ一つの操作ではない。 また、キーボード操作など、座標の判定から始まらない入力もある。

14 座標から入る操作と、意味から入る操作
画面上の座標
ポインターの入力
ヒットテストで対象を決める
DOMイベントの配送
キャプチャー、対象、バブリング
要素の役割と名前
button / 「読む」 / 状態
アクセシビリティAPIへ公開
支援技術
意味を読み上げ、操作につなぐ
左の矢印はポインター入力の処理。右は支援技術への意味の公開と操作の関係。キーボード入力など、座標のヒットテストから始まらない入力もある。

図14の右側は、別の入口だ。 支援技術が必要とするのは、「ここに青い文字がある」という画素だけではない。 「これはボタンで、名前は読む、押す操作ができる」という意味だ。 アクセシビリティAPIを通して、役割、名前、状態などをプラットフォームへ公開する。 WebCoreのAXObjectCacheは、そのためのオブジェクトや更新を扱う。

この構造はDOMの全ノードのコピーではなく、公開する意味に応じて組み立てられる。 図8で三つ目の木を別にした理由もここにある。 プラットフォームのAPIや公開範囲にも差があるため、模式図を特定OSの実際のツリーとして扱うことはできない。

Webを書く側にも、この違いは関係する。 見た目をボタンらしくすることと、ボタンとして意味や操作を提供することは別の仕事だ。 例でbutton要素を使ったのは、文書の構造に操作の意味を持たせるためでもある。 なお、イベントの既定動作を止めるpreventDefault()と、伝播を止めるstopPropagation()も、それぞれ別の役割を持つ。

この節の出典とコードの入口

14応答、ページ状態、データを使い直す

一度読んだページをもう一度開くとき、何を使い直せるだろう。 「キャッシュ」という一語でまとめると、HTTPの応答と、動いていたページそのものが混ざりやすい。 まず、保存する対象で三つを分けよう。

HTTPキャッシュは、HTTPの規則に従ってレスポンスを再利用する仕組みだ。 Cache APIは、スクリプトからRequestとResponseの組を保存し、照合して取り出す仕組みだ。 BackForwardCacheは、戻る、進む操作で再利用するページ状態を保持する仕組みだ。 最後のものは、HTMLのレスポンスだけを保存するキャッシュとは異なる。

15 何を残すかで、キャッシュを見分ける
HTTPキャッシュ
レスポンス

ブラウザーがHTTPの規則に従って再利用

Cache API
Request / Response

スクリプトが保存と照合を扱う

BackForwardCache
ページ状態

戻る、進む操作で適格なら復元

Service Workerが取得に関与する場合
FetchEventへの応答
Cache APIから返す / 通信する / Responseを生成する
各列は別の仕組み。レスポンスを使い直すことと、動いていたページの状態を復元することは異なる。保存や復元は常に成功するとは限らない。

図15の下にあるService Workerは、制御対象のページなどの取得処理に関与できる。 FetchEventに対して、Cache APIで見つけたレスポンスを返すことも、通信することも、自分でResponseを生成することもできる。 ただし、登録、スコープ、有効化、制御状態などの条件があり、すべての通信が必ずService Workerを通るわけではない。

名前にWorkerとあっても、前節の集計用Dedicated Workerとは利用目的が異なる。 Service Workerはイベントに応じて起動され、アイドル時に終了されうる。 永続的に動き続けるバックグラウンドプロセスが得られるAPIとして設計すると、寿命の見込みを誤る。

読書記録そのものを保存したければ、localStorageやIndexedDBなどの領域も関係する。 WebKitのNetworkプロセスにあるNetworkStorageManagerは、サイトの保存領域、容量制限、削除などを管理する。 sessionStorageには同じオリジンでもページごとに異なる領域を持ちうる性質があり、名前に「保存」と付く仕組みを同一視はできない。

保存したデータにも無期限の保持保証はない。 容量や削除方針などが関わり、BackForwardCacheにも利用できるかの判定がある。 戻る操作が速かった理由を調べるときは、応答を再利用したのか、ページ状態を復元したのかを分けると、観測の意味が明確になる。

この節の出典とコードの入口

15文書と権限の境界を守る

アプリのコードと、ネットワークから来たページのコードには、与えてよい権限の違いがある。 WebKitではWebContentを制限されたサンドボックス内で動かし、ファイルやカメラなどへの操作には権限の仲介が関わる。 具体的な制限はOSやポートで変わるが、ページのコードへアプリと同じ自由を与える構成ではない。

さらに、Webの文書同士にも境界がある。 オリジンは、通常はスキーム、ホスト、ポートの組で決まる。 同じホストでもHTTPとHTTPSは異なるし、同じドメイン配下でもサブドメインが変われば異なるオリジンになる。 同じオリジンの範囲を基準に、別の文書のDOMなどへのアクセスを制限する。

16 オリジンの境界と、プロセスの境界を重ねない
https://shop.example.com:443スキーム + ホスト + ポート
同じオリジン
https://shop.example.com/cart
パスが違ってもオリジンは同じ
異なるオリジン
https://api.example.com/
同じexample.com配下でもホストが違う
プロセスA
親フレームのDocument
LocalFrame
別サイトの子の位置
RemoteFrame
プロセスB
子フレームのDocument
LocalFrame
RemoteFrame ⇄ IPCで協調 ⇄ 別プロセスのフレーム
URLは例示用。上段は同一オリジン判定。下段は別サイトiframeを別プロセスへ置くSite Isolation有効時の概念図で、出荷版の既定構成ではない。

図16の上段と下段は、異なる境界を示している。 サイトとオリジンも同義ではなく、同じサイトに属するサブドメインが別オリジンになる場合がある。 サイトの正確な定義は使う仕様や文脈にも注意が必要だ。 そして、オリジンの違いがそのままOSプロセスの違いになるとは限らない。

Site Isolationの実装では、別プロセスに文書を置くフレームをRemoteFrameで表す構成がある。 参照コミットのSiteIsolationEnabledunstableで、上書き可能な既定値がfalseと宣言されている。 図16の下段は有効な場合の考え方であり、利用中のSafariの実効設定を確認した図ではない。 ソースの構造、設定の宣言、動いている製品の状態を分けて読む必要がある。

ほかの制御にも、それぞれの仕事がある。 CORSはオリジンを跨ぐレスポンスの共有を制御する仕組みで、エラーになったからといって必ず要求自体が未送信とは限らない。 CSPは文書が読み込むものや実行するものなどに制約を与える。 サンドボックス、同一オリジンの境界、CORS、CSPを一つの壁としてまとめると、何を制限しているのかが分からなくなる。

プライバシーのためには、第三者の保存データやキャッシュを第一者サイトごとに分けるpartitioningなども使われる。 そのため、データをどの単位で保存し、どこから使えるかには、APIの基本機能だけでなく追跡防止の方針も関わる。 いずれの仕組みも、単独ですべての問題をなくすものとしては扱えない。

この節の出典とコードの入口

16動画とCanvasも、ページに参加する

ページに動画を一つ置くと、四角形と文字だけの話では足りなくなる。 videoはDOMの要素であり、ページ内で場所を持つ。 一方、メディアを読み込んで再生する機能は、別の部品へつながる。

WebCoreのHTMLMediaElementMediaPlayerを作り、MediaPlayerは再生バックエンドを選んで読み込みを進める。 バックエンドとは、共通の窓口の後ろで環境に応じた実処理を担当する実装だ。 Apple向けにはAVFoundationの実装があり、WebKitGTKとWPEではGStreamerを利用する。 すべての環境で両方を同時に使うという意味ではない。

17 動画の再生と、ページへの表示が出会う場所
HTMLのvideo要素
HTMLMediaElementがMediaPlayerを用意
読み込みと再生をバックエンドへ依頼
MediaPlayerと再生バックエンド
Apple向けのAVFoundation / GTK・WPE向けのGStreamerなど
通常のpaint経路
RenderVideo → video要素 → player
描画先へ動画の内容を描く
加速表示を使う条件での経路
プラットフォームレイヤー
通常のソフトウェアpaintを省く場合がある
描画内容やレイヤーをページの表示へ
他の要素と一緒に表示
videoの配置はページのレイアウトに関わる
矢印は依頼と出力の関係。二つの表示経路は条件による分岐。AVFoundationとGStreamerはポート別の例で、全環境で同時に使う部品ではない。

図17の分岐が、再生とページの描画が出会う場所だ。 通常のpaint経路では、RenderVideo::paintReplacedから動画要素のpaintを経て、MediaPlayerの描画へつながる。 一方、加速合成などの条件が揃うとソフトウェアpaintを省く分岐があり、プレイヤーのプラットフォームレイヤーへつなぐ接点もある。 したがって「動画の全フレームを、毎回ほかのDOMと同じpaintで描く」とは限らない。 ポスター表示、フルスクリーン、スナップショットでも条件が変わる。

Canvasも、要素の配置と、その中身の描き方を分けて考えられる。 Canvas 2Dでは、スクリプトから矩形、パス、文字、画像などを描く。 描いた図形一つひとつがHTML要素としてDOMに追加されるわけではない。 WebGLやWebGPUは、さらに別の描画やGPU利用のAPIを提供する。

音声にはAudioContext、カメラなどの取得にはMediaDevicesという入口もある。 ここでも、ソースにAPIの定義があること、実行するページにAPIが公開されること、利用者の許可を得ること、実際に処理が成功することは別の段階だ。 WebGPUやMediaDevicesには安全な文脈であることなどの条件があり、コーデックやハードウェアも環境によって異なる。 GPUプロセスにメディア関連の実装があることからも、その名前を「3D描画専用」と読むべきでないことが分かる。

この節の出典とコードの入口

17不要になったオブジェクトを片づける

読み込み、文書、スクリプト、描画を動かす間には、多数のオブジェクトが作られる。 必要がなくなったものをいつ片づけるかも、エンジンの仕事だ。 ただし、WebKit全体を一種類の回収方式で管理しているわけではない。

JSCには**GC(ガベージコレクション)**があり、JavaScriptのオブジェクトを管理する。 回収の判断では、出発点となるルートから参照を辿れるかが中心になる。 画面に表示されているかどうかだけでは決まらない。

const savedButton = document.querySelector("button");
savedButton.remove();
// 文書から外した後も、この変数から要素へアクセスできる。
18 画面から消えたことと、不要になったことは違う
JavaScript側:GC
ルートから参照を辿れるか
変数などからの参照
JSから見える要素
DOMから外しても参照が残りうる
C++側:所有権と参照カウント
多くのオブジェクトでRef / RefPtrなどを利用
所有する参照
WebCoreのオブジェクト
参照と寿命の規則で管理
JSラッパー ⇄ バインディングによる寿命の調整 ⇄ C++オブジェクト
bmallocなどのアロケーター
使うメモリー領域を確保する。GCの到達可能性判定とは別の役割
矢印は参照関係の模式例。JSラッパーとC++オブジェクトの寿命には調整があるため、二つの管理方式だけで全条件を描き切る図ではない。

図18の例では、文書からボタンを取り外しても、それを指す変数が残っている。 「DOMから消した」と「もう使えない」は同じではないし、「すぐにメモリーを回収した」とも限らない。 実際の寿命にはJavaScriptラッパーとC++オブジェクトの関係も含まれる。

WebCoreの多くのC++オブジェクトでは参照カウントを使う。 所有する参照を数え、その規則に従って寿命を管理する方式だ。 コードにはRefRefPtrunique_ptr、弱参照などが現れ、所有の有無やnullを取りうるかなどを区別している。 すべてのオブジェクトが同じ型で管理されるわけではない。

この二つの世界をつなぐバインディングには、ラッパーや参照先の寿命を調整する仕組みがある。 GCと参照カウントを別々に説明するだけで、その接続まで説明し終えたことにはならない。 さらにbmallocなどのアロケーターが担うのは、使うメモリー領域の確保であり、「何が不要か」を判定するGCそのものとは違う。 部品の名前へ戻ると、それぞれが異なる問いを引き受けていたことが見えてくる。

この節の出典とコードの入口

18観測からコードとテストへ進む

最初のボタンへ戻ろう。 URLを開く依頼はアプリ側から始まり、通信で得た入力が文書になる。 CSSと文書から表示に必要な構造を作り、位置を計算して描く。 クリックは対象の要素へ届き、JSCで実行した処理がバインディングを通ってDOMを変更する。 必要な更新が行われ、その結果が画面へ現れる。 ここまでに登場した名前は、この往復のどこを担当するかで結びつけられる。

自分で確かめるときは、まずWeb Inspectorで観測する対象を一つ決めるとよい。 要素とスタイルを調べるなら、HTMLソースと現在のDOMを比べる。 通信を調べるなら、どんなリソースを取得し、どこで待ったかを見る。 動作の重さを調べるなら、タイムラインでJavaScript、レイアウト、描画のどこに時間がかかったかを分ける。 そのページと端末で得た結果は、WebKit全体の性能順位を決める材料とは別だ。

19 調べた場所から、コードとテストへ歩く
要素や見た目
Web Inspectorの要素・スタイル

WebCoreのDOM / style / rendering
実行や通信
スクリプト・通信・タイムライン

JSC / loader / NetworkProcess
回帰を確かめる
再現する小さなケース

LayoutTests / JSCテスト / APIテスト
  1. 課題と修正再現条件とテストを用意
  2. レビューとEWS変更内容、ビルド、テストを確認
  3. 上流へ取り込み共有するソースが更新される
  4. ポートごとのリリース製品に搭載して利用者へ届ける
矢印は調査の入口。下段は通常の変更が届くまでの流れ。上流への取り込みと製品への搭載には別の判断と時間がある。

図19のように観測からコードへ進むと、巨大なソースにも入口ができる。 たとえば、文字内容の変更ならDOMとバインディング、ボックスの変化ならstylerendering、応答データならloaderNetworkProcessへ進める。 関数を見つけた後は、呼び出し元と条件分岐を確認する。 Workerで見たように、共通クラスに分岐があるだけでは、説明したい場面でその分岐を通るとは分からない。

変更を確かめるテストにも種類がある。 LayoutTestsは名前に反してレイアウト専用ではなく、DOMなどのWeb機能も扱う。 JavaScriptCore向けのテスト、Tools/TestWebKitAPIのAPIテスト、共有されたWeb Platform Testsもある。 通常の貢献では、課題と修正、テスト、レビュー、EWSによる確認を経て上流へ取り込む流れになる。 セキュリティ問題の報告には別の非公開の方針がある。

それでも、上流への取り込みが手元の製品への搭載完了を意味するわけではない。 ポートごとの統合とリリースが、その先にある。 WebKitを調べるときは、「どの部品か」「どこで動くか」「何を受け渡すか」「どの条件の話か」を揃えると、名前の一覧が、追いかけられる処理の流れになる。

この節の出典とコードの入口