本記事の目的
JavaScript や Node.js はよくシングルスレッドだ〜、と言われますが、では非同期処理はどうやって実行されているのか (Non-Blocking I/O) をざっくりと (私の身内に) 説明する為のサンプルコードです。
検証環境
- iMac (Retina 5K, 27-inch Late 2014), 4 GHz Intel Core i7
- Node.js v12.13.0
$ nodebrew install-binary v12.13.0
$ nodebrew use v12.13.0
ブラウザ JavaScript の Event loop はまたちょっと違います。
早速サンプルコードから
以下の様な JavaScript index.js
を、Node.js で実行します。
- 【処理 1】ミリ秒で終わる処理を
setTimeout()
で 5 秒後に発火. - 【処理 2】ミリ秒で終わる処理を
setTimeout()
で 0 秒後に発火. - 【処理 3】10 秒かかる同期処理を実行.
- 時間の計測には Node.js 標準 API の perf_hooks モジュールを使用しています。 Node.js プロセス実行開始からのミリ秒を得られます
- コード中では、ミリ秒 → 秒、に変換して表示しています
index.js
const { performance } = require('perf_hooks');
/**
* @return 本スクリプトを実行してからの経過秒数.
*/
const seconds = () => performance.now() / 1000;
const secondsPadded = () => seconds().toFixed(6).padStart(10, ' '); // 長さ揃える.
//////////////// 処理3つ ////////////////
/**
* 処理 1 (非同期, 5 秒後に発火).
*/
const func1 = () => {
console.log(`${secondsPadded()} seconds --> 処理 1 (非同期, 5 秒後に発火)`);
};
/**
* 処理 2 (非同期, 0 秒後に発火).
*/
const func2 = () => {
console.log(`${secondsPadded()} seconds --> 処理 2 (非同期, 0 秒後に発火)`);
};
/**
* 処理 3 (同期. 10 秒かかる).
*/
const func3 = () => {
while (seconds() < 10) { /* consuming a single cpu for 10 seconds... */ }
console.log(`${secondsPadded()} seconds --> 処理 3 (同期, 10 秒かかる)`);
};
//////////////// 計測開始 ////////////////
console.log(`${secondsPadded()} seconds --> index.js START`);
// [非同期] 5 秒後に実行.
setTimeout(func1, 5000);
// [非同期] 即時実行.
setTimeout(func2);
// 同期実行.
func3();
console.log(`${secondsPadded()} seconds --> index.js END`);
//////////////// 計測終了 ////////////////
期待値?
なんとなく 「こう動作するだろう...」 という気分になるのは ↓ でしょう。
$ node index.js
0.000000 seconds --> index.js START
0.000000 seconds --> 処理 2 (非同期, 0 秒後に発火)
5.000000 seconds --> 処理 1 (非同期, 5 秒後に発火)
10.000000 seconds --> 処理 3 (同期, 10 秒かかる)
10.000000 seconds --> index.js END
実際は...
現実はこうです。何故でしょうか。
$ node index.js
0.175104 seconds --> index.js START
10.000085 seconds --> 処理 3 (同期, 10 秒かかる)
10.000210 seconds --> index.js END
10.000955 seconds --> 処理 2 (非同期, 0 秒後に発火)
10.001161 seconds --> 処理 1 (非同期, 5 秒後に発火)
シングルスレッドだから、順番に処理している
おおよそ、Node.js (+V8/libuv) の内部では ↓ のように処理がシングルスレッドで行われています。
- JavaScript コンテキストの生成時にイベントループが生成されます
- 最初のエントリ JavaScript
index.js
がタスクとして、未実行キューに乗ります - イベントループ
- 未実行キューから
index.js
タスクが取り出され、実行が開始されます-
setTimeout(処理1, 5秒)
が実行され、【処理 1】がタイマーキューに追加されます -
setTimeout(処理2, 0秒)
が実行され、【処理 2】がタイマーキューに追加されます - 【処理 3】が同期的に実行され、10 秒間、CPU (シングルコア) を専有します
-
-
index.js
タスクの実行が終了します
- 未実行キューから
- イベントループ
- タイマーキューから 有効期限が切れたタスク【処理 2】 を取り出し、実行が開始されます
- 【処理 2】タスクの実行が終了します
- イベントループ
- タイマーキューから 有効期限が切れたタスク【処理 1】 を取り出し、実行が開始されます
- 【処理 1】タスクの実行が終了します
実際はタイマー Phase はキューではない (FIFO でもない) ですが、説明の都合上そう表記しました。
要はイベントループにて、実行可能なタスクがあれば即時実行し、なければ I/O 待ち (epoll) をすることになります。
結論
つまり、setTimeout()
等の非同期タイマー処理は...
- 指定した時間が来たら即座に Callback を実行する. (OS 割り込みみたいに)
ではなく...
- 指定した時間を 過ぎてたら Callback を できるだけ早く 実行する
ですね。
それは Promise や、Network Socket I/O 待ちである fetch でも同じで...
- Callback が実行可能になってから (現在実行中の他の処理を待って) 順番が来たら (やっと) 実行開始する
です。