Chapter 06

タスクキューとマイクロタスクキュー

PADAone🐕
PADAone🐕
2022.12.13に更新

このチャプターについて

このチャプターでは、タスク(Task)とマイクロタスク(Microtask)、そしてそれらをイベントループで処理するために必要なタスクキュー(Task queue)とマイクロタスクキュー(Microtask queue)について解説してきます。

Masaki Hara さんの記事で解説されている図も参考にすると頭が整理されると思いますので、参考にしてください。

https://zenn.dev/qnighy/articles/345aa9cae02d9d

このチャプターではなるべく仕様に沿った情報で考えていきます。実際の仕様については次の URL から確認してください。

https://html.spec.whatwg.org/multipage/webappapis.html#definitions-3

イベントループとはそもそも何?

『What the heck is the event loop anyway?』の動画で、イベントループの概略自体はつかめていると思いますが、その定義から考えていきます。

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop, which is unique to that agent.
(HTML Standard より引用)

イベントループとは、イベントやユーザーインタラクション、スクリプト、レンダリング、ネットワーキングなどをまとめ上げて調整するために、ユーザーエージェントが使用しなくてはならないものであると述べられています。

イベントループは以下で解説するタスクキューとマイクロタスクキューを所有しています。

タスクキュー

WHATWG の仕様において、イベントループは1つ以上のタスクキュー(Task queue)を所有していると述べられています。つまり、タスクキューは1つではなく、複数個存在してもよいということが分かります。

An event loop has one or more task queues.
(HTML Standard より引用)

タスクキュー(Task queue)とは、文字通りタスクのキューであるとここでは考えてください。ただし、後述しますが、仕様上のタスクキューは厳密にはキュー(Queue)ではなくセット(Set)というデータ型です。

ここで重要な話として、イベントループは複数のタスクキューから実行可能なタスクが少なくとも一つ以上あるようなタスクキューを一つ選択した上で、そのキュー内に存在する最初の実行可能なタスクを処理しなくてはいけませんが、複数あるタスクキューからどのタスクキューを選ぶかというのは 実装側(つまり環境側)が定義する方法 で考慮します。

これについては、WHATAG 仕様の Processing model の項目にて述べられています。

  1. If the event loop has a task queue with at least one runnable task, then:
    1. Let taskQueue be one such task queue, chosen in an implementation-defined manner.

(HTML Standard より引用)

この Processing model がイベントループが実際に行うことです。この本ではすべての詳細を触れずに擬似コードで解説するので、ブラウザ環境のイベントループの詳細を詳しく知りたい場合にはこの Processing model を参照してください。

タスクキューとは Set である

タスクキューは名前上はタスクのキュー(Queue)となっていますが、実際にはタスクの Set である、ということが仕様では述べられています。

task queue is a set of tasks.
(HTML Standard より引用)

ここで言う Queue や Set とは特定のデータ構造(data structure)のことです(以前の解説では、この点についてただの集合であると誤解していました)。データ構造については WHATAG 仕様に定義されています。具体的には HTML Standard ではなく、それらの仕様が基づく用語や概念を定義している Infra Standard というページに記載されています。

Queue や Set は Infra Standard の 5. Data structures の項目に記載されています。このページを見ると、Queue や Set とは List という仕様におけるデータ型の一つであることがわかります。List には他にも Stack というデータ構造が定義されています。

  • List
    • Stack:
    • Queue: ← マイクロタスクキュー
    • Set: ← タスクキュー

ここで、List とは以下のようなものであると定義されていて、有限個の要素からなる順序付きの列からなる仕様の型であることが述べられています。日本語訳版だと「有限個の アイテム ( item )からなる有順序連列」となっています。

A list is a specification type consisting of a finite ordered sequence of items.
(Infra Standard より引用)

List は上で述べたように Stack や Queue や Set である可能性があります。言い換えれば、これらのデータ構造は List の派生となります。

