Chapter 05

イベントループの概要と注意点

PADAone🐕
PADAone🐕
2022.12.15に更新

このチャプターについて

このチャプターでは、イベントループの各ステップと、それについてのいくつかの注意点などについて解説します。イベントループについては、『What the heck is the event loop anyway?』を見ていればなんとなくイメージがついていると思いますが、まだ未視聴の場合は動画を視聴してからこのチャプターを読んでください。

イベントループの各ステップ

まずはイベントループの各ステップについて説明しておきます。

ただし、このループはブラウザ環境の場合であることに注意してください。実装はそれぞれ違いますし、ブラウザ環境であるレンダリングのステップなどは Node や Deno ではありません。Node のイベントループにあるいくつかのフェーズはもちろんブラウザ環境にはありません。

という訳で、ある程度の抽象度でイベントループを理解したら各環境でのループの実装について調べる必要があります(これについては『それぞれのイベントループ』のチャプターで詳しく解説しています)。

イベントループはタスクキューとマイクロタスクキュー内にあるタスク/マイクロタスクを処理するためのループアルゴリズムです。イベントループは次に実行するタスクあるいはマイクロタスクを選択して、コールスタック(Call Stack)へと配置します。コールスタックに配置されたタスクやマイクロタスクが実際に処理されることで非同期処理が実現します。

JS Visualizer ではイベントループは基本的に次の4つのステップから構成されると記載されています。

  • (1) スクリプトの評価: 関数本体であるかのように(例えば main() のように)、スクリプトを同期的に実行し、コールスタックが空になるまで実行する。
  • (2) 単一のタスクの実行: タスクキューから最も古いタスクを選択してコールスタックが空になるまで実行する。
  • (3) すべてのマイクロタスクの実行: マイクロタスクキューから最も古いマイクロタスクを選択してコールスタックが空になるまで実行し、マイクロタスクキューが空になるまで繰り返す。
  • (4) UI のレンダリング更新: UI をレンダリング更新してステップ2に戻る(ブラウザ環境のみで、Node や Deno では存在しない)。

JS Visualizer を使い始めた当初は理解できていませんでしたが、上の4つのステップにおいてステップ(1)は実質ステップ(2)と同じであり、「スクリプトの評価」自体が最初のタスクになっています。文字だけの説明だと理解するのは難しいので、イベントループは擬似コードで理解したほうがよいです。

イベントループへの誤解

以下はブラウザ環境における疑似コードでのイベントループのループの仕組みです。

ブラウザ環境のイベントループ
while (eventLoop.waitForTask()) {
  // 複数個存在しているタスクキューから1つのタスクキューを選ぶ
  const taskQueue = eventLoop.selectTaskQueue();
  // このループ一周を tick と呼ぶ
  if (taskQueue.hasNextTask()) {
    // タスクキューある単一のタスクを処理する
    taskQueue.processNextTask();
  }

  // イベントループに一つしかないマイクロタスクキュー
  const microtaskQueue = eventLoop.microTaskQueue;
  // このループ一周を microtick と呼ぶ
  while (microtaskQueue.hasNextMicrotask()) {
    // マイクロタスクキューにあるすべてのマイクロを処理する
    microtaskQueue.processNextMicrotask();
  }

  // レンダリングを更新すべきタイミングなら更新する
  if (shouldRender()) {
    applyScrollResizeAndCSS();
    runAnimationFrames();
    render();
  }
}

それぞれのタスクキューとマイクロタスクキューのループでの処理はぞれぞれ "tick" や "microtick" と呼ばれますが、これについてはそういう用語がある程度にとらえておくだけで大丈夫です。

実は上のような疑似コードにはいくつかパターンがあるのですが、この疑似コードは比較的に理解しやすいものであり、次のサイトのページから拝借しています。疑似コードについてはこのページを見てもらうとよく理解できるはずです。イベントループを理解する上でも大変参考になります。

https://blog.risingstack.com/writing-a-javascript-framework-execution-timing-beyond-settimeout/

