Node.js

内部実装から読み解くNode.js Eventloop

Goal

  • Node.js Eventloopとは何かを知る
  • JSの非同期処理とNode.jsの非同期処理を区別する
  • 非同期コールバックの優先順位を知る
  • 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の図が重要
    • 現在LTSのv8.xとほぼ変わってない
  • Eventloopはユーザーランドの実行後に回るので、長い同期処理があるといつまでも非同期コールバックが評価されない
    • ということを内部実装を見て確認

Node.js Eventloop(内部実装)


Node.js Eventloop ≠ node.cc do-while?


handle/req

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

Q. Nodejsが終了する条件は?

  • コードの上から下まで実行された時
  • 登録した非同期処理が全て実行された時
    • 実行待ちの非同期処理をdeactivateすることもできるので偽
  • activeなhandle/reqが無くなった時
    • もちろん例外による終了やprocess.exit()などによる強制終了もある

libuvとは

  • イベントの監視を担う
  • イベント例
    • 期間
    • 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の同期・非同期は出力先によって異なる。