カテゴリー
【nodejs基礎講座】Node.jsを組込みアプリケーションに使う前に知っておきたい「イベントループ」の仕組み
※ 当ページには【広告/PR】を含む場合があります。
2024/04/23

Node.jsはもともとはサーバーサイドの動作を念頭にした「イベント駆動型のサーバーアーキテクチャ」ベースの「非同期処理」を実現するアプリケーションになっています。
昨今ではサーバー実機にとどまらず、Raspberry Piのようなシングルボードコンピュータ向けの組込みアプリケーションとしても利用できるようになっています。
通常、C言語等で組込みアプリケーションを作成する場合には、簡単なポーリングの中で、デバイスの初期化や割り込みなどで、一定の時間間隔で処理をくるくる回していくような実装になります。
他方、Node.jsを使って組込みアプリケーションを作成したい場合、Node.js独自の
今回は組込み開発者目線で、Node.jsのイベントループの話を紹介していきます。
Node.jsの内部アーキテクチャ〜libuvの仕組み
Node.jsの内部構造を詳しく網羅していくと切がない話なので、今回は
933x489

上の図で表すように、Node.jsは様々なOS上で共通のアプリケーションを実行させるための、「クロスプラットホーム」のJavascript実行環境です。
より低レイヤーのOS側には、「V8」や「libuv」の主要なライブラリや、いくつかのOSカーネルに接続するC/C++ライブラリで構成されています。
このうち特に、「libuv」はイベントループを"非同期"に処理するための仕組みを提供しています。
もう一層上の中間レイヤーには、Javascript側からC/C++のライブラリを呼べるように橋渡しする
こういったOS側にある低レイヤーの煩雑な仕組みをNode.jsが全て受け持ってくれるおかげて、Nodejsアプリケーションの開発者は、中間レイヤーより上のアプリケーション層(API層)だけに意識しながら、Javascriptコードによるアプリケーション作成に集中することが可能になります。
さて、全ての処理が仮に"同期的"であれば、「イベントループ」という仕組みも不要でそもそもlibuvを使う必要もないのですが、現実は他のサーバーからHTTPS通信などで返されたレスポンスを非同期で受け取る必要がありますし、他にも様々な非同期の処理がサーバー内部で動いています。
Node.jsを組込みで使う場合にも例外ではなく、カメラから写真をキャプチャするのにシャッターボタンを押すときの割り込みは、非同期処理で実現したほうが圧倒的にパフォーマンスが良いです。
libuvとは?
Node.jsでの非同期処理を一手に受け持つのが
公式ドキュメントの模式図から、libuvの内部構成は以下のようになっています。
1020x493

ちなみにlibuvの謳う、「マルチプラットフォームの非同期I/O処理」の部分が、
epoll (Linux)
kqueue (BSD)
event port (Solaris)
IOCP (Windows)
Node.jsのイベントループ
一般的に
文字通り、イベント通知を発生させて、常時監視している仕組みを意味しています。
このイベントループの利点として、クライアントからのサーバーへのコネクションを、シングルスレッドでも十分処理し、かつ他のプロセスは止めない「ノンブロッキングI/O」を実現することが可能となるメリットがあります。
特に、Node.jsのイベントループは「libuv」に基づいたものです。
ちなみに、ブラウザでも"イベントループ"の仕組みが存在しますが、こちらはHTML5に規定されるものであり、Node.jsのイベントループとは別物ですので注意が必要です。
ここではイベントループといえば、「Node.js(=libuv)のイベントループ」という意味合いで以降で説明していきます。
libuvのイベントループ
先程のからの繰り返しになりますが、イベントループとは、
このイベントループを端的に表した模式図が以下のようなものです。
647x717