そして筆者がこの本を書き始めた時点で誤解・勘違いしていた重要な点について挙げておきます。

  • 最初のタスクは「スクリプトの評価」となる
  • ループはネストしている(イベントループの各ループにおいて、タスクは一個の処理なのにマイクロタスクの処理は完全に終わるまで続く)
  • タスクキューは複数個あり、そこから1つのキューを選択して1つのタスクを実行する
  • レンダリングの更新は起きない場合もある(各ループで必ず更新されるわけではなく、60fps の平均 16.7 ミリ秒ごとに起こる)

タスクキューへと供給されるタスク(Task)はタスク源(Task source)と呼ばれる供給源があり、それは複数存在しています。

Per its source field, each task is defined as coming from a specific task source. For each event loop, every task source must be associated with a specific task queue.
(HTML Standard より引用)

例えば、setTimeout() API のコールバックとマウスクリックから発火されるイベントからのコールバックはそれぞれべつの Task source から来るタスクであり、それぞれのタスクは別々のタスクキューへと送られます

このようにタスクはそれぞれ分離した供給源が複数個あります。そして重要なこととして、タスクキューには2つの制約があります。

  • 同一のソースから運ばれるタスクは同一のキューに属さなければならない
  • 「タスクはすべてのキューににおいて挿入された順番に処理されなければならない」

通常、setTimeout() で非同期処理を考えたりしますが、setTimeout() でタスクキューへと送ったタスクはすべて同一のキューへと配置されます。その一方で、ユーザーのクリック操作などから送られてくるタスクは setTimeout() によって送られるタスクのキューとは別のキューへと配置されます。

そして重要なこととして、イベントループの1回のループでは複数あるタスクキューから1つのみを選択してその中で最も古いタスク(キューの頭にあるもの)を実行します。

複数個のタスクキューがあるため、どれを選ぶかということが重要になってきますが、User agent が自由に選択します。従って、開発者側が正確なタイミングで特定の実行することはできません。これは Chrome 環境ならレンダリングエンジンである Blink が状況に応じて優先するタスクキューを選択しています。

ブラウザ環境はユーザー入力などのパフォーマンスに敏感なタスクを優先的に処理しようとしてイベントループ内にある複数のタスクキューから関連するキューを選びだし、その中にあるタスクを処理します。つまり、setTimeout() で登録しておいたコールバックの処理よりもマウスクリックなどの操作のほうが優先度の高い場合があり、setTimeout() で登録したコールバックの処理について大きく遅延する可能性が出てきます。

イベントループのステップ(1)とステップ(2)は実質的に同じ

JS Visualizer を使い始めた初期段階では次のコードの実行順序が理解できなくなります。実際に実行してみてください。

タスクvsマイクロタスク
console.log("🦖 [1] Mainline");

setTimeout(() => { // コールバック関数はタスクとして処理される
  console.log("⏰ [3] Callback is a task");
}, 0);

Promise.resolve()
  .then(() => { // コールバック関数はマイクロタスクとして処理される
    console.log("👦 [2] Callback is a microtask");
  });

/* 実行結果
🦖 [1] Mainline
👦 [2] Callback is a microtask
⏰ [3] Callback is a task
*/

イベントループを4ステップで考えると、出力は "⏰ [3] Callback is a task""👦 [2] Callback is a microtask" になるはずですが、実際はその逆で "👦 [2] Callback is a microtask""⏰ [3] Callback is a task" になります。そしてこの実行順番はブラウザ環境だけでなく Node でも Deno でも同じになります。

4ステップで考えると、ステップ(2)で本来ならタスクが先に実行されるべきなのに、ステップ(3)のマイクロタスクが先に実行されているため、これによってステップ(2)の「単一のタスクの実行」がスキップされているように思えてしまいます。

最初、筆者はこの事実に気付いてさえいなかったのですが、よく考えるとおかしいと感じて調査した結果、どうやらステップ(1)の「スクリプトの評価」自体がタスク(Task)として処理されているらしいという結論に至りました。現時点で情報が不正確であり申し訳無いですが、なにせイベントループの学習ソースとなる記事が少ないので「どうやらそうらしい」という結論になってしまいました(実際これは正しく、「スクリプトの評価」が最初のタスクとなります)。

