2023年7月9日

イベントループ:マイクロタスクとマクロタスク

Node.jsと同様に、ブラウザのJavaScript実行フローは、イベントループに基づいています。

イベントループの仕組みを理解することは、最適化にとって、そして時には正しいアーキテクチャにとって重要です。

この章では、まず物事の仕組みについての理論的な詳細を説明し、次にその知識の実用的応用を見ていきます。

イベントループ

イベントループの概念は非常にシンプルです。無限ループがあり、JavaScriptエンジンはタスクを待ち、実行してからスリープ状態になり、さらにタスクを待ちます。

エンジンの一般的なアルゴリズム

  1. タスクがある間
    • 最も古いタスクから順番に実行します。
  2. タスクが表示されるまでスリープし、次に1に進みます。

これは、ページを閲覧するときに目にすることの形式化です。 JavaScriptエンジンはほとんどの時間何もせず、スクリプト/ハンドラ/イベントがアクティブになった場合にのみ実行されます。

タスクの例

  • 外部スクリプト<script src="...">がロードされると、タスクはそれを実行することです。
  • ユーザーがマウスを動かすと、タスクはmousemoveイベントをディスパッチし、ハンドラを実行することです。
  • スケジュールされたsetTimeoutの時間が来ると、タスクはそのコールバックを実行することです。
  • …など。

タスクが設定され、エンジンがそれらを処理し、次に(スリープしてCPUをほとんど消費せずに)さらにタスクを待ちます。

エンジンがビジー状態のときにタスクが来る場合があり、その場合はキューに入れられます。

タスクはキュー、いわゆる「マクロタスクキュー」(v8用語)を形成します

たとえば、エンジンがscriptの実行でビジー状態の間に、ユーザーがマウスを動かしてmousemoveが発生したり、setTimeoutの期限が来たりするなど、これらのタスクは上の図に示すようにキューを形成します。

キューからのタスクは、「先入れ先出し」方式で処理されます。 エンジンブラウザがscriptの処理を完了すると、mousemoveイベント、次にsetTimeoutハンドラなどを処理します。

これまでのところ、非常にシンプルですよね?

さらに2つの詳細

  1. エンジンがタスクを実行している間は、レンダリングは決して行われません。 タスクに時間がかかっても問題ありません。 DOMへの変更は、タスクが完了した後にのみ描画されます。
  2. タスクに時間がかかりすぎると、ブラウザはユーザーイベントの処理など、他のタスクを実行できません。そのため、しばらくすると、「ページが応答しません」のような警告が表示され、ページ全体でタスクを強制終了することを提案します。これは、複雑な計算が多すぎる場合、またはプログラミングエラーによって無限ループが発生した場合に発生します。

それは理論でした。それでは、その知識をどのように適用できるかを見てみましょう。

ユースケース1:CPU負荷の高いタスクの分割

CPU負荷の高いタスクがあるとしましょう。

たとえば、構文の強調表示(このページのコード例に色を付けるために使用されます)は、CPUにかなりの負荷がかかります。 コードを強調表示するために、分析を実行し、多くの色付き要素を作成し、それらをドキュメントに追加します。これは、大量のテキストでは多くの時間がかかります。

エンジンが構文の強調表示でビジー状態のときは、他のDOM関連の処理、ユーザーイベントの処理などを行うことができません。ブラウザが少し「しゃっくり」したり、「ハング」したりする可能性があり、これは許容できません。

大きなタスクを複数の部分に分割することで、問題を回避できます。 最初の100行を強調表示し、次の100行にsetTimeout(遅延ゼロ)をスケジュールする、などです。

このアプローチを実証するために、簡単にするために、テキストの強調表示の代わりに、1から1000000000までカウントする関数を使用してみましょう。

以下のコードを実行すると、エンジンはしばらくの間「ハング」します。 サーバーサイドJSの場合はそれがはっきりとわかり、ブラウザで実行している場合は、ページ上の他のボタンをクリックしてみてください。カウントが完了するまで他のイベントが処理されないことがわかります。

let i = 0;

let start = Date.now();

function count() {

  // do a heavy job
  for (let j = 0; j < 1e9; j++) {
    i++;
  }

  alert("Done in " + (Date.now() - start) + 'ms');
}

count();

ブラウザは「スクリプトの実行に時間がかかりすぎています」という警告を表示する場合さえあります。

入れ子になったsetTimeout呼び出しを使用してジョブを分割してみましょう

let i = 0;

let start = Date.now();

function count() {

  // do a piece of the heavy job (*)
  do {
    i++;
  } while (i % 1e6 != 0);

  if (i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  } else {
    setTimeout(count); // schedule the new call (**)
  }

}

count();

これで、「カウント」プロセス中にブラウザインターフェースは完全に機能します。

countの1回の実行は、ジョブの一部(*)を実行し、必要に応じて自身を再スケジュールします(**)

  1. 最初の実行カウント:i=1...1000000
  2. 2回目実行カウント:i=1000001..2000000
  3. …など。