Node.jsアプリケーションを起動すると、イベントループが初期化され、同期的な処理(通常のタスク)が逐次コールスタックに追加されて実行、ついで6つのフェイズと2つのマイクロキューが順次実行されていきます。
ちなみにここでのイベントループ開始前の初期化処理とは、
1. タイマーがあればスケジュールを設定
2. process.nextTick関数等のマイクロキューの残りを実行
3. 同期タスクがあれば実行
4. 非同期APIのコールバック呼び出し
といったことを指します。
イベントループ初期化前の挙動や、通常の同期処理はさておき、動作中のイベントループを理解するためには、以下の6つのフェイズの役割が重要です。
この6つの各フェーズにそれぞれ実行するコールバックのジョブキュー(=イベントキュー)が存在し、
1. キューにあるジョブを全て実行する(=Emptyになる)
2. キューにあるコールバックの数が最大数(上限)に達する
のどちらかの条件が満たされると次のフェイズに移行します。
またこの6つのフェイズとは別に、各フェイズ内で発生した
「マイクロタスク」
「マイクロタスクキュー」
マイクロタスクの役割は、各フェイズでコールバックの処理が終了した後で、その結果を一度Nodejsアプリ側へ伝達させるためのものです。
このマイクロタスクキューには、
「nextTickQueue」
「microTaskQueue」
この2つキューはlibuvではなく、Node.js側から提供される機能となっています。
そのため、イベントループからは切り離されたNode.jsに属するキューであり、イベントループの外側に存在する非同期のAPIという扱いに注意が必要です。
イベントループの各フェーズの後にマイクロタスクキューに送られたコールバックが処理され、全てキューが空になるまで実行します。
リアクターパターン
先程、各フェイズの「イベントキュー」に、「コールバックを送る」と簡易的な表現に留めていましたが、実際にイベントキューへハンドラ(コールバック)を送る仕組みは少し複雑です。
『
実際のNode.js内部で用いられているリアクターパターン以外に、スレッドプールによる非同期処理の仕組みも上手く競合させているため、もっと複雑な仕様のようですが、リアクターパターンだけに着目すると以下のようなものに簡略的に模式化されます。
900x623