問題となるタスクキューは Queue ではなく、Set です。この Set は List でもあるので、順序が付いた列です。定義的には、順序集合(ordered set)と呼ばれるものであり、同一のアイテムを重複してもたない List です。

Some lists are designated as ordered sets. An ordered set is a list with the additional semantic that it must not contain the same item twice.
(Infra Standard より引用)

仕様ではタスクキューがその名前とは裏腹に Queue ではなく Set である理由が述べられています。

Task queues are sets, not queues, because the event loop processing model grabs the first runnable task from the chosen queue, instead of dequeuing the first task.
(HTML Standard より引用)

イベントループの Processing model では、上で述べたようにまずは複数個ありえるタスクキューの中から環境定義の方法で一つのタスクキューを選択しますが、そのタスクキューの中から最初のタスクを選択(dequeuing)するのではなく、実行可能(runnable)という状態である最初のタスクを選択して処理します。

Set は List でもあるので順序がありますが、その中で実行可能(runnable)かつ順序的に最初のタスクが処理されるということです。ということで、ほとんど Queue と同じような処理となりますが、実行可能ではないものはとばされてしまうということになります。

ちなみに後で解説するマイクロタスクキュー(Microtask queue)は厳密に Queue なので注意してください。

タスク

タスク(Task)は、タスクキュー(Task queue)にプッシュされるものですが、タスクそれ自体がなにかを仕様から見ていきます。

まず、タスクの実態は形式的に以下のものを所有する構造体(struct: Infra Standard で定義された仕様型の1つ)であるとも仕様で定義されています。

  • ステップ(Steps)
    • 当該のタスクによって処理される仕事を指定する一連のステップ
  • ソース(A source)
    • 当該のタスクに関連するタスクをグループ化してシリアライズするために利用されるタスクソースの1つ
  • ドキュメント(A document)
    • 当該のタスクに関連付けられた Document(DOM が定義している Document インターフェイス)、あるいは Window event loop にないタスクの場合の null
  • スクリプト評価の環境設定オブジェクトの Set (A script evaluation environment settings object set)

Tasks encapsulate algorithms that are responsible for such work as:
(HTML Standard より引用)

実態は上記のような構造体(struct)ですが、タスク(Task)は以下のような作業の責務を持つアルゴリズムをカプセル化します。

  • イベント(Events)
  • パース(Parsing)
  • コールバック(Callbacks)
  • リソースの使用(Using a resource)
  • DOM 操作への反応(Reacting to DOM manipulation)

ここでは、上記のような操作(あるいはアルゴリズム)そのものがタスクであると考ればよいです。わかりやすく例をあげると、後述する setTimeout() API に登録されたコールバック関数はタスクの1つであり、クリックイベントなどもタスクの1つです。注意点として、スクリプトのパースなどもタスクの一種です。これは『最初のタスク』の項目で説明します。

タスクソース

タスクの説明にあったように、タスクには供給されるタスクソース(Task source)というものがあり、似た種類のタスクは同一のタスクキューへとプッシュされます。

Essentially, task sources are used within standards to separate logically-different types of tasks, which a user agent might wish to distinguish between. Task queues are used by user agents to coalesce task sources within a given event loop.
(HTML Standard より引用)

タスクキューで見てきた通り、タスクキューは1つ以上存在できるので、もちろんタスクキューにタスクを供給してくる供給源であるタスクソースも複数あります。上記に上げたタスクはそれぞれ異なるタスクソースで、異なるタスクキューにタスクを供給すると考えてよいでしょう。

タスクキューで重要なルールは以下のものとなります。

  • (1) 複数あるタスクキューはどの順番に処理されるか決められていない。
  • (2) 同一のタスクキュー内に存在しているタスクは到着した順番に処理される
  • (3) 同一の供給源から来たタスクは同じタスクキューへと送られる

