この記事はChromium Browser Advent Calendar 2017の3日目の記事になります。

今日の担当はamiq11(twitter, chromium)です。2016年の4月からBlink-WorkerチームでServiceWorkerの実装をしています。このadvent calendarに登録しているchromiumコミッターのなかでもChromium歴が浅いのでちょっと記事かくのドキドキしちゃいますが、がんばります٩(●˙▿˙●)۶

はじめに

さて、なうでやんぐな機能であるところのServiceWorker、みなさんつかってますか?
最近はWebKitでも開発中になったということで話題になりましたよね!
PWA (Progressive Web App)を作る上でもベースとなるこのAPI、その内部の実装について、このアドベントカレンダーでは全体の動きと最近の高速化という2つに分けてざっくり説明してみようと思います。

前半となる今回は、以下の3種類のWorker一味の動きについて、プロセス構成とからめて簡単に紹介したいと思います。

  • Worker (MDN)
  • SharedWorker (MDN)
  • ServiceWorker (MDN)

Chromiumはなんでたくさんプロセスがあるの?

まず、Chromiumのプロセス構成についてざっくり解説します。
Chromiumはたくさんのプロセスがあるのは有名な話かと思います。右上のメニューから「その他のツール」→「タスクマネージャ」なんてやるとプロセスの一覧が見れたりします。

chromium-processes-removed.png

これをみると、「ブラウザ」、「GPUプロセス」という2つのプロセスに加え、おおよそそれぞれのタブが別のプロセスに割り振られていることがわかります。このタブごとのプロセスを「レンダラプロセス」といいます。
このマルチプロセス構成は、Sandboxというセキュリティ上重要な機能のために存在します。詳しい内容についてはこちら (win)こちら(linux)を参照していただければと思いますが、ざっくり言うとプロセスを分けて権限を最小限にしようぜという話です。レンダラプロセスでは各サイトのHTMLやJavaScriptなどを読み込んで実行するため、悪意のある入力(ウェブサイト)を受け取る可能性があります。そのため、レンダラプロセスではファイルやネットワークの読み書きなどに関して制限を設け、必要なときはIPCを発行してブラウザプロセスが行います。
簡単に図示すると、以下のような感じになります。
process-model.png

たとえばa.comを開いたとすると、ブラウザプロセスがレンダラプロセスをつくり、ネットワークリクエストがブラウザプロセスへ投げられ(1)、ブラウザプロセスがネットワークにリクエストを投げ(2)、レスポンスがブラウザプロセスに帰ってきて(3)、それをレンダラになげる(4)、という手順になります。
実際には最近はこの手順がPlzNavigateプロジェクトにより最適化されてプロセスを作る前にネットワークリクエストを投げていたりしますが、まぁおおよそこんな感じだと思っていて大きく間違ってはいないと思います。

Type 1: (Dedicated) Worker

さて、このうえでWorker一味がどのように動いているのか見ていきましょう。
ところで全部WorkerなのでWorkerと呼ぶとどれのことを指しているのかよくわからなくなってきませんか?僕はなります。
ということで、以降ではノーマルタイプのWorker (WebWorkerとも呼ばれたりするやつ)をDedicated Worker と呼ぶことにしましょう。
Dedicated Workerは以下のように扱うことができます。

main.html
<!doctype html>
<script>
let worker = new Worker('worker.js');
// なにかしらの続き
console.log('つづき');
</script>
worker.js
// なにか重い処理
for (;;) {
  console.log('(´・◡・`)');
}

Dedicated Workerは最も基本的なJavaScript上でスレッドを扱う機能です。つまり、上記の例ではmain.jsとworker.jsは並列に動くということになります。
この動きを時間軸上に書くと以下のような感じです。
worker-timeline.png

ポイントは、worker.jsが別のスレッド上で並列 (parallel) に実行されているということです。また、これらのスレッドはレンダラプロセス上に存在するため、実際には前述したように1, 3のリソース読み込みはブラウザプロセスへIPCでお願いすることになります。

Dedicated Workerは別のスレッドで動きますが、タブを閉じる、iframeを消すなどしてDedicated Workerを作ったコンテキストが死ぬと、同時にそのスレッドも消されます。

Type 2: Shared Worker

Dedicated Workerでは各コンテキストごとにスレッドが存在しましたが、Shared Workerはその名の通りコンテクスト間で共有されます。
Shared Workerの使い方は、作るだけならDedicated Workerと変わりません。

main.html
<!doctype html>
<script>
let worker = new SharedWorker('worker.js');
// なにかしらの続き
console.log('Main threadだよ');
</script>
worker.js
// なにか重い処理
for (;;) {
  console.log('Shared Workerだよ(´・◡・`)');
}

Workerと同じような動きをすることを考えると、時系列で見るとこんな感じになりそうです。
shared-worker-timeline-before.png
(注:1のmain.htmlの読み込みや3のworker.jsの読み込みはちょっとサボって書いてあります。これらも本当はブラウザプロセスへのIPCが必要です。)

しかし、Shared Workerの場合は複数のコンテキストを用意するとDedicated Workerと動きが異なってきます。プロセスの説明をした際に、「おおよそタブごとにプロセスが分かれている」という説明をしましたが、Shared Workerを使うタブが複数あった場合にはどうなるでしょうか。

shared-worker-process-model.png