JSConf.Asia での Jake Archibald 氏による講演動画『In The Loop』においても、Script がコールスタックに載っており、Script が Call Stack から pop されて初めてマイクロタスクが実行されています。

↓ 動画の 31:33 ~ のところ。
https://youtu.be/cCOL7MC4Pl0

これまた Jake Archibald 氏の記事内のデモにおいて、最初の「スクリプトの評価」もしくは「スクリプトの実行」自体が実質的にタスクとなっており、Call stack に script がプッシュされています。

https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/

実際にそれを証明するためのコードとして、次のようなコードが考えられます。pause 関数によって同期的にブロッキングした後に setTimeout() 関数と Promise.resolve().then() を競争させて、どちらのコールバックが先に実行されるかを検証します。

blockingTimer.js
// メインスレッドを同期的に指定時間だけブロッキングする関数
function pause(milliseconds, order) {
  const dt = new Date();
  while ((new Date()) - dt <= milliseconds) {
    // なにもしない
  }
  console.log(`${order} Timer exit after ${milliseconds}`);
}

console.log("[1] Sync");

// 環境にタイマー処理を委任する
setTimeout(() => {
  // コールバックはタスクとして処理される
  console.log("[4? - 5] setTimeout[0ms] finished");
}, 0);
setTimeout(() => {
  // コールバックはタスクとして処理される
  console.log("[5? - 6] setTimeout[1000ms] finished");
}, 1000);

Promise.resolve().then(() => {
  // コールバックはマイクロタスクとして処理される
  console.log("[6? - 4] then callback");
});

// ここで 3000[ms] メインスレッドをブロッキングする
pause(3000, "[2]");
// pause() が完了した時点で環境に委任したタイマーの時間は経過している

console.log("[3] Sync");

3000 ミリ秒の間メインスレッドをブロッキングしている最中に環境へ委任したタイマー処理自体は完了しているはずなので、コールバックが実行できるようになっているはずです。そしてステップ(1)がステップ(2)と同じでなければ、ステップ(2)の「単一のタスクの実行」を行うはずなので console.log("[3] Sync"); が終わった時点で次に実行されるのは console.log("[4? - 5] setTimeout[0ms] finished"); であり、"[4? - 5] setTimeout[0ms] finished" という文字列がログに順番として出力されるはずですが、実際の出力は次のようになります。

❯ deno run blockingTimer.js
[1] Sync
[2] Timer exit after 3000
[3] Sync
[6? - 4] then callback
[4? - 5] setTimeout[0ms] finished
[5? - 6] setTimeout[1000ms] finished

やはり「ステップ(1)とステップ(2)は同質のものであり、実質的にスクリプトの評価自体が最初のタスクである」と考えるとすべて辻褄があうので、この本では「イベントループのステップ1とステップ2は実は同質のものであり、スクリプトの評価自体が最初のタスクとなる」を真として話を進めていきます(実際これは正しかったです)。

そして、この事実は JSConf EU 2018 での Erin Zimmer 氏の講演動画である『Further Adventures of the Event Loop』で明確に語られていました。動画の 1:35 ~ のところ。

https://youtu.be/u1kqx6AenYw

https://2018.jsconf.eu/speakers/erin-zimmer-further-adventures-of-the-event-loop.html

ブラウザ環境において、次のようなスクリプトタグがあった場合、ブラウザはこのスクリプトタグをパースして、同期処理の部分は実際にタスクとして実行されますaddEventListener のコールバック部分については、ブラウザがキーダウンイベントを受け取った時に別のタスクとして実行されます。

<script>
  // <- Task1 (スクリプトの評価: すべての同期処理)
  const foo = bar;
  foo.doSomething();

  document.body.addEventLlistener('keydown', (event) => {
    // <- Task2 (コールバック)
    if (event.key === 'PageDown') {
      location.href = "/#/36";
    }
    // Task2 ->
  });
  // Task1 ->
</script>

このように <script> タグ内の JavaScript はまとめてタスク(Task1)として処理されます。イベントリスナーに登録されてるコールバックはイベントが発火した時点で別のタスク(Task2)として処理されます。