(1) については、環境が独自のルールで決めます。Chrome ブラウザ環境の場合は、レンダリングエンジン Blink がマウスクリックなどから発火するイベントを優先的に処理したり、それぞれのタスクキューのスケジューリングをしています。Node の場合はフェーズで順繰りに処理されます。
(2) については、キューなので FIFO(First In First Out)であるということです。
(3) については、タイマー系の API からくるコールバックはすべて同一のタスクキューへ、クリックイベントなどからくるコールバックは別のキューへ行くようになっています。ただし、Node の API である setImmediate() のコールバックは Check phase の専用のキューへと行きます。

タスク(Task)を発行する非同期 API はいくつか存在しますが、代表的なものとしてはタイマー処理を行う次の API が挙げられます。

  • setTimeout() API
  • setInterval() API

setTimeout API

タスクベースの非同期 API である、setTimeout() は、setTimeout(cb, delay) というように指定した遅延時間が経過した後に、引数のコールバック関数をタスクとしてタスクキューに発行します。

// タスクベースの非同期 API
const timerId = setTimeout(() => {
  console.log("⏰ TIMRES: task [Functional Execution Context]");
}, 1000);
// 1000 ミリ秒後にタイマー用タスクキューにタスクを発行する

戻り値は、setTimeout() で作成したタイマーを識別するための ID で 0 でない正の整数値です。clearTimeout(timerId) を使ってタイマーを解除できます。

https://developer.mozilla.org/ja/docs/Web/API/setTimeout

setTimeout() API の遅延時間を 0 秒にすることでタスクキューへタスクを簡単に発行できますが、あくまでタイマー処理であり、0ミリ秒遅延は実際0ミリ秒にはならず1ミリ秒以上の時間がかかることに注意してください。また、タスクキューに他のタスクがある場合には先に存在していたタスクが処理されるので、その場合もタスク処理が指定時間よりも遅く処理されることになります。スケジュール通りの正確な実行が保証されないことは仕様に記載されています。

This API does not guarantee that timers will run exactly on schedule. Delays due to CPU load, other tasks, etc, are to be expected.
(HTML Standard より引用)

Denoでの型定義
function setTimeout(
  cb: (...args: any[]) => void,
  delay?: number,
  ...args: any[],
): number;

https://doc.deno.land/deno/stable/~/setTimeout

setInvertal API

タスクベースの非同期 API である、setInterval() は、setInverval(cb, interval) というように指定したインターバル時間が経過するたびに、引数のコールバック関数をタスクとしてタスクキューに発行します。

// タスクベースの非同期 API
const inervalId = setInterval(() => {
  console.log("⏰ TIMRES: task [Functional Execution Context]");
}, 1000);
// 1000 ミリ秒経過するごとにタイマー用タスクキューにタスクを発行する

戻り値は setInterval() で作成したタイマーを識別するユニークな ID で、0 でない正の整数値です。clearInterval(intervalId) を使って以後のインターバルをキャンセルできます。

https://developer.mozilla.org/en-US/docs/Web/API/setInterval

Denoでの型定義
function setInterval(
  cb: (...args: any[]) => void,
  delay?: number,
  ...args: any[],
): number;

https://doc.deno.land/deno/stable/~/setInterval

イベントループの所有物

続いて仕様にはイベントループが所有するものについてもいくつか定義されています。理解する上で重要な部分について触れていきます。

Each event loop has a currently running task, which is either a task or null. Initially, this is null. It is used to handle reentrancy.
(HTML Standard より引用)

Each event loop has a microtask queue, which is a queue of microtasks, initially empty. A microtask is a colloquial way of referring to a task that was created via the queue a microtask algorithm.
(HTML Standard より引用)

イベントループは Currnetly running task (現在実行中のタスク) を持ち、さらに単一のマイクロタスクキュー(Microtask queue)を持つというように定義されていますね。

仕様の『spin the event loop』の項目では、次のように記載されています。

  1. Let task be the event loop's currently running task.
    (HTML Standardより引用)

task could be a microtask.
(HTML Standardより引用)

