Node.js

内部実装から読み解くNode.js(v11.0.0) Eventloop

Goal

  • Node.js Eventloopとは何かを知る
  • JSとNode.jsの非同期APIを区別する
  • NodejsではEventloopを止めてはいけないことを学ぶ
    • 他の同期処理をベースとした言語と同じ書き方をしてはいけない

Q. 次の挙動は?

setTimeout(() => {
  console.log('ok');
}, 0);
// 時間のかかる同期処理 => その極端な例として無限ループ
while (1) {}

A. 何秒待っても出力されない

同期処理の言語に慣れていると、すぐにokの出力を期待してしまうが...


Node.jsでのsetTimeout()

  • Node.jsはJavaScriptエンジン(v8)を持っている
    • 基本的にはJSのAPIを利用できる
  • ただし一部はNode.jsのランタイムではNode core APIが実行される
  • setTimeout(),setInterval(),setImmediate()JSのTimerではなくNode core APIのTimerが実行される
    • これらはNode.js Eventloop内で処理される

JS/Node.jsの主な非同期処理

  • JS
    • Promise(ここではObject.observe()として扱う1)
  • Node.js
    • I/O
    • setImmediate
    • setTimeout/setInterval
    • process.nextTick
    • EventEmitter ※emit()のタイミング次第で同期にもなる

Node.js Eventloop

  • Node.jsの非同期処理は全てEventloop内で評価される
  • Node.jsコミッターの大津さんが作成したv0.11の図が今でもわかりやすい
  • Eventloopはユーザーランドの実行後に回るので、長い同期処理があるといつまでも非同期コールバックが評価されない

ということを内部実装を見て確認


Node.js: 始まりの場所

node ***.jsを実行すると...

  1. src/node_main.cc: main(argc, argv)
    • stdout,stderrのバッファリングを0に
    • node::Start(argc, argv)
  2. src/node.cc: Start(argc, argv)
    • v8の初期化など
  3. src/node.cc: Start(event_loop, args, exec_args)
    • isolate作成: chromeのタブと同じ
  4. src/node.cc: Start(isolate, data, args, exec_args)
    • context作成
    • LoadEnvironment()でC++からユーザーランドのJSを実行(globalを生やすとかbootstrap処理の後) - do-while

Node.js: libuv

  • node.ccのdo-while内でuv_run()
  • depsディレクトリはNode.jsを支えるサードパーティの置き場(v8など)
    • uv_run()はlibuvというマルチプラットフォームなライブラリのAPI
  • libuvのuv_run()のwhileループをEventloopと呼ぶ

Eventloop ≠ node.cc do-while?


Eventloopの終了条件

  • uv_run()のwhileループの終了条件を見れば良い
    • uv__loop_alive()の戻り値が0になること
    • すなわちuv__has_active_handlesuv__has_active_reqsが0
    • activeなhandleまたはreqが無くなったとき

handle/req

特徴 module例
handle I/Oが発生していないときでもイベントループを維持 Timers, server.listen()
req I/O発生中のみイベントループを維持 fs, http

Nodejsが終了する条件は?

  • コードの上から下まで実行された時
  • 登録した非同期処理が全て実行された時
    • 実行待ちの非同期処理をdeactivateすることもできるので偽
  • Eventloopが終了、つまりactiveなhandle/reqが無くなった時
    • 但しbeforeExitイベントリスナー内でhandle/reqが追加されると復活
    • もちろんuncaughtExceptionなどの例外終了やprocess.exit()などによる強制終了もある

libuvとは

  • クロスプラットフォームで非同期イベントの監視を担う
    • src/以下でディレクトリ名でunix/winで分かれ、ゴリゴリのマクロで実装
  • イベント例
    • 期間
    • timer
    • idle状態
    • 複数のfd(ファイルディスクリプタ)のstatus
      • file(disk I/O)やsocket(network I/O)など
    • SIGNAL
    • 子プロセスの状態
    • 別スレッドからの通知

イベントの監視順

  • 記述したコードの上から下の順に非同期コールバックはキューに積まれる
  • uv_run()内の各箇所で評価
    • uv__run_timers()でsetTimeout/setIntervalを評価
    • uv__io_poll()でI/Oを評価
    • uv__run_check()でsetImmediate()を評価

イベントの監視順(コードで確認)

order.js
setImmediate(()=>{
  console.log('setImmediate');
});

var fs = require('fs');
fs.open('./README.md', 'r', (err, fd)=>{
  console.log('fs.open');
  fs.read(fd, Buffer.alloc(1000), 0, len = 1000, null, (err, bytesRead, buffer) => {
    console.log('fs.read');
  });
});
setTimeout(()=>{
  console.log('setTimeout');
}, 1);

console.log('userland');
// 実行結果
// userland
// setTimeout
// fs.open
// setImmediate
// fs.read

process.nextTick/Promiseのタイミング

  • process.nextTickはEventloop内ではなくユーザーランドの実行後
  • PromiseはJSレイヤーなので、Eventloop内ではなくユーザーランドの実行後
  • どちらが先?
    • PromiseObject.observe()と同じ発火タイミングで、process.nextTick()の方が先になる

process.nextTick/Promiseのタイミング(コードで確認)

nextTick_vs_promise.js
Promise.resolve().then(() => console.log('promise resolved'));
process.nextTick(() => console.log('next tick'));
// 実行結果
// next tick1
// promise1 resolved

(重要)非同期APIの順番は保証されない

  • 各イベントの順番は保証されていない
  • API互換の保証外の挙動のため、今後の修正で変わりうる
  • 順番に依存しないような設計をすべき

Eventloopを止めるな!(NG)

stop_eventloop.js
const max = process.argv[2] - 0; // 十万以上でやばい
for(let i = 0; i < max; i++)
  console.log('test');
  • process.memoryUsage()などでメモリ利用量の変化を監視
    • rss(アプリケーションのメモリ使用量)がloop回数に比例して増大
  • Eventloopが回らず、console.logがイベントキューに溜まり続けてメモリが圧迫されるため2

EventLoopを止めるな!(OK)

  • while/forでユーザーランドで同期的に処理させるのではなく、非同期処理でEventloop内で処理をする
alive_eventloop.js
const max = process.argv[2] - 0;
let counter = 1;
const timeout = setInterval(() => {
  if(counter++ > max) clearInterval(timeout);
  console.log('test');
}, 1);

まとめ

  • Eventloopとは、libuvによるイベント(主にfdやtimer)を監視し続ける仕組み
    • 監視に順番はあるが保証外なので依存しないよう設計する
  • NodejsではEventloopが回るよう意識して実装する必要がある
    • 同期実行が長いとtimerが指定時に動かなかったりmemory leakぽい挙動をすることがある


  1. Object.observe()とNode.jsのイベントループの関係 

  2. 但しfd=1(tty)への書き込みは同期的に行われる。console.logは内部的にprocess.stdoutで実装されているが、それが同期か非同期かは出力先によって異なる: https://nodejs.org/api/process.html#process_a_note_on_process_i_o