エンジンがパート1の実行でビジー状態のときに新しいサイドタスク(たとえば、onclickイベント)が発生した場合、それはキューに入れられ、パート1が完了した後に、次のパートの前に実行されます。 count実行の間に定期的にイベントループに戻ることで、JavaScriptエンジンが他のことを行ったり、他のユーザーアクションに反応したりするのに十分な「空気」が提供されます。

注目すべき点は、setTimeoutでジョブを分割する場合と分割しない場合の両方のバリアントの速度が同等であるということです。 全体的なカウント時間に大きな違いはありません。

それらを近づけるために、改善を行いましょう。

スケジューリングをcount()の先頭に移動します

let i = 0;

let start = Date.now();

function count() {

  // move the scheduling to the beginning
  if (i < 1e9 - 1e6) {
    setTimeout(count); // schedule the new call
  }

  do {
    i++;
  } while (i % 1e6 != 0);

  if (i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  }

}

count();

これで、count()を開始し、さらにcount()する必要があることがわかった場合は、ジョブを実行する前にすぐにスケジュールします。

実行すると、かなり時間が短縮されていることが easilyわかります。

なぜでしょうか?

それは簡単です。ご存じのとおり、ブラウザ内では、多数の入れ子になったsetTimeout呼び出しに対して4ミリ秒の最小遅延があります。 0を設定しても、4ms(またはそれ以上)になります。 したがって、早くスケジュールするほど、実行速度が速くなります。

最終的に、CPU負荷の高いタスクを複数のパートに分割しました。これで、ユーザーインターフェースがブロックされることはありません。 また、全体的な実行時間はそれほど長くはありません。

ユースケース2:進捗表示

ブラウザスクリプトの負荷の高いタスクを分割することのもう1つの利点は、進捗状況を表示できることです。

前述のように、DOMへの変更は、現在実行中のタスクが完了した後にのみ描画されます。タスクの実行時間がどれくらい長くても関係ありません。

一方では、それは素晴らしいことです。なぜなら、私たちの関数は多くの要素を作成し、それらを1つずつドキュメントに追加し、スタイルを変更する可能性があるからです。訪問者は「中間」の未完成の状態を見ることはありません。 重要なことですよね?

デモは次のとおりです。iへの変更は関数が終了するまで表示されないため、最後の値のみが表示されます

<div id="progress"></div>

<script>

  function count() {
    for (let i = 0; i < 1e6; i++) {
      i++;
      progress.innerHTML = i;
    }
  }

  count();
</script>

…しかし、タスク中に何かを表示したい場合もあります。たとえば、プログレスバーです。

setTimeoutを使用して負荷の高いタスクを複数に分割すると、変更はその間に描画されます。

これはよりきれいに見えます

<div id="progress"></div>

<script>
  let i = 0;

  function count() {

    // do a piece of the heavy job (*)
    do {
      i++;
      progress.innerHTML = i;
    } while (i % 1e3 != 0);

    if (i < 1e7) {
      setTimeout(count);
    }

  }

  count();
</script>

これで、<div>にはiの増加値が表示されます。一種のプログレスバーです。

ユースケース3:イベント後に何かを実行する

イベントハンドラでは、イベントがバブルアップしてすべてのレベルで処理されるまで、一部のアクションを延期することを決定する場合があります。 これは、コードを遅延ゼロのsetTimeoutでラップすることで実行できます。

カスタムイベントのディスパッチの章では、例を見ました。カスタムイベントmenu-opensetTimeoutでディスパッチされるため、「click」イベントが完全に処理された後に発生します。

menu.onclick = function() {
  // ...

  // create a custom event with the clicked menu item data
  let customEvent = new CustomEvent("menu-open", {
    bubbles: true
  });

  // dispatch the custom event asynchronously
  setTimeout(() => menu.dispatchEvent(customEvent));
};

マクロタスクとマイクロタスク

この章で説明したマクロタスクに加えて、マイクロタスクの章で説明したマイクロタスクがあります。

マイクロタスクは、もっぱら私たちのコードから発生します。 通常、これらはpromiseによって作成されます。.then/catch/finallyハンドラの実行はマイクロタスクになります。 マイクロタスクは、promise処理の別の形式であるため、awaitの「内部」でも使用されます。

また、マイクロタスクキューで実行するためにfuncをキューに入れる特別な関数queueMicrotask(func)もあります。

すべてのマクロタスクの直後に、エンジンは他のマクロタスクやレンダリングなどを実行する前に、マイクロタスクキューからすべてのタスクを実行します。

たとえば、見てみましょう

setTimeout(() => alert("timeout"));

Promise.resolve()
  .then(() => alert("promise"));

alert("code");

ここでどのような順序になるでしょうか?

  1. codeが最初に表示されます。これは通常の同期呼び出しであるためです。
  2. promiseが2番目に表示されます。.thenはマイクロタスクキューを通過し、現在のコードの後に実行されるためです。
  3. timeoutは最後に表示されます。これはマクロタスクであるためです。

よりリッチなイベントループの図は次のようになります(順序は上から下、つまり、最初にスクリプト、次にマイクロタスク、レンダリングなどです)

すべてのマイクロタスクは、他のイベント処理やレンダリング、または他のマクロタスクが実行される前に完了します。