イベントループの開始時にタスクをイベントループの Currently running task として扱いますが、この場合マイクロタスクであってもよいと書かれているので、現在実行中のタスクとマイクロタスクも Currnetly running task として考慮できます。従って、本質的にはタスクとマイクロタスクも同じ扱いで、コールスタック上にプッシュされた実行コンテキストであると理解できます。

マイクロタスクキュー

次に、マイクロタスクキューについて深堀りしましょう。まずマイクロタスクキュー(Microtask queue)はタスクキューではありません。

The microtask queue is not a task queue.
(HTML Standardより引用)

上で見たとおり、イベントループはマイクロタスクキューを1つ持つといっていますね。

マイクロタスクキューはタスクキューよりも優先的に処理されます。単一タスクが終わったら、すべてのマイクロタスクを処理するというのはそういうことです。node の nextTickQueue にあるのもマイクロタスクですが、単一タスクが終わったら必ずマイクロタスクキューにあるすべてのマイクロタスクが処理されます。マイクロタスクキューで重要なことはこれだけです。

マイクロタスク

マイクロタスクキューに送られるマイクロタスクについて考えてみます。WHATWG 仕様では以下のように述べられています。

A microtask is a colloquial way of referring to a task that was created via the queue a microtask algorithm.
(HTML Standard より引用、太字は筆者強調)

queue a microtask」と呼ばれるアルゴリズムによって作成されるタスクのことを指す俗称ということです。仕様的にはマイクロソフトタスクはタスクの一種ということですね。

仕様だとかなり分かりづらいので、MDN のドキュメントを見ると、こう書かれています。

A microtask is a short function which is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the script's execution environment.
(Using microtasks in JavaScript with queueMicrotask() - Web APIs | MDNより引用)

マイクロタスク自体は、それを呼び出し関数やプログラムが実行された後にコールスタックが空になった後にのみ実行される短い関数です。API や Promise の then() メソッドなどの引数に渡すコールバック関数がマイクロタスクとして扱われます。

Promise.resolve().then(callback1).catch(callback2).finally(callback3) のように、Promise のプロトタイプメソッドである、then(), catch(), finally() の引数に渡すコールバック関数はすべてマイクロタスクとしてマイクロタスクキューへと送られます。

基本的にはマイクロタスクを作成するのは、Promise 関連の処理(then(cb) や async/await など)ですが、他にもマイクロタスクをマイクロタスクキューに追加する API が存在しています。

  • queueMicrotask() API
  • MutationObserver() API

queueMicrotask API

queueMicrotask(cb) は引数のコールバック関数をマイクロタスクとしてマイクロタスクキューに発行しますが、戻り値は何もないことに注意してください。つまり、Promise.then() のように、Promise インスタンスを返しません。イベントループにおけるマイクロタスクの挙動などをテストしたり、その他のコールバックが処理される前のクリーンアップなどに役に立ちます。

// Web API
queueMicrotask(() => {
  console.log("👦 MICRO: microtask [Functional Execution Context]");
}); // 戻り値なしなので Promise chain はできない
// ただちにマイクロタスクキューにマイクロタスクを発行する

https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask

単にマイクロタスクを作成したいだけなら、Promise.resolve().then() よりも queueMicortask() を使用することが推奨されます。queueMicrotask() を使用することで、マイクロタスクを作成するためにプロミスを使うことで発生するオーバーヘッドなどを回避できます。

https://developer.mozilla.org/ja/docs/Web/API/HTML_DOM_API/Microtask_guide#enqueueing_microtasks

Promise インスタンスが返ってこないので chain できない点以外については、基本的に Promise.resolve().then() と同じように考えることができます。

// qmt.js
Promise.resolve().then(() => console.log("[1] 🍎"));
queueMicrotask(() => console.log("[2] 🫐"));
Promise.resolve().then(() => console.log("[3] 🍎"));
queueMicrotask(() => console.log("[4] 🫐"));

/* 実行結果
❯ deno run qmt.js
[1] 🍎
[2] 🫐
[3] 🍎
[4] 🫐
*/

queueMicrotask() はブラウザ環境で提供される Web API ですが、Node でも Deno でも同じ名前で使用できます。

