JavaScript の2つのキュー Macrotasks, Microtasks
以前、実行待ちの非同期処理の行列としてタスクキューについて説明したが、実は JavaScript には2種類の非同期処理の待ち行列(キュー)が存在し、それらは Macrotasks と Microtasks と呼ばれている。
Macrotasks はタスクキュー(Task Queue)とも呼ばれ、次のものが Macrotasks を通過するタスク(処理)に該当する。
- <script>タグによる JavaScript コード
- setTimeout のコールバック関数
- setInterval のコールバック関数
- click, mousemove などの UI イベントのコールバック関数
- requestAnimationFrame のコールバック関数
- …etc
一方、Microtasks はジョブキュー(Job Queue)とも呼ばれ、次のものが Microtasks を通過するタスクに該当する。
- Promise のコールバック関数
- queueMicrotask のコールバック関数
- MutationObserver のコールバック関数
- …etc
したがって、JavaScript の非同期処理の流れを理解するための図にこれら2つのキューを描くと次のようになる。
イベントループによる処理の手順
タスクキュー(Macrotasks)とジョブキュー(Microtasks)にどのような違いがあるのかを理解するためには、非同期処理の実行の流れ、つまりイベントループによる処理の流れをこれら2つのキューを含めて理解する必要がある。
HTMLの仕様で定めるイベントループによる処理の手順を簡略化すると次のようになる。(参考ページ1、参考ページ2)
- タスクキュー(Macrotasks)に存在する先頭のタスクを1つだけ取り出して実行する。
- ジョブキュー(Microtasks)に存在する先頭のタスクを1つだけ取り出して実行する、という作業をジョブキューが空になるまで行う。
- レンダリングを行う。
- タスクキュー(Macrotasks)が空であれば、タスクがキューに追加されるまで待つ。
- 1.に戻って繰り返す。
このようにタスクキューから1つ取り出して実行すると、次の取り出しまでの間に必ずジョブキューの処理とレンダリングが挟まる。
一方でジョブキューは、その中の全てのタスクが実行されて空になるまで連続して取り出し・実行が行われる。
Microtask を含んだ非同期処理の実行の流れ
それでは具体的なサンプルを使って、上記で説明した非同期処理の流れを追ってみよう。
setTimeout(function task1() {
console.log('task1');
});
setTimeout(function task2() {
console.log('task2');
});
new Promise(function executor(resolve) {
console.log('promise');
queueMicrotask(function job1() {
console.log('job1');
resolve();
});
queueMicrotask(function job2() {
console.log('job2');
})
}).then(function job3() {
console.log('job3');
});
console.log('end');
// promise
// end
// job1
// job2
// job3
// task1
// task2
以下、タスクキュー(Macrotasks)のタスクを task、ジョブキュー(Microtasks)のタスクを job と表現する。
まずは、このスクリプト(コード)が1つの task として扱われ、コールスタックに積まれ実行される。この task の中では次のことが行われれる。
- setTimeout 関数により、API に task1 が登録される。第二引数が省略されているため値0が使用され、すぐにタスクキューに追加される。(実際には0が設定されても 4ms 以上遅れてタスクキューに追加される。参考:MDN)
- setTimeout 関数により、API に task2 が登録される。第二引数が省略されているため値0が使用され、すぐにタスクキューに追加される。
- new Promise(); の実行により関数 executor が実行され、コンソールに ‘promise’ を表示する。
- 関数 executor の処理内で queueMicrotask 関数により、job1 がジョブキューに追加される。
- 関数 executor の処理内で queueMicrotask 関数により、job2 がジョブキューに追加される。
- 関数 executor の処理が終了し、new Promise(); によるインスタンスが生成される。そして生成されたインスタンスから then メソッドが呼ばれ、API に job3 が登録され、resolve / reject されるのを待つ。
- 最後にコンソールに ‘end’ を表示する。
したがってこの段階でのタスクの状態は次のようになっている。
スクリプト(グローバルコンテキスト)の実行を終え、コールスタックが空になったので、イベントループは次にジョブキューの先頭から1つ job (job1)を取り出し、コールスタックに積んで実行する。
job1 では次のことが行われる。
- コンソールに ‘job1’ と表示する。
- resolve 関数を呼び出し、Promise インスタンスを解決する。これにより、then メソッドで登録していた job3 がジョブキューに追加される。
したがって job1 が終了した直後のタスクの状態は次のようになっている。
そしてジョブキューが空になるまで job が1つずつ取り出されて実行される。つまり job2 が取り出されて実行され、その後に job3 が取り出されて実行される。そのため、コンソールに ‘job2’ が表示され、その後に ‘job3’ が表示される。
したがって job3 が終了した直後のタスクの状態は次のようになっている。
ジョブキューが空になったので、本来ならレンダリングが行われて画面の表示に変更が反映されるのだが、今回のサンプルでは特に画面を変更する処理はないため、このステップはスキップされる。
タスクキューにはまだ task が残っているので、先頭の task1 が取り出されて実行される。そのためコンソールに ‘task1’ が表示される。
その後、ジョブキューには何もないのでスキップされ、レンダリングも必要ないのでスキップされ、再びタスクキューから先頭の task2 が取り出されて実行される。そのためコンソールに ‘task2’ が表示される。
以上がこのサンプルにおける非同期処理の流れとなっている。
queueMicrotask 関数は Promise.resolve().then() と同等
queueMicrotask 関数は、引数に渡したコールバック関数をジョブキュー(Microtasks)に追加する関数だが、解決(resolve)された Promise インスタンスから呼ばれる then メソッドにコールバック関数を渡しても、すでに解決されているので即時にジョブキュー(Microtasks)に追加されることになる。
そのため解決された Promise インスタンスを生成できる Promise の静的メソッド Promise.resolve() を使えば、Promise.resolve().then() は実質的に queueMicrotask 関数と同等の働きをすることになる。
queueMicrotask(function() {
console.log('queueMicrotask1');
});
Promise.resolve().then(function() {
console.log('Promise.resolve().then')
});
queueMicrotask(function() {
console.log('queueMicrotask2');
});
console.log('end');
// end
// queueMicrotask1
// Promise.resolve().then
// queueMicrotask2