これは重要です。マイクロタスク間でアプリケーション環境が基本的に同じであること(マウス座標の変更なし、新しいネットワークデータなしなど)を保証するためです。

現在のコードの後で、しかし変更がレンダリングされたり新しいイベントが処理されたりする前に、関数を非同期的に実行したい場合は、queueMicrotaskでスケジュールできます。

前に示したものと同様の「カウントプログレスバー」の例を次に示しますが、setTimeoutの代わりにqueueMicrotaskが使用されています。 レンダリングが最後に行われることがわかります。同期コードとまったく同じです

<div id="progress"></div>

<script>
  let i = 0;

  function count() {

    // do a piece of the heavy job (*)
    do {
      i++;
      progress.innerHTML = i;
    } while (i % 1e3 != 0);

    if (i < 1e6) {
      queueMicrotask(count);
    }

  }

  count();
</script>

まとめ

より詳細なイベントループアルゴリズム(ただし、仕様と比較して簡略化されています)

  1. マクロタスクキュー(例:「script」)から最も古いタスクをデキューして実行します。
  2. すべてのマイクロタスクを実行します
    • マイクロタスクキューが空でない間
      • 最も古いマイクロタスクをデキューして実行します。
  3. 変更があればレンダリングします。
  4. マクロタスクキューが空の場合は、マクロタスクが表示されるまで待ちます。
  5. ステップ1に進みます。

新しいマクロタスクをスケジュールするには

  • 遅延ゼロのsetTimeout(f)を使用します。

大きな計算負荷の高いタスクをブラウザがユーザーイベントに反応し、その間の進捗を表示できるようにするために、小さな断片に分割するために使用できます。

また、イベントハンドラでイベントが完全に処理された(バブリングが完了した)後にアクションをスケジュールするためにも使用されます。

新しいマイクロタスクをスケジュールするには

  • queueMicrotask(f)を使用します。
  • また、Promiseハンドラはマイクロタスクキューを通過します。

マイクロタスク間にはUIやネットワークイベントの処理は行われません。マイクロタスクは次々にすぐに実行されます。

そのため、関数を非同期的に実行したいが、環境の状態は維持したい場合は、queueMicrotaskを使用することがあります。

Web Workers

イベントループをブロックすべきでない、長く重い計算には、Web Workersを使用できます。

これは、別の並列スレッドでコードを実行する方法です。

Web Workersはメインプロセスとメッセージを交換できますが、独自の変数と独自のイベントループを持ちます。

Web WorkersはDOMにアクセスできないため、主に計算用、複数のCPUコアを同時に使用する場合に役立ちます。

タスク

重要度: 5
console.log(1);

setTimeout(() => console.log(2));

Promise.resolve().then(() => console.log(3));

Promise.resolve().then(() => setTimeout(() => console.log(4)));

Promise.resolve().then(() => console.log(5));

setTimeout(() => console.log(6));

console.log(7);

コンソール出力は:1 7 3 5 2 6 4 です。

タスクは非常にシンプルで、マイクロタスクキューとマクロタスクキューの動作方法を知る必要があります。

何が起こっているのか、ステップバイステップで見てみましょう。

console.log(1);
// The first line executes immediately, it outputs `1`.
// Macrotask and microtask queues are empty, as of now.

setTimeout(() => console.log(2));
// `setTimeout` appends the callback to the macrotask queue.
// - macrotask queue content:
//   `console.log(2)`

Promise.resolve().then(() => console.log(3));
// The callback is appended to the microtask queue.
// - microtask queue content:
//   `console.log(3)`

Promise.resolve().then(() => setTimeout(() => console.log(4)));
// The callback with `setTimeout(...4)` is appended to microtasks
// - microtask queue content:
//   `console.log(3); setTimeout(...4)`

Promise.resolve().then(() => console.log(5));
// The callback is appended to the microtask queue
// - microtask queue content:
//   `console.log(3); setTimeout(...4); console.log(5)`

setTimeout(() => console.log(6));
// `setTimeout` appends the callback to macrotasks
// - macrotask queue content:
//   `console.log(2); console.log(6)`

console.log(7);
// Outputs 7 immediately.

要約すると、

  1. 単純なconsole.log呼び出しはキューを使用しないため、数字17はすぐに表示されます。
  2. 次に、メインのコードフローが終了した後、マイクロタスクキューが実行されます。
    • マイクロタスクキューには、console.log(3); setTimeout(...4); console.log(5)というコマンドがあります。
    • 数字35が表示され、setTimeout(() => console.log(4))console.log(4)呼び出しをマクロタスクキューの最後に追加します。
    • マクロタスクキューは now: console.log(2); console.log(6); console.log(4)となります。
  3. マイクロタスクキューが空になると、マクロタスクキューが実行されます。 264を出力します。

最終的に、出力は:1 7 3 5 2 6 4となります。

チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善のための提案がある場合は、コメントする代わりにGitHub issueを送信するか、プルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • 数語のコードを挿入するには、<code>タグを使用し、複数行の場合は<pre>タグで囲み、10行を超える場合はサンドボックス(plnkrjsbincodepen…)を使用してください。