https://nodejs.org/dist/v18.2.0/docs/api/globals.html#queuemicrotaskcallback

上で説明したとおり、Node 環境では process.nextTick() API よりも queueMicrotask() API の使用が推奨されます。

Denoでの型定義
function queueMicrotask(func: VoidFunction): void;

https://doc.deno.land/deno/stable/~/queueMicrotask

MutationObserver API

MutationOberver() コンストラクタ関数は MutationObserver API という大きなインタフェースの一部として提供されています。DOM 内の要素を監視して、何かの変更があった際にコールバックをマイクロタスクとして発火する Web API です。この API が発行するマイクロタスクは Promise とは関係なく、MutationObserver() 自体からも Promise ではなくオブザーバインスタンスが返ってくるので注意してください。

マイクロタスクキューは基本的に Promise 処理のための機構ですが、この Web API はその機構を利用します。

// 監視対象とするノードを取得
const targetNode = document.getElementById('target');

// オブザーバーインスタンスを作成
const observer = new MutationObserver((mutationRecords) => {
  console.log("Detect mutation", mutationRecords);
});

// オブザーバーインスタンスに対象ノードとオブザーバーの設定をアタッチする
observer.observe(targetNode, ({
  childList: true,
  subtree: true,
  characterDataOldValue: true
}));

https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/MutationObserver

https://ja.javascript.info/mutation-observer

最初のタスク

タスクについて説明しましたが、仕様だけでは理解しづらい部分がやはり多々あるので MDN のドキュメントも見てみましょう。特に最初のタスクが何になるかは誤解しやすいので非同期処理の予測で重要です。

https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide/In_depth

A task is any JavaScript scheduled to be run by the standard mechanisms such as initially starting to execute a program, an event triggering a callback, and so forth. Other than by using events, you can enqueue a task by using setTimeout() or setInterval().
(上記ページ より引用、太字は筆者強調)

タスク(Task)とは、プログラムの実行開始イベントがコールバックをトリガーするなどの標準的なメカニズムにより実行されるようにスケジューリングされた JavaScript のことである、と述べられています。

https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide#tasks_vs_microtasks

具体的にタスクがタスクキューに追加されるのは以下の時です。

  • 新しい JavaScript のプログラムやサブプログラムが直接的に実行される時(コンソールからや、<script> 要素内のコードを実行するなどの形式で)
  • イベントが発火し、イベントのコールバック関数がタスクキューへと追加する時
  • setTimeout()setInterva() で作成されたタイムアウトやインターバルの時間が経過し、登録しておいたコールバックがタスクキューへと追加される時

重要なこととして、イベントループにおいて最初のタスクはプログラムの実行開始そのものであり、コンソールから実行するときや、スクリプトタグのコードを実行する際に同期処理の部分はまとめてタスクとして実行されます。

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

<script>
  // <-- task 1
  console.log("Sync process start: [Global Execution Context]");

  const foo = bar;
  foo.doSomething();

  document.body.addEventLlistener('keydown', (event) => {
    // <-- task2
    if (event.key === 'PageDown') {
      location.href = "/#/36";
    }
    console.log("Event fired (Async process): [Functional Execution Context]");
    // task2 -->
  });

  console.log("Sync process End: [Global Execution Context]");
  // task1 -->
</script>

チャプター『コールスタックと実行コンテキスト』で説明したようにコードが実行開始されると、グローバルコンテキスト(Global Execution Context)が作成されて、コールスタック(Call stack)上にそのグローバルコンテキストが積まれます。そして、同期処理の関数呼び出しなどはすべてこのグローバルコンテキストに積まれることで実行されていきます。同期処理部分がすべて実行されて、このグローバルコンテキストが破棄された後で、ある時間にキーダウンイベントなどをブラウザが受け取ると、Web API がそのイベントを受け取って addEventListener() で登録しておいたコールバックを別のタスクとしてイベント用のタスクキューへと送ります。

