Node.js シングルスレッドモデルの探求
Wenhao Wang
Dev Intern · Leapcell

Node.js シングルスレッドモデルの探求
Node.jsは、イベント駆動型および非同期I/Oアプローチを採用し、シングルスレッドで高度な並行性を持つJavaScriptランタイム環境を実現しています。シングルスレッドであるということは、一度に1つのことしかできないということですが、Node.jsはどのようにして1つのスレッドだけで高い並行性と非同期I/Oを実現しているのでしょうか?この記事では、この疑問を中心に、Node.jsのシングルスレッドモデルを探求します。
高い並行性を実現する戦略
一般的に、高い並行性を実現するための解決策は、マルチスレッドモデルを提供することです。サーバーは、クライアントからのリクエストごとに1つのスレッドを割り当て、同期I/Oを使用します。システムは、スレッドの切り替えによって同期I/O呼び出しの時間コストを補います。例えば、Apacheはこの戦略を使用しています。I/O操作は通常、時間がかかるため、このアプローチでは高いパフォーマンスを達成することは困難です。しかし、非常にシンプルで、複雑なインタラクションロジックを実装することができます。
実際には、ほとんどのWebサーバーサイドは、それほど多くの計算を実行しません。リクエストを受信した後、リクエストを他のサービス(データベースの読み取りなど)に渡し、結果が返ってくるのを待って、最後に結果をクライアントに送信します。したがって、Node.jsは、この状況を処理するためにシングルスレッドモデルを使用します。受信したリクエストごとにスレッドを割り当てるのではなく、メインスレッドを使用してすべてのリクエストを処理し、I/O操作を非同期的に処理することで、スレッドの作成、破棄、およびスレッド間の切り替えのオーバーヘッドと複雑さを回避します。
イベントループ
Node.jsは、メインスレッドにイベントキューを保持しています。リクエストを受信すると、イベントとしてこのキューに追加され、他のリクエストの受信を続行します。メインスレッドがアイドル状態(リクエストが着信していない状態)になると、イベントキューをループして、処理するイベントがあるかどうかを確認し始めます。2つのケースがあります。非I/Oタスクの場合、メインスレッドはそれらを直接処理し、コールバック関数を介して上位レイヤーに戻ります。I/Oタスクの場合、スレッドプールからスレッドを取得してイベントを処理し、コールバック関数を指定して、キュー内の他のイベントのループを続行します。
スレッド内のI/Oタスクが完了すると、指定されたコールバック関数が実行され、完了したイベントがイベントキューの最後に追加され、イベントループを待ちます。メインスレッドがこのイベントを再度ループすると、直接処理して上位レイヤーに返します。このプロセスをイベントループと呼び、その動作原理を次の図に示します。
この図は、Node.jsの全体的な動作原理を示しています。左から右、上から下へ、Node.jsは、アプリケーション層、V8エンジン層、Node API層、LIBUV層の4つの層に分かれています。
- アプリケーション層: これは、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は1つのメインスレッドのみを使用してリクエストを受信することがわかります。リクエストを受信した後、直接処理するのではなく、イベントキューに入れてから、他のリクエストの受信を続行します。アイドル状態になると、イベントループを介してこれらのイベントを処理し、非同期効果を実現します。もちろん、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はそれらを1つずつ処理します。前のタスクが完了していない場合、後続のタスクは待機するしかありません。下の図に示すように:
イベントキューでは、前のCPU計算タスクが完了していない場合、後続のタスクがブロックされ、応答が遅くなります。オペレーティングシステムがシングルコアの場合、許容できる可能性があります。しかし、現在、ほとんどのサーバーはマルチCPUまたはマルチコアであり、Node.jsには1つのEventLoopしかないため、1つのCPUコアしか占有しません。Node.jsがCPU集中型タスクによって占有され、他のタスクがブロックされると、CPUコアがアイドル状態のままになり、リソースの浪費になります。
したがって、Node.jsはCPU集中型タスクには適していません。
アプリケーションシナリオ
- RESTful API: リクエストとレスポンスに必要なテキスト量は少なく、多くのロジック処理は必要ありません。したがって、数万の接続を同時に処理できます。
- チャットサービス: 軽量でトラフィックが多く、複雑な計算ロジックはありません。
Leapcell:Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォーム
最後に、Node.jsサービスのデプロイに最適なプラットフォームであるLeapcellをご紹介します。
1. 多言語サポート
- JavaScript、Python、Go、またはRustで開発します。
2. 無料で無制限のプロジェクトをデプロイ
- 使用量に対してのみ支払い—リクエストなし、料金なし。
3. 無敵のコスト効率
- アイドル料金なしの従量課金制。
- 例:25ドルで、平均応答時間60msで694万のリクエストをサポートします。
4. 合理化された開発者エクスペリエンス
-Effortlessセットアップのための直感的なUI。
- 完全に自動化されたCI / CDパイプラインとGitOps統合。
- 実用的な洞察のためのリアルタイムメトリックとロギング。
5. 簡単なスケーラビリティと高性能
- 高い並行性を簡単に処理するための自動スケーリング。
- 運用上のオーバーヘッドはゼロ—構築に集中するだけです。
Leapcell Twitter:https://x.com/LeapcellHQ