先ほどの例のようにmain.htmlがworker.jsをつかう場合を考えてみます。すると、上の図にmain.htmlがまずブラウザプロセス経由で読み込まれて実行され(図中1)、SharedWorkerも同じプロセスにつくられます(図中2)。そしてそのタブを開いたまま別のタブでmain.htmlを開くと(図中3)、main.htmlは2のSharedWorkerを共有する必要があります。
このとき、図からわかる通り通信する経路がブラウザプロセス経由しかありません。Shared Workerとページ中のJavaScriptのコンテクストのやりとりはMessagePortというAPIを用いて行うことができますが、この図のようにMessagePortの実装はプロセス間通信となります。
実際にブラウザプロセス中で対応するSharedWorkerの実態がどのプロセスにいるかを探しているコードはこのあたりになります。
ここまでの説明を時系列にまとめるとこのような感じになります。(図はShared Workerが見つからずに新しく作る場合。)
shared-worker-timeline-after.png
(注:1のmain.htmlの読み込みや4のworker.jsの読み込みはちょっとサボって書いてあります。本当はブラウザプロセスへのIPCが必要です。)

Shared Workerは、それをつかうすべてのページがなくなると同時に消されます。

Type 3: Service Worker

さて、ついに本題のService Workerの動き方について紹介します。
Service WorkerはよくPWAをつくるための基本技術の一つとして、プッシュを受けたりオフライン対応をするためのJavaScriptのコンテキストとして紹介されることが多いと思います。このService Workerの動きがユニーク部分としては、「ページとは独立して動くJavaScriptのコンテキスト」であるという点だと思っています。詳しい説明はこのあたりにあるのでぜひ確認してみてください。

ここでは、もっともシンプルなfetchイベントを使ってリクエストを中継する処理を考えてみましょう。

main.html
<!doctype html>
<script>
navigator.serviceWorker.register('worker.js', {scope: 'in-scope'})
    .then(() => {
        console.log('登録終了するとここにくる');
    });

// なにかしらの続き
console.log('Main threadだよ');
</script>
<iframe src="in-scope"></iframe>
worker.js
console.log('service workerだよ');

addEventListener('fetch', (e) => {
    e.respondWith(fetch('other.html'));
});
other.html
hogehogehoge

この例では、たとえばmain.htmlがhttps://example.com/main.html にあったとすると、 https://example.com/in-scope から始まるURLに来たリクエストに対してother.htmlをレスポンスとして返します。つまり、main.htmlのiframeの中にはhogehogehogeと表示されます。

Service Workerでは、初回の訪問時でServiceWorkerを登録し、次回以降にそれを使ってレスポンスを返すことができる、というのはService Workerについて調べたことのある人であればご存知かと思います。今回は、Service Workerのあるなしで”in-scope”を読み込んでいるiframeのロードがどのように変わるか見てみましょう。
Service Worker無しの場合は、以下のように単純にin-scopeをロードし、表示(実行)します。
service-worker-inscope-simple.png

ただ、今回はブラウザプロセスを経由する部分を省略せずきちんと書くことにします。すると、以下のようになります。

service-worker-inscope-to-browser.png

ここで、1-2のようにServiceWorkerが登録されているか確認するというステップが追加されています。発行されたメインリソースのリクエストはServiceWorkerが受け取る可能性もあるため、ServiceWorkerを使わない場合でも一旦ブラウザプロセスでエントリが存在するかどうかを確認する必要があります。

次に、Service Workerありの場合です。
service-worker-inscope-to-worker.png

先ほどの例と異なり、Service Workerが存在する場合には1-3のリクエストを投げる先がService Workerになります。
また、もしWorker threadが立ち上がっていなかった場合は、そのWorker threadを作る操作も必要になります。

さて、ここでこれらの通信をプロセス間の関係性に注目して見てみましょう。
service-worker-process-model-onepage.png
数字は先ほど時系列で見たものと合わせてあります。まずレンダラプロセスからin-scopeのリクエストをブラウザプロセスに投げ、ブラウザプロセスではディスク、もしくはすでに起動しているService Workerの中から対応しているものがあるか探します。もしService Workerがあったらそのリクエストをワーカーに投げてfetchイベントを発火し、respondWith()で帰ってきたレスポンスをブラウザプロセス経由でレンダラプロセスに投げ返します。
なぜいちいちブラウザプロセスを中継するかというと、これはService Workerが別のプロセスに存在する可能性があるからです。たとえばShared Workerと同じように別のタブで同じページが開かれた場合を考えてみましょう。すると、2つのページで同一のService Workerを使うため、プロセス構成は以下のようになります。
service-worker-process-model-twopage.png

このように、ブラウザプロセスを介して、2つのタブをひとつのworker.jsでホストすることになります。

まとめ

本日は、ブラウザプロセスとレンダラプロセスの関係性と、Dedicated Worker/Shared Worker/Service Workerがどのようにスレッドやプロセス間通信を行っているかを紹介しました。
Dedicated Workerはレンダラプロセス内で完結していましたが、Shared WorkerやService Workerでは一度ブラウザプロセスを経由してやりとりをしなければならないことを説明できていたら嬉しいです。
次回の自分の番では、このプロセス間通信やスレッドの関係性をもとに、この1年ほどでどのようにService Workerの高速化が行われたかを紹介したいと思います。

雑記

仕事としてChromiumの開発をはじめた去年ごろにこのあたりのプロセス間通信のライブラリを変える仕事をしていたのですが、やればやるほど並列分散処理系の実装は難しいということをひしひしと感じています。。

あと、思い出しつつ調べつつ書いていたら予想よりはるかに記事を書くのに時間がかかってしまって、細かいところがやや雑になってしまったような気がします。もし質問などありましたらぜひコメントか@MakotoShimazuにリプでも飛ばしてください٩(●˙▿˙●)۶