イベントループはそのタスクが存在していることを確認し、元々スクリプトタグが評価された時点からイベントが発火されるまで時間がたっていたとしても、そのコールバック関数により作成される関数実行コンテキストをコールスタック上に配置して、Ruuning Execution Context として実行します(その際、タスクは Currently running task として扱われています)。

このように、<script> タグの読み込みから、スクリプト内の同期処理をすべて処理するまで最初のタスクとして考えることができます(もちろん内部で色々なプロセスを行っているのでしょうが)。

<script>
  // <- Task1
  // ...
  // Task 1 ->
</script>

従って、次のように複数の <script> タグがあった場合も、それぞれのスクリプトの評価がタスクとして扱われます。これが理解できていると、次のように2つスクリプトタグがあったときの実行順番が予測できます。

<script>
  // <- Task 1
  console.log("🦖 [1] MAINLINE: script1 start [Global Execution Context]");
  setTimeout(()=>{
    // <- Task 3
    console.log("⏰ [9] TIMERS: settimeout [Functional Execution Context]");
    // Task 3 ->
  },1000)
  new Promise((resolve)=>{
    console.log("😅 [2] Sync callback script1:promise1 [Functional Execution Context]");
    resolve()
  }).then(()=>{
    // <- Microtask 1
    console.log("👦 [4] MICRO: async callback script1:then1 [Functional Execution Context]");
    // Microtask 1 ->
  })
  console.log("🦖 [3] MAINLINE: script1 end [Global Execution Context]");
  // Task 1 ->
</script>
<script>
  // <- Task 2
  console.log("🦖 [5] MAINLINE: script2:start [Global Execution Context]");
  new Promise((resolve)=>{
    console.log("😅 [6] MAINLINE: (Sync callback) script2:promise2 [Functional Execution Context]");
    resolve()
  }).then(()=>{
    // <- Microtask 2
    console.log("👦 [8] MICRO: (async callback) script2:then1 [Functional Execution Context]");
    // Microtask 2 ->
  })
  console.log("🦖 [7] MAINLINE: script2:end [Global Execution Context]");
  // Task 2 ->
</script>

コンソールでこのように出力されます。

output_doubleScriptTag

結局のところ、JavaScript コードはすべてタスクかマイクロタスクになります(仕様上のマイクロタスクはタスクの一種なので注意)。タスクとして実行される JavaScript コードは、このように script であるか、非同期のコールバック関数のどちらかになります。

ランタイム環境でも同じです。Node と Deno も Chrome と同じ V8 エンジンを積んでおり、コールスタックは V8 エンジンが管理しているのでまったく同じ様に考えることができます。プログラムの開始時のスクリプト評価で同期処理はすべてまとめてタスクとしてカウントして、グローバルコンテキストを作成し、コールスタック上に配置されます。同期処理がすべて終わるとグローバルコンテキストはコールスタックからポップします。そして単一タスクの後はすべてのマイクロタスクを処理するので、同期処理が終わり次第マイクロタスクは処理されます。別の言い方で言えば、コールスタックが空になったらマイクロタスクを実行します。

これが理解できることで、次のスクリプトの実行順番も理解できます。

// blockingTimer.js
// <-- Task 1

// メインスレッドを同期的にブロッキングする関数
function pause(milliseconds, order) {
  const dt = new Date();
  while ((new Date()) - dt <= milliseconds) {
    // 何もしない
  }
  console.log(`🦖 ${order} Sync process: timer exit after ${milliseconds}`);
}

console.log("🦖 [1] MAINLINE: Sync process start");

setTimeout(() => {
  // Task 2
  console.log("⏰ [5] TIMERS: setTimeout[0ms] finished");
}, 0);
setTimeout(() => {
  // Task 3
  console.log("⏰ [6] TIMERS: setTimeout[1000ms] finished");
}, 1000);
queueMicrotask(() => {
  // Microtask
  console.log("👦 [4] MICRO: queueMicrotask");
});