この図では、処理をイベントループの時間軸に並べて、処理のおおよその流れを説明したものです。
イベントループ中に、まずアプリケーション側から
このとき、I/Oリクエストの発行自体はメインスレッドの処理を止めない(ノンブロッキング)ため、即座にアプリケーションへ処理が戻ります。
イベント・デマルチプレクサは、 I/Oリクエストを受け取った後で、対象のフェイズのイベントキューへ、イベントとハンドラを更に発行します(②)。
イベントループは、各フェイズに遷移したタイミングで、対象のイベントキューの中身に従って、実行すべきイベントを送り出します(③)。
各フェイズで実行予定のイベントに対して、紐付けされたハンドラが呼び出され、ここでメインスレッドで実行に移されます。
選択されたハンドラの処理が順次一つ一つ捌かれるので、一つのハンドラ処理が終わると、また次のハンドラが処理され、イベントキューが順次消化されるのを繰り返します(⑤a)。
もしくは、処理中のハンドラから更にI/Oリクエストが発行された場合には、イベントループ処理へ戻す前にアプリケーション側からI/Oリクエストを繰り返します(⑤b)。
対象のフェイズの全てのイベントキューが処理されると、新しいイベントがイベントキューに入るまで待機しています(⑥)。
というのが、リアクターパターンの基本思想のようです。
ちなみに、イベント・デマルチプレクサにはOSに備わっている非同期I/Oイベントを管理・監視する仕組みがあり、この機能は各OSごとに異なるようですが、このへんをlibuvが上手く処理してくれています。
実際のJavascriptコードでイベントループの処理を確認する
先程までで、イベントループの概要をおおむね説明してきました。
ここからは"百聞は一見に如かず"ということで、実際のコードを動かして実行順序を確認してみます。
手元の動作確認した環境では、以下のnodejsのバージョンにしています。
$ node --version
v20.12.2
ESModlue対応を意識したコーディングになっているため、Requireでモジュールを読み込む古いnode(CommonJS)では以下のコードが動作しないかもしれませんのでご容赦ください。
setTimeout関数を利用する際の注意点
Node.jsでの非同期処理の起点とも言えるのが、
setTimeout
ただ、
setTimeout
1 - 2147483647[ms]
1[ms]
ということで、以下は全て1ms(とちょっと)の時間で遅延します。
setTimeout(() => {console.log('1msくらい遅延!'), 1});
setTimeout(() => {console.log('1msくらい遅延!')});
setTimeout(() => {console.log('1msくらい遅延!'), 0});
setTimeout(() => {console.log('1msくらい遅延!'), -1});
setTimeout(() => {console.log('1msくらい遅延!'), undefined});
setTimeout(() => {console.log('1msくらい遅延!'), 100_000_000_000});
また、マシーンの演算処理のスペックにもよりますが、Timerフェイズが1ms程度の遅延だと、1ms以下の時間でTimerキューに入ったコールバックを消費されてしまい、キューが空になったとイベントループに判断されると、次のフェイズに送られ、そのラウンドが終了してしまう可能性もあります。
これを以下のコードで確認してみましょう。
//👇ちょっと長め(=2ms)のタイマー
setTimeout(() => console.log(`2msでTimerフェイズです`), 2);
for (let i=0; i < 5;i++) {
//👇ちょっと長め(=2ms)のタイマー
setTimeout(() => {
console.log(`1msでTimerフェイズ${i}です`);
//👇微妙な計算負荷をかけて若干遅延気味に
for (let j = 0; j < 100_000_000; j++) {}
});
}
setImmediate(() => console.log('Checkフェイズ'));
これを実行すると、
1msでTimerフェイズ0です
1msでTimerフェイズ1です
1msでTimerフェイズ2です
1msでTimerフェイズ3です
1msでTimerフェイズ4です
Checkフェイズ
2msでTimerフェイズです
だったり、場合によっては、
1msでTimerフェイズ0です
1msでTimerフェイズ1です
Checkフェイズ
2msでTimerフェイズです
1msでTimerフェイズ2です
1msでTimerフェイズ3です
1msでTimerフェイズ4です
または、
Checkフェイズ
2msでTimerフェイズです
1msでTimerフェイズ0です
1msでTimerフェイズ1です
1msでTimerフェイズ2です
1msでTimerフェイズ3です
1msでTimerフェイズ4です
となったり、実行するたびに異なる間欠的な実行結果になります。
タイマーを1msで使う機会はあまりないので、普段は気に留める必要もないのですが、各フェイズのイベントキューが「空になった」・「次のフェイズ進む」のは実行中のNodejsプロセスに全てお任せであり、開発者が手動でフェイズ遷移のタイミングを決定することはできないことにも注意です。
同期処理・Timer/Pending/Closeフェイズ・マイクロタスクの確認
イベントループ処理の実行順序を意図的に確認してみましょう。
同期処理
Timerフェイズ
Pendingフェイズ
Closeフェイズ
マイクロタスク
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
console.log('同期処理1です');
setTimeout(() => {
console.log('Timerフェイズ1です');
process.nextTick(() => console.log('マイクロタスク3です'));
Promise.resolve().then(() => console.log('マイクロタスク4です'));
});
setImmediate(() => {
process.nextTick(() => console.log('マイクロタスク5です'));
Promise.resolve().then(() => console.log('マイクロタスク6です'));
console.log('Checkフェイズ1です');
});
process.nextTick(() => console.log('マイクロタスク1です'));
Promise.resolve().then(() => console.log('マイクロタスク2です'));
console.log('同期処理2です');
//👇存在しないファイルを読み込むためErrorレスポンスが返る
fs.readFile(`not-found.txt`, () => {
process.nextTick(() => console.log('マイクロタスク7です'));
Promise.resolve().then(() => console.log('マイクロタスク8です'));
console.log('Pendingフェイズ1です');
setTimeout(() => {
console.log('Timerフェイズ2です');
process.nextTick(() => console.log('マイクロタスク9です'));
Promise.resolve().then(() => console.log('マイクロタスク10です'));
});
});
//👇jsファイルと同じフォルダにあるsample.txtファイルを読み込む
const readStream = fs.createReadStream(`${__dirname}/sample.txt`);
readStream.on("data", () => {
console.log('Pendingフェイズ2です');
process.nextTick(() => console.log('マイクロタスク11です'));
Promise.resolve().then(() => console.log('マイクロタスク12です'));
}).on('close', () => {
console.log('Closeフェイズ1です');
Promise.resolve().then(() => console.log('マイクロタスク14です'));
process.nextTick(() => console.log('マイクロタスク13です'));
});
Promise.resolve().then(() => console.log('マイクロタスク15です'));
console.log('同期処理3です');
process.nextTick(() => console.log('マイクロタスク16です'));
これを実行しますと、以下のように動作します。
同期処理1です
同期処理2です
同期処理3です
マイクロタスク1です
マイクロタスク16です
マイクロタスク2です
マイクロタスク15です
Timerフェイズ1です
マイクロタスク3です
マイクロタスク4です
Pendingフェイズ1です
マイクロタスク7です
マイクロタスク8です
Checkフェイズ1です
マイクロタスク5です
マイクロタスク6です
Timerフェイズです2
マイクロタスク9です
マイクロタスク10です
Pendingフェイズ2です
マイクロタスク11です
マイクロタスク12です
Closeフェイズ1です
マイクロタスク13です
マイクロタスク14です
ちょっと欲張ってしまった感があるので、結果が少し見にくいのですが、少し解説しますと、まず冒頭のイベントループが開始される前の、
同期処理1です
同期処理2です
同期処理3です
マイクロタスク1です
マイクロタスク16です
マイクロタスク2です
マイクロタスク15です
では、メインスレッドにある同期処理(通常の関数)と、マイクロタスク(
nextTick
Promise
これらの処理が完了すると、イベントループがTimerフェイズから開始されます。
Timerフェイズ1です
マイクロタスク3です
マイクロタスク4です
Pendingフェイズ1です
マイクロタスク7です
マイクロタスク8です
Checkフェイズ1です
マイクロタスク5です
マイクロタスク6です
までが、イベントループの1ラウンド目で、
Timer --> Pending --> Check
また各フェイズの終わりには、フェイズ内部で発行されたマイクロタスクが全て実行されていることも見て取れます。
なお、
fs.readFile
ここでI/Oエラーが出さないと、Pendingフェイズの処理結果が変わり、2ラウンド目の処理が変化します。
詳しくは後述するPendingフェイズのパートで解説します。
ここでの次のイベントループ2ラウンド目では、
Timerフェイズ2です
マイクロタスク9です
マイクロタスク10です
Pendingフェイズ2です
マイクロタスク11です
マイクロタスク12です
Closeフェイズ1です
マイクロタスク13です
マイクロタスク14です
となっています。
1ラウンド目のPendingフェイズ内で更に発行した新たなTimerイベントがTimerキューに送りこまれているため、イベントループ2週目の始まりのタイミングでsetTimeoutのコールバックが実行されています。
また1ラウンド目では実行されなず持ち越しになっていた
fs.createReadStream
このラウンドでClose Callbackキューに入れられたハンドラも実行されていることが確認できます。
ということで、2ラウンド目は、
Timer --> Pending --> Close
Pollフェイズの確認
最後に6つのイベントフェイズの中で、理解のしにくさ故に、先程のコードからわざと除外していた、
Idle/Prepare
Poll
まず、
Idle/Prepare
Idle/Prepare
Poll
Poll
ネットワーク接続やディスクアクセスなど、I/Oイベントに基づくコールバックのほとんどがこのフェイズで実行されます。
まずPollフェイズでは、ポーリングする時間を計算します。
このポーリング時間は、状況によって計算結果が変わるようです。
Nodejs側から要求されたI/Oイベントを各OS側のジョブキューに全てへ送り出します。
OSカーネルのシステムコールを呼び、計算された時間でポーリングを行い、カーネルから届くIOイベントを待ちます。
ポーリングで待っている間にI/O処理が完了したら、そのままPollキューに入ったコールバックが実行されます。
Pollフェイズも基本的にPollキューが空になるか、キュー数の上限に達するなどで次のCheckフェイズへ遷移します。
ただしとりわけ注意が必要なのが、他のフェイズとは違うPollフェイズの特殊ルールが存在します。
1. TimersキューかCheckキューが空でない(スケジューリングされている)場合、
Pollフェーズを一旦終了し、次のCheckフェーズへ進む
2. TimersキューとCheckキューが空になっている(スケジューリングされていない)場合、
Pollフェイズでキューが空になるまで待ち続ける
というようにPollフェイズは、TimersフェイズとCheckフェイズに常に気にかけながら動いている、「上と下が気になってしょうがない」フェイズと言えます。
先程の例の中で、故意に存在しないファイルを読み込ませエラーレスポンスを使うことで、Pollフェイズの状態に変化を与えていたところだけを使って、このPollフェイズの特殊ルールを確認してみましょう。
先程のサンプルコードに少し手を加えて、以下のようなコードでPollフェイズの挙動を調べてみます。
まずは先行するファイルI/O処理でエラーレスポンスが返るサンプルコードです。
import { fileURLToPath } from "node:url";
import fs from "node:fs";
const __filename = fileURLToPath(import.meta.url);
setTimeout(() => console.log('Timerフェイズ1です'));
setImmediate(() => console.log('Checkフェイズ1です'));
fs.readFile(`not-found.txt`, () => {
console.log('Pendingフェイズ1です');
setTimeout(() => console.log('Timerフェイズ2です'));
setImmediate(() => console.log('Checkフェイズ2です'));
});
fs.readFile(__filename, () => {
console.log('Pendingフェイズ2です');
setImmediate(() => console.log('Checkフェイズ3です'));
});
これを実行すると、
Timerフェイズ1です
Pendingフェイズ1です
Checkフェイズ1です
Checkフェイズ2です
Timerフェイズ2です
Pendingフェイズ2です
Checkフェイズ3です
比較で、ファイルI/O処理が両方とも正常に完了する場合をやってみます。
import { fileURLToPath } from "node:url";
import fs from "node:fs";
const __filename = fileURLToPath(import.meta.url);
setTimeout(() => console.log('Timerフェイズ1です'));
setImmediate(() => console.log('Checkフェイズ1です'));
fs.readFile(__filename, () => {
console.log('Pendingフェイズ1です');
setTimeout(() => console.log('Timerフェイズ2です'));
setImmediate(() => console.log('Checkフェイズ2です'));
});
fs.readFile(__filename, () => {
console.log('Pendingフェイズ2です');
setImmediate(() => console.log('Checkフェイズ3です'));
});
こちらを実行すると、
Timerフェイズ1です
Checkフェイズ1です
Pendingフェイズ1です
Pendingフェイズ2です
Checkフェイズ2です
Checkフェイズ3です
Timerフェイズ2です
となり、イベントループの実行順序がガラッと異なります。
なぜI/O処理のレスポンス結果が違うだけで、実行されるイベントループのラウンドが異なってしまうかを考慮するには、
Pollフェイズの特殊ルール
イベントループ開始時に2つの
fs.readFile
つまり、1つ目の
fs.readFile
setTimeout
ということで、イベントループの2ラウンド目は、遅延して発行されたタイマーのコールバックだけが「
Timerフェイズ2です
ところが、1つ目の
fs.readFile
すると微妙な時間差ですが、その内部で発行していた
setTimeout
2つ目の
fs.readFile
ということで、2つ目の
fs.readFile
...Pollフェイズはなかなかに複雑な仕組みですが、I/O処理の実行ラウンドを厳密に管理するようなNodejsプログラムを開発するケースはかなり稀だと思います。
さほどシビアに捉えず、Node側にタイミングを全てお任せする余裕を持たせたアプリケーション設計を心がけるほうが良いでしょう。
まとめ
今回は、Nodejsアプリケーション開発者ならじっくり知っておきたい、「イベントループ」の仕組みで、特に組込み向けのアプリケーションを作成するなら事前に知っておきたい内容を中心に深堀しておきました。
更にNode.jsの非同期処理の内部構造やベース技術まで詳しく知りたい方は、『
記事を書いた人
ナンデモ系エンジニア
主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。
カテゴリー