LoginSignup
28
13

More than 3 years have passed since last update.

JavaScript (Node.js) の非同期処理とシングルスレッド

Last updated at Posted at 2020-04-04

本記事の目的

JavaScriptNode.js はよくシングルスレッドだ〜、と言われますが、では非同期処理はどうやって実行されているのか (Non-Blocking I/O) をざっくりと (私の身内に) 説明する為のサンプルコードです。

Node.js, V8 のコードレベルでちゃんと理解したいのであれば、以下のサイトが大変参考になりました。

検証環境

  • 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. 【処理 1】ミリ秒で終わる処理を setTimeout() で 5 秒後に発火.
  2. 【処理 2】ミリ秒で終わる処理を setTimeout() で 0 秒後に発火.
  3. 【処理 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 の内部では ↓ のように処理がシングルスレッドで行われています。

  1. JavaScript コンテキストの生成時にイベントループが生成されます
  2. 最初のエントリ JavaScript index.js がタスクとして、未実行キューに乗ります
  3. イベントループ
    1. 未実行キューから index.js タスクが取り出され、実行が開始されます
      1. setTimeout(処理1, 5秒) が実行され、【処理 1】がタイマーキューに追加されます
      2. setTimeout(処理2, 0秒) が実行され、【処理 2】がタイマーキューに追加されます
      3. 【処理 3】が同期的に実行され、10 秒間、CPU (シングルコア) を専有します
    2. index.js タスクの実行が終了します
  4. イベントループ
    1. タイマーキューから 有効期限が切れたタスク【処理 2】 を取り出し、実行が開始されます
    2. 【処理 2】タスクの実行が終了します
  5. イベントループ
    1. タイマーキューから 有効期限が切れたタスク【処理 1】 を取り出し、実行が開始されます
    2. 【処理 1】タスクの実行が終了します

実際はタイマー Phase はキューではない (FIFO でもない) ですが、説明の都合上そう表記しました。

JavaScript Non-Blocking I_O Architecture.png

要はイベントループにて、実行可能なタスクがあれば即時実行し、なければ I/O 待ち (epoll) をすることになります。

結論

つまり、setTimeout() 等の非同期タイマー処理は...

  • 指定した時間が来たら即座に Callback を実行する. (OS 割り込みみたいに)

ではなく...

  • 指定した時間を 過ぎてたら Callback を できるだけ早く 実行する

ですね。

それは Promise や、Network Socket I/O 待ちである fetch でも同じで...

  • Callback が実行可能になってから (現在実行中の他の処理を待って) 順番が来たら (やっと) 実行開始する

です。

参考文献

28
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
13