pause(3000, "[2]"); // 3000[ms] メインスレッドをブロッキング

console.log("🦖 [3] Sync process end");
// Task 1 -->

プログラム実行開始のスクリプトの評価が最初のタスク(Task)となり、同期処理がすべて終われば、単一タスクが実行されたことになるので、次はマイクロタスクをすべて処理するのがイベントループです。従って、Deno でも Node でも実行結果は同じになります。

# Deno 環境
❯ deno run blockingTimer.js
🦖 [1] MAINLINE: Sync process start
🦖 [2] Sync process: timer exit after 3000
🦖 [3] Sync process end
👦 [4] MICRO: queueMicrotask
⏰ [5] TIMERS: setTimeout[0ms] finished
⏰ [6] TIMERS: setTimeout[1000ms] finished
# Node 環境node blockingTimer.js
🦖 [1] MAINLINE: Sync process start
🦖 [2] Sync process: timer exit after 3000
🦖 [3] Sync process end
👦 [4] MICRO: queueMicrotask
⏰ [5] TIMERS: setTimeout[0ms] finished
⏰ [6] TIMERS: setTimeout[1000ms] finished

非同期処理の本質

この本の結論をもう言ってしまいますが、非同期処理の本質的な仕組みは「イベントループにおけるタスクとマイクロタスクの処理」です。

非同期処理の起点は「非同期 API」による並列的作業です。環境がバックグラウンドで代行している非同期 API の処理(fetch() メソッドによるリソース取得など)の間、メインスレッドでは別の処理を行うことができます。そして、その並列的作業の処理が終わり次第、メインスレッドへと通知させて、起点となった非同期 API の処理結果を使って別のことをやります(取得したリソースのデータを加工するなど)。非同期 API を起点とした一連の作業は「特定の順番に行うこと」、つまり「A したら B する、B したら C する」というような「逐次処理」が肝になります。

その順番をうまく設定するのが開発者であり、コード内で特定の順番となるように処理のスケジューリングを行います。そして、API を起点とした一連の逐次処理のための書き方がコールバック関数や Promise chain、async/await となります。その書き方から実際に実行するためにあれやこれやをやる仕組みがイベントループであり、タスクキュー・マイクロタスクキューです。その処理の単位がタスクやマイクロタスクです。

ということで「A したら B する、B したら C する」というような逐次処理について、他の同期処理とは別のタイミングで(つまり非同期的に)それぞれ順番に実行したいなら、タスクやマイクロタスクを連鎖(chain)させます。それぞれの処理が連鎖的に起きることで、逐次処理となります

コールバックベースの API の利用に伴うネストされたコールバック(Callback hell)についてはいわば「タスク連鎖(Task chain)」といえるでしょう。イベントループ上でタスクの連鎖的処理を行うことで非同期的に逐次処理を行うことができます。

Promise chain や async/await、Promise-based API などについては Promise インスタンスを連鎖させることで非同期的に逐次処理を行えます。いわば「マイクロタスク連鎖(Microtask chain)」といえるでしょう。イベントループ上でマイクロタスクの連鎖的処理を行うことで非同期的に逐次処理を行うことができます。これを分かりやすくイメージできるのがまさに Promise chain であり、promiseBasedApi().then(cb1).then(cb2).then(cb3) というように「Promise-based API の並列的処理を起点として、それが完了したら次に cb1 を行い、それが完了したら次に cb2 を行い、それが完了したら次に cb3 を行う」といった感じで、コールバック関数の処理を then() メソッドで連鎖させて繋ぐことで逐次処理ができます。

"タスク連鎖" や "マイクロタスク連鎖" は筆者が作成したただの造語ですが(本質的な部分を捉えるために考えた言葉です)、このようにイメージができるとイベントループのメンタルモデルが盤石になり、頭の中で処理順番がどうなるか想起できるようになると思います。

ここで語った内容も含めて「非同期処理」の全体については『総括 - 非同期処理のまとめ』のチャプターで再度まとめておきますので安心してください。