本記事の目的
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 本スクリプトを実行してからの経過秒数.
*/constseconds=()=>performance.now()/1000;constsecondsPadded=()=>seconds().toFixed(6).padStart(10,'');// 長さ揃える.//////////////// 処理3つ /////////////////**
* 処理 1 (非同期, 5 秒後に発火).
*/constfunc1=()=>{console.log(`${secondsPadded()} seconds --> 処理 1 (非同期, 5 秒後に発火)`);};/**
* 処理 2 (非同期, 0 秒後に発火).
*/constfunc2=()=>{console.log(`${secondsPadded()} seconds --> 処理 2 (非同期, 0 秒後に発火)`);};/**
* 処理 3 (同期. 10 秒かかる).
*/constfunc3=()=>{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の内部では ↓ のように処理がシングルスレッドで行われています。
- 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 が実行可能になってから (現在実行中の他の処理を待って) 順番が来たら (やっと) 実行開始する
です。