はじめに
最も簡単な例から始めましょう:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
ログの出力順序は以下の通りです:
script start
script end
promise1
promise2
setTimeout
なぜでしょうか?
macrotask
とりあえず「マクロタスク」と呼びます。多くの文脈では単に「タスク(task)」とも呼ばれます。例えば:
-
setTimeout -
setInterval -
setImmediate -
requestAnimationFrame -
I/O
-
UI rendering
最も一般的な遅延実行や定期実行、Node環境の即時実行、高頻度のRAF、そしてI/O操作やUIの変更。これらはすべてマクロタスクです。イベントループの主な仕事は、マクロタスクキューを1周ずつチェックし、これらのタスクを処理することです。
例えば:
setImmediate(() => {
console.log('#1');
});
setImmediate(() => {
console.log('#2');
});
setImmediate(() => {
console.log('#3');
setImmediate(() => {
console.log('#4');
});
});
次に immediate マクロタスクキューをチェックする際、外側の3つのコールバック関数が順番に実行されます。内側のものはその次の回に実行されます。したがって、マクロタスクのルールは「次のバスを待つ」ことです(次のイベントループ、または現在のイベントループのまだ発生していない特定のフェーズを待つ)。
microtask
マイクロタスク。 「ジョブ(job)」とも呼ばれます。例えば:
-
process.nextTick -
Promise callback
-
Object.observe -
MutationObserver
nextTick や Promise はよく見かけますが、Object.observe は廃止されたAPI(ネイティブのオブザーバー実装)です。MutationObserver は古くからあり、DOMの変更を監視するために使われます。
通常、これらのコールバック関数はある条件下でマイクロタスクキューに追加され、現在のマクロタスクキューのフラッシュ(実行)が終了した後にそのキューをチェックし、すべてフラッシュされます(キュー内のすべてのマイクロタスクを処理します)。
P.S. 「通常でないケース」とは、一部のブラウザバージョンにおける Promise コールバックが必ずしもマイクロタスクキューを通るとは限らないことを指します。これは Promises/A+ 仕様で明確に要求されていないためです(どちらでも良いとされています)。
例えば:
setImmediate(() => {
console.log('immediate');
});
Promise.resolve(1).then(x => {
console.log(x);
return x + 1;
}).then(x => {
console.log(x);
return x + 1;
}).then(x => console.log(x));
次にマイクロタスクキューをチェックしたとき、Promise コールバックが1つしかないことに気づき、即座に実行します。再チェックするとまた1つ現れたので実行を続け、おや、チェックするとさらに1つ湧いてきたのでまた実行し、再チェックして、なくなったので、イベントループを継続して immediate マクロタスクキューをチェックし、ようやく setImmediate のコールバックを実行します。したがって、マイクロタスクのルールは「現在の車の最後尾にぶら下がる」ことであり、さらに「その場で作ってその場で売る」ことが許可されています(現在のマクロタスクキューのフラッシュが終わった直後に実行され、次のバスを待つ必要はありません。また、マイクロタスクキューのフラッシュ中に生成された同タイプのマイクロタスクも即座に処理されます。つまり、ブロックが許可されています)。
Event Loop
JavaScript本来の非同期特性は、イベントループによって実現されています。例えば:
const afterOneSecond = console.log.bind(console, '1s later');
setTimeout(afterOneSecond, 1000);
具体的な実行プロセスは概ね以下の通りです:
-
JSスレッドが起動し、イベントループを作成する
-
script がコールスタックに追加される
-
1行目を実行し、Function を作成する
-
2行目を実行し、(Event Table により)
1000ms 後にafterOneSecondコールバックを処理することを記録する -
script がスタックから外れ、コールスタックが空になる
-
イベントループが空回りする(マクロタスクキューが空で、することがない)
-
1秒強後、タイマーが期限切れになり、
afterOneSecondコールバックがマクロタスクキューに挿入される -
次のイベントループのチェックでマクロタスクキューが空でないことが判明し、先入れ先出し(FIFO)で
afterOneSecondコールバックを取り出してコールスタックに追加する -
afterOneSecondを実行し、ログに1s laterを出力する -
afterOneSecondがスタックから外れ、コールスタックが再び空になる -
これ以上何も起こらないため、イベントループが終了する
ここから少し面白くなってきます。例えば、イベントループが終了するタイミングについて、よくある誤解があります:
JSコードの実行はすべてイベントループの中で行われる
これは曖昧な表現です。実際には、コールスタックが空になったとき初めてイベントループが存在感を示し(タスクキューをチェックし)、これ以上何も起こらないことが確認されるとイベントループは終了します。例えば:
// 上記の例を ./setTimeout.js ファイルに書き込む
$ node ./setTimeout.js
1s later
./setTimeout.js を実行するための Node プロセスは約1秒間存続し、イベントループの終了とともに正常に exit しました。しかし、特定のポートでリクエストを待ち受け続けるサーバープログラムなどは、イベントループが終わらないため、Node プロセスも存在し続けます。
P.S. 各JSスレッドには独自のイベントループがあるため、Web Worker も独立したイベントループを持っています。
P.S. Event Table は、イベントループと組み合わせて使用されるデータ構造で、コールバックのトリガー条件とコールバック関数の対応関係を記録するために使われます:
setTimeout関数を呼び出したり、非同期操作を行ったりするたびに、それらは Event Table に追加されます。これは、特定のイベントが発生した後に特定の関数がトリガーされるべきであることを知っているデータ構造です。
役割
では、イベントループが存在する意義は何でしょうか? これがないとダメなのでしょうか?
それは非同期特性をサポートするためです。考えてみてください。JavaScriptがブラウザ環境で長年使われてきた中で、UIのインタラクションもネットワークリクエストも比較的低速なものです。しかし JavaScript はメインスレッドで動作し、レンダリングをブロックします。もしこれらの「スローモーション」がすべて同期的なブロッキングであったなら、体験はかなり悪くなるでしょう。例えば:
document.body.addEventListener('click', () => alert(+new Date));
const xhr = new XMLHttpRequest();
// Sync xhr
xhr.open('GET', 'http://www.ayqy.net', false);
xhr.send(null);
console.log(xhr.responseText);
send() を実行している約3秒間、ページは完全に無反応になります。この間にクリックして発生した alert ボックスはマクロタスクキューに挿入され、リクエストのレスポンスが返ってくるまで、これらのボックスは順番にポップアップすることはありません。
もしイベントループがなければ、この3秒間は完全にインタラクションができず、 alert ボックスも将来のどこかの時点でポップアップすることはないでしょう。したがって、イベントループは、スローモーションがレンダリングをブロックする問題に対処するために非同期特性をもたらしたのです。
P.S. 実際、DOMイベントのコールバックはすべてマクロタスクであり、同様にイベントループに依存しています。
Call Stack
JavaScriptのシングルスレッド環境は、ある瞬間に一つのことしかできないことを意味します。そのため、(一つのJSスレッド下では)コールスタックは一つしかありません。例えば:
function mult(a, b) { return a * b; }
function double(a) { return mult(a, 2); }
+ function main() {
return double(12);
}();
実行中のコールスタックの変化は以下の通りです:
// push script
// push main
// push double
// push mult
// pop mult
// pop double
// pop main
// pop script
注意点として、コールスタックが空のときにのみ、イベントループが動作する機会を得られます。 例えば:
function onClick() {
console.log('click');
setTimeout(console.log.bind(console, 'timeout'), 0);
// Wait 10ms
let now = Date.now();
while (Date.now() - now < 10) {}
}
document.body.addEventListener('click', onClick);
document.body.firstElementChild.addEventListener('click', onClick);
document.body.firstElementChild.click();
上記の例の出力結果は以下の通りです:
click
click
timeout
timeout
最初の click が出力された後すぐに timeout が出力されないのは、この時点でコールスタックが空ではないからです(スタックには onClick があり、それは子のものです)。そのため、マクロタスクキューの中に期限切れタイマーのコールバックがあるにもかかわらず、イベントループはチェックを行いません。具体的には、イベントバブリングによって body の onClick もトリガーされたため、一連の同期的バブリングが終わるまで、子コンポーネントの onClick もスタックから外れることができないからです。
P.S. したがって、このシナリオの興味深い点は、イベントバブリングがもたらす「暗黙的な関数呼び出し」にあります。
6つのタスクキュー
NodeJSには、**4つのマクロタスクキュー(明確な処理順序がある)**があります:
-
Expired timers/intervals queue:
setTimeout、setInterval -
IO events queue:ファイルの読み書きやネットワークリクエストなどのコールバック
-
Immediates queue:
setImmediate -
Close handlers queue:ソケットの close イベントのコールバックなど
イベントループは期限切れのタイマーからチェックを開始し、順番に各キューで待機しているすべてのコールバックを処理します。
さらに、**2つのマイクロタスクキュー(こちらも明確な処理順序がある)**があります:
-
Next tick queue:
process.nextTick -
Micro task queue: Promise のコールバックなど
nextTick マイクロタスクキューは他のマイクロタスクキューよりも優先度が高いため、nextTick が空になって初めて Promise などが処理されます。
Next tick キューは、他のマイクロタスクキューよりもさらに高い優先度を持っています。
nextTick と setImmediate
前者はマイクロタスクであり、後者はマクロタスクです。これは、過度に連続した nextTick の呼び出しがイベントループをブロックし、結果として I/O をブロックすることを意味します。したがって、必要でない限り nextTick を乱用しないでください:
process.nextTick()よりもsetImmediate()を使用することが推奨されます。setImmediate()はおそらくあなたが期待していること(より効率的なsetTimeout(..., 0))を行い、このティックの I/O の後に実行されます。process.nextTick()は実際にはもはや「次(next)」のティックで実行されるわけではなく、同期操作であるかのように I/O をブロックしてしまいます。
また、両者の主な違いは、nextTick は現在の車の最後尾にぶら下がって実行されるのに対し、setImmediate は次のバスを待つ必要があるという点です:
process.nextTick()は、同じフェーズ内ですぐに実行されます。setImmediate()は、イベントループの次のイテレーションまたは「ティック」で実行されます。
P.S. setImmediate の説明は厳密ではありません。次の immediate フェーズになれば実行可能であり、必ずしも次のイベントループまで待つ必要はありません(現在どのフェーズにいるかによります)。
P.S. 名前だけを見ると immediate の方が近いように思えますが、実際には nextTick こそが最も近い未来です。歴史的な理由により、名前を変えることはできませんでした。
なお、 nextTick が存在する理由は、より細かい粒度のタスクを提供し、イベントループの各フェーズの隙間で実行できるようにするためです。例えば、急ぎのクリーンアップ作業やエラー処��/リトライなどを行うための実用的なシーンがあります。詳細は Why use process.nextTick()? を参照してください(ここでは詳しく説明しません)。
setTimeout と setImmediate
setTimeout(function() {
console.log('setTimeout')
}, 0);
setImmediate(function() {
console.log('setImmediate')
});
timer-IO-immediate-close というマクロタスクの処理順序に基づくと、ログの順番は以下のようになると推測されます:
setTimeout
setImmediate
しかし、実際の状況は以下の通りです:
// 1回目
setImmediate
setTimeout
// 2回目
setImmediate
setTimeout
// 3回目
setImmediate
setTimeout
// 4回目
setTimeout
setImmediate
// 5回目
setImmediate
setTimeout
// 6回目
setTimeout
setImmediate
出力は順不同です。これは競争状態があるからではなく、 setTimeout 0 の 0 が厳密な意味での「即時」ではないためです。つまり、 0ms のタイマーが必ずしも即座にコールバック関数をタスクキューに挿入するとは限らず、そのため setTimeout 0 が直近の次のイベントループに間に合わない可能性があり、その結果、理屈に合わない出力が発生することがあります。
では、どのような状況であれば両者の順序を確定できるのでしょうか?
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0);
setImmediate(() => {
console.log('immediate')
})
});
I/O キューの処理中に新しい timer タスクと immediate タスクが発生した場合、順序に従って次は immediate キューが処理されます。そのため、常に先に 'immediate' が出力され、順序が乱れることはありません。
では、これらを逆の順序に保つ方法はありますか?
あります。このようにします:
setTimeout(function() {
console.log('setTimeout')
}, 0);
//! タイマーが切れるのを待つ
var now = Date.now();
while (Date.now() - now < 2) {
//...
}
setImmediate(function() {
console.log('setImmediate')
});
上記の例では、安定して先に setTimeout が出力されます。途中の 2ms のブロッキングはタイマーの期限切れを待つためのものであり、これによりイベントループを開始する前に、タイマーが切れてコールバックがすでに処理待ちキューに挿入されていることを保証できます。
P.S. なぜここで 2ms を使うのかというと、 setTimeout 0 は内部的に setTimeout 1ms に変換されると言われているため、念のために少し多めに待っているのです。詳細は Understanding Non-deterministic order of execution of setTimeout vs setImmediate in node.js event-loop の uvlib ソースコード分析を参照してください。
P.S. もし 2ms で足りなければ、もう少し長く待ってください。要するにポイントはタイマーを期限切れにさせることであり、そうすることでイベントループが次の回まで待たずに、最初に setTimeout 0 のコールバックを見つけることができるようになります。
IO starvation
マイクロタスクの仕組みは I/O スターベーション(飢餓状態)の問題を引き起こします。無限に長いマイクロタスクキューはイベントループをブロックしてしまいます。この問題を避けるため、初期の NodeJS バージョン (v0.12) では 1000 という深度制限 ( process.maxTickDepth ) が設定されていましたが、後に削除されました。
process.maxTickDepthは削除され、process.nextTickが I/O を無期限に飢餓状態にすることが許可されました。これは 0.10 でsetImmediateが追加されたためです。
P.S. 詳細は https://github.com/nodejs/node/wiki/API-changes-between-v0.10-and-v0.12#process を参照してください。
例えば:
const fs = require('fs');
function addNextTickRecurs(count) {
let self = this;
if (self.id === undefined) {
self.id = 0;
}
if (self.id === count) return;
process.nextTick(() => {
console.log(`process.nextTick call ${++self.id}`);
addNextTickRecurs.call(self, count);
});
}
addNextTickRecurs(Infinity);
setTimeout(console.log.bind(console, 'omg! setTimeout was called'), 10);
setImmediate(console.log.bind(console, 'omg! setImmediate also was called'));
fs.readFile(__filename, () => {
console.log('omg! file read complete callback was called!');
});
console.log('started');
このコードは永遠に omg! xxx を出力しません。同期コードの実行が終わってコールスタックが空になった後、イベントループがタスクキューをチェックすると nextTick マイクロタスクキューが空でないことが判明し、そのマイクロタスクを取り出してコールスタックに放り込んで実行しますが、その中でまた一つ挿入されるということが延々と続き、止まらなくなるからです。
注意点として、現在イベントループのどのフェーズにいるかに関わらず、即座に nextTick キューがチェックされます:
nextTickQueue は、現在の操作が完了した後、イベントループの現在のフェーズに関わらず処理されます。
(The Node.js Event Loop, Timers, and process.nextTick() より引用)
Event Loop Counter
イベントループをカウントするにはどうすればよいでしょうか?
このようにすることができます:
const LoopCounter = {
counter: 0,
active: true,
start() {
setImmediate(this.countLoop.bind(this));
},
stop() {
this.active = false;
},
get() {
return this.counter;
},
countLoop() {
this.counter++;
if (this.active) setImmediate(this.countLoop.bind(this));
}
};
// テスト
LoopCounter.start();
let now = Date.now();
let intervals = 0;
let MAX_COUNT = 10;
let handle = setInterval(() => {
console.log(LoopCounter.get());
if (++intervals >= MAX_COUNT) {
clearInterval(handle);
LoopCounter.stop();
}
}, 10);
setImmediate を時計として使うのは、4種類のマクロタスクの中で setImmediate だけが、次のイベントループですぐに処理されることを保証できるからです。
このカウンターは何の役に立つのでしょうか?
イベントループの追跡に使用できます。例えば、同じイベントループ内にいるかどうかの確認や、以前に議論した setTimeout 0 と setImmediate の順序の問題について、カウンターを通じてさらなる検証を行うことができます。結果は以下の通りです:
// 1回目
setImmediate 1
setTimeout 1
// 2回目
setTimeout 0
setImmediate 1
1 1 はタイマーが直後の最初のイベントループに間に合わず、2周目に実行されたことを示しています。 0 1 は次の最初のイベントループの前に、タイマーがすでに期限切れになっていた(間に合った)ことを示しています。
参考文献
-
Difference between microtask and macrotask within an event loop context
-
Philip Roberts: Help, I’m stuck in an event-loop.:イベントループとコールスタック、タスクキューに関する動画
-
loupe:JS実行プロセス可視化ツール
-
Timers, Immediates and Process.nextTick— NodeJS Event Loop Part 2:最後の問題が非常に興味深いです。答えはコメント欄にあります。このシリーズの5つの記事はどれも良いでしょう。
コメントはまだありません