Node.jsイベントループ徹底解剖
Node.jsのシングルスレッドモデルの探求
Node.jsはイベント駆動と非同期I/Oのアプローチを採用し、シングルスレッドで高い並列性を持つJavaScriptランタイム環境を実現しています。シングルスレッドとは一度に一つのことしかできないことを意味するので、Node.jsはどのようにして一つのスレッドで高い並列性と非同期I/Oを実現しているのでしょうか?この記事ではこの問題を中心にNode.jsのシングルスレッドモデルを探求します。
高並列性の戦略
一般的に、高並列性の解決策はマルチスレッドモデルを提供することです。サーバは各クライアントリクエストに一つのスレッドを割り当て、同期I/Oを使用します。システムはスレッド切り替えを通じて同期I/O呼び出しの時間コストを補います。たとえば、Apacheはこの戦略を使用しています。I/O操作は通常時間がかかるため、このアプローチで高性能を達成するのは難しいです。ただし、これは非常にシンプルで、複雑なインタラクションロジックを実装できます。
実際、ほとんどのWebサーバー側では多くの計算を行わないのが現状です。リクエストを受け取った後、それらは他のサービス(データベースの読み取りなど)に渡され、その結果が返ってくるのを待ち、最後に結果をクライアントに送信します。したがって、Node.jsはこの状況を処理するためにシングルスレッドモデルを使用しています。入ってくる各リクエストにスレッドを割り当てる代わりに、メインスレッドを使ってすべてのリクエストを処理し、その後非同期にI/O操作を処理することで、スレッドの生成、破棄、およびスレッド間の切り替えのオーバーヘッドと複雑さを回避しています。
イベントループ
Node.jsはメインスレッド内にイベントキューを維持しています。リクエストを受け取ると、それはイベントとしてこのキューに追加され、その後他のリクエストの受信を続けます。メインスレッドがアイドル状態(新しいリクエストが入ってこない)になると、イベントキューをループして処理すべきイベントがあるかどうかをチェックし始めます。二つのケースがあります:非I/Oタスクの場合、メインスレッドはそれらを直接処理し、コールバック関数を通じて上位層に戻ります;I/Oタスクの場合、スレッドプールからスレッドを取り出してイベントを処理し、コールバック関数を指定してから、キュー内の他のイベントをループし続けます。
スレッド内のI/Oタスクが完了すると、指定されたコールバック関数が実行され、完了イベントがイベントキューの末尾に配置され、イベントループを待ちます。メインスレッドがこのイベントに再度ループすると、それを直接処理して上位層に戻します。このプロセスはイベントループと呼ばれ、その動作原理は下図の通りです:
この図はNode.jsの全体的な動作原理を示しています。左から右、上から下に向かって、Node.jsは4つの層に分かれています:アプリケーション層、V8エンジン層、Node API層、LIBUV層。
-
アプリケーション層: JavaScriptのインタラクション層です。一般的な例としては、
http
やfs
のようなNode.jsモジュールです。 - V8エンジン層: V8エンジンを使ってJavaScriptの構文を解析し、その後下位層のAPIとインタラクションします。
- Node API層: 上位層のモジュールにシステムコールを提供し、通常C言語で実装され、オペレーティングシステムとインタラクションします。
- LIBUV層: イベントループ、ファイル操作などを実現するクロスプラットフォームの下位層のカプセル化で、Node.jsが非同期性を実現するためのコア部分です。
LinuxプラットフォームでもWindowsプラットフォームでも、Node.jsは内部的にスレッドプールを使って非同期I/O操作を完了し、LIBUVが異なるプラットフォームの違いを統一的に呼び出します。したがって、Node.jsのシングルスレッドとは、JavaScriptがシングルスレッドで動作することを意味するだけで、Node.js全体がシングルスレッドであるという意味ではありません。
動作原理
Node.jsが非同期性を実現するコアはイベントにあります。つまり、すべてのタスクをイベントとして扱い、その後イベントループを通じて非同期の効果をシミュレートします。この事実をより具体的かつ明確に理解し受け入れるために、以下で擬似コードを使ってその動作原理を説明します。
1. イベントキューの定義
キューであるため、先入れ先出し(FIFO)のデータ構造です。JSの配列を使って以下のように記述します:
/**
* イベントキューの定義
* エンキュー: push()
* デキュー: shift()
* 空のキュー: length === 0
*/
let globalEventQueue = [];
配列を使ってキュー構造をシミュレートしています:配列の最初の要素がキューの先頭で、最後の要素がキューの末尾です。push()
はキューの末尾に要素を挿入し、shift()
はキューの先頭から要素を取り除きます。これにより、シンプルなイベントキューが実現されます。
2. リクエスト受信エントランスの定義
すべてのリクエストはインターセプトされ、処理関数に入ります。以下の通りです:
/**
* ユーザーリクエストの受信
* すべてのリクエストはこの関数に入ります
* パラメータ requestとresponseを渡します
*/
function processHttpRequest(request, response) {
// イベントオブジェクトを定義
let event = createEvent({
params: request.params, // リクエストパラメートを渡す
result: null, // リクエスト結果を格納
callback: function() {} // コールバック関数を指定
});
// イベントをキューの末尾に追加
globalEventQueue.push(event);
}
この関数は単純にユーザーのリクエストをイベントとしてパッケージし、キューに入れてから、他のリクエストの受信を続けます。
3. イベントループの定義
メインスレッドがアイドル状態になると、イベントキューをループし始めます。そのため、イベントキューをループする関数を定義する必要があります:
/**
* イベントループの本体、適切なときにメインスレッドが実行
* イベントキューをループする
* 非IOタスクを処理する
* IOタスクを処理する
* コールバックを実行し、上位層に戻る
*/
function eventLoop() {
// キューが空でない場合、ループを続ける
while (this.globalEventQueue.length > 0) {
// キューの先頭からイベントを取り出す
let event = this.globalEventQueue.shift();
// 時間のかかるタスクの場合
if (isIOTask(event)) {
// スレッドプールからスレッドを取り出す
let thread = getThreadFromThreadPool();
// そのスレッドにタスクを処理させる
thread.handleIOTask(event);
} else {
// 非時間のかかるタスクを処理した後、結果を直接返す
let result = handleEvent(event);
// 最後に、コールバック関数を通じてV8に戻り、その後V8がアプリケーションに戻る
event.callback.call(null, result);
}
}
}
メインスレッドはイベントキューを継続的に監視します。I/Oタスクの場合はスレッドプールに任せて処理し、非I/Oタスクの場合は自身で処理して戻ります。
4. I/Oタスクの処理
スレッドプールがタスクを受け取ると、それは直接I/O操作を処理します。たとえば、データベースの読み取りなどです:
/**
* IOタスクの処理
* 完了後、イベントをキューの末尾に追加
* スレッドを解放する
*/
function handleIOTask(event) {
// 現在のスレッド
let curThread = this;
// データベース操作
let optDatabase = function (params, callback) {
let result = readDataFromDb(params);
callback.call(null, result);
};
// IOタスクを実行
optDatabase(event.params, function (result) {
// 戻り結果をイベントオブジェクトに格納
event.result = result;
// IOが完了した後、もはや時間のかかるタスクではない
event.isIOTask = false;
// このイベントをキューの末尾に再追加
this.globalEventQueue.push(event);
// 現在のスレッドを解放
releaseThread(curThread);
});
}
I/Oタスクが完了すると、コールバックが実行され、リクエスト結果がイベントに格納され、イベントはキューに戻され、ループを待ちます。最後に、現在のスレッドが解放されます。メインスレッドがこのイベントに再度ループすると、それを直接処理します。
上記のプロセスをまとめると、Node.jsは一つのメインスレッドを使ってリクエストを受け取ります。リクエストを受け取った後、それを直接処理するのではなく、イベントキューに入れてから他のリクエストの受信を続けます。アイドル状態になると、イベントループを通じてこれらのイベントを処理することで、非同期の効果を実現しています。もちろん、I/Oタスクの場合、システムレベルのスレッドプールに依存して処理する必要があります。
したがって、簡単に言えば、Node.js自体はマルチスレッドプラットフォームですが、JavaScriptレベルでのタスク処理はシングルスレッドで行われます。
CPU集中型タスクは短所
ここまでで、Node.jsのシングルスレッドモデルについて簡単かつ明確な理解が得られるはずです。それはイベント駆動モデルを通じて高い並列性と非同期I/Oを実現しています。ただし、Node.jsが得意としないこともあります。
上述の通り、I/Oタスクの場合、Node.jsはそれをスレッドプールに任せて非同期処理し、これは効率的でシンプルです。したがって、Node.jsはI/O集中型タスクの処理に適しています。しかし、すべてのタスクがI/O集中型ではありません。CPU集中型タスク、つまりデータ暗号化・復号化(node.bcrypt.js
)、データ圧縮・解凍(node-tar
)のようにCPUの計算に依存する操作に遭遇すると、Node.jsはそれらを一つずつ処理します。前のタスクが完了していない場合、後続のタスクは待機するしかありません。以下の図の通りです:
イベントキューにおいて、もし前のCPU計算タスクが完了していなければ、後続のタスクはブロックされ、応答が遅くなります。もしオペレーティングシステムがシングルコアであれば、許容できる場合もあります。しかし、現在の多くのサーバーはマルチCPUまたはマルチコアであり、Node.jsには一つのイベントループしかなく、つまり一つのCPUコアしか占有しません。Node.jsがCPU集中型タスクで占有され、他のタスクがブロックされる場合、まだ空いているCPUコアがあり、リソースの無駄につながります。
だから、Node.jsはCPU集中型タスクには適していません。
アプリケーションシナリオ
- RESTful API:リクエストと応答には少量のテキストしか必要とせず、多くの論理処理は必要ありません。したがって、同時に数万の接続を処理することができます。
- チャットサービス:軽量で、トラフィックが多く、複雑な計算ロジックがありません。
Leapcell: The Next - Gen Serverless Platform for Web Hosting, Async Tasks, and Redis
最後に、Node.jsサービスを展開するのに最適なプラットフォームを紹介しましょう。それはLeapcellです。
1. マルチ言語サポート
- JavaScript、Python、Go、またはRustで開発できます。
2. 無制限のプロジェクトを無料でデプロイ
- 使用量に応じて支払います。リクエストがなければ、料金はかかりません。
3. 比類なきコスト効率
- 使った分だけ支払うペイグーグー方式で、アイドル時の料金はありません。
- 例えば、25ドルで平均応答時間60msの694万件のリクエストをサポートします。
4. 合理化された開発者体験
- 直感的なUIで簡単にセットアップできます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- リアルタイムのメトリクスとロギングで実行可能な洞察を得ることができます。
5. 簡単なスケーラビリティと高性能
- 自動スケーリングで高い並列性を簡単に処理できます。
- オペレーションオーバーヘッドはゼロ。開発に集中できます。
Leapcell Twitter: https://x.com/LeapcellHQ
Discussion