Node.js の非同期処理の仕組み
こんにちは。株式会社ラキールで DX 基盤開発を行う LaKeel DX Engine Group に所属する砂田です。
普段は、UI コンポーネントを一元管理し、それらを自由に組み合わせてアプリケーションを構築できる、フロントエンドの開発基盤 「LaKeel Visual Mosaic」という製品を担当しています。
社内ウェビナー (ウェブセミナー) で発表した Node.js の非同期処理について、その内容を一部公開します。
Node.js の導入と利用
LaKeel DX では Node.js を利用して Web アプリケーションを構築しています。ユーザ数の増加に伴いシステムが適切に対応できること、つまりスケーラビリティの確保は、情報システムにおいて求められる重要な要素の1つです。
Node.js は非同期型のイベント駆動の JavaScript 実行環境であり、スケーラブルな Web アプリケーションを構築するために設計されています。
イベントループモデルや非同期 I/O、マルチプロセス、ワーカースレッドなど、効率的な Web アプリケーション開発のための要点を紹介していきます。
イベントループモデル
Node.js はシングルスレッドのイベントループモデルを採用しており、1 つのメインスレッドでイベントループを実行します。これは、処理の直列化によって効率的にタスクを処理するためです。しかし、非同期的な処理を実現するために、I/O 操作や非同期処理を別のスレッドで実行することがあります。これらのスレッドは、裏側で動作する補助的なスレッドであり、メインスレッドがブロックされることなく処理を続けることができます。
これにより、多くの HTTP リクエストを効率的に処理できるようになっています。Apache のようなマルチプロセス/マルチスレッドのモデルとは異なり、Node.js はシングルスレッドであるため、スレッドの切り替えによるオーバーヘッドが発生せず、メモリ消費量を抑えることができます。
イベントループでは、実行すべきタスクがなくなると、次に実行するべきタスクがあるかどうかを確認して、あれば実行します。このとき、他のスレッドにタスクを委任することがありますが、これらのスレッドはメインスレッドとは別々のコンテキストで実行されます。
例えば、HTTP リクエストがある場合、イベントループはそのリクエストを非同期で処理し、その処理が完了するまでメインスレッドの処理をブロックしないため、他のリクエストの処理を継続することができます。このような仕組みにより、Node.js は非常に効率的で高速な処理を実現することができています。
非同期 I/O
Node.js の非同期 API は、データベース接続や HTTP 通信処理を行う際に、非同期 I/O(UNIX の I/O モデルの一種)を利用します。ブロッキングされることなくタスクを実行できるため、効率的なリソースの利用が可能となります。また、これはスケーラビリティの向上にも寄与し、アプリケーションが大量の HTTP リクエストに効率的に対応できるようになります。
非同期 I/O では、I/O 操作を別のスレッドで実行するため、メインスレッドはブロックされることなく、他のタスクを実行することができます。I/O 操作が完了したら、Node.js はコールバック関数を呼び出し、I/O 操作の結果を返します。このコールバック関数は、非同期 I/O 操作が完了した際に呼び出されるため、処理を中断することなく、I/O 操作の結果を処理することができます。
Node.js の構造
Node.js は C++ 製の libuv というクロスプラットフォームライブラリを採用しています。Node.js の非同期 I/O は libuv で実現されており、Node.js Bindings という機能を利用して libuv を実行しています。非同期 I/O は OS ごとに実現方法が異なりますが、libuv は OS ごとの非同期 I/O の差異を吸収します。つまり、Node.js の非同期 I/O は、libuv を利用して OS レイヤで実行されています。
libuv の構造
libuv は内部的にスレッドを持っています。fs や crypt などの並行処理は、libuv の内部スレッドを利用して行われます。環境変数UV_THREADPOOL_SIZE(デフォルト: 4)を変更することで、スレッド数を増やすことができます(最大 128)。
Node.js の非同期 API は、OS レイヤの非同期 I/O (下図 epoll, kqueue)と libuv のスレッド (下図 Thread Pool) を使い分けています。
Node.js の 非同期 API の使い分け
例えば、https モジュールは、OS レイヤ(epoll, kqueueなど)の非同期 I/O を使用しているため、同時に多くのタスクを処理できます。一方、crypto モジュールは、libuv の内部スレッド(デフォルト:4)を使用しているため、同時に 4 つのタスクしか処理できないことがわかります。
これにより、アプリケーションのチューニングや最適化において、異なるアプローチが必要になることが分かります。
マルチプロセス
Node.js は 1 つの CPU コアしか使用しません。サーバーに 4 コアの CPU が搭載されていても、3 つの CPU コアが使用されないことになります。Node.js では、CPU を有効活用するために、マルチプロセス化の手段が提供されています。マスタープロセスがリクエストを受け付けて、ワーカープロセスに処理を振り分けます。
活用シーン
マルチプロセスは、複数の CPU コアを効率的に活用することができます。これにより、ユーザーからのリクエストを同時に処理することができるため、大量のアクセスがある Web サイトやアプリケーションに適しています。また、マルチプロセスでは、1 つのプロセスがクラッシュしても他のプロセスに影響が及ばず、システム全体の信頼性と安定性が保たれやすくなります。
cluster モジュール
cluster モジュールを使用して、マルチプロセスで起動が可能です。LaKeel DX では Kubernetes を利用していますが、Kubernetes ノード(サーバ)に対して、そのノードの CPU コア数に応じた Node.js のワーカープロセスが立ち上がります。
以下は、cluster モジュールを利用した実装例です。
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;の
if (cluster.isMaster) {
// マスタープロセスの場合、CPU コア数分、ワーカープロセスを起動する
console.log(`Master ${process.pid} is running`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`worker ${worker.process.pid} dead`);
});
} else {
// ワーカープロセスの場合、Web サーバーを起動する
http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(8000);
console.log(`Worker ${process.pid} started`);
}
LaKeel SDK の対応
ラキールが提供する、サーバサイド開発向けの SDK は、マルチプロセスに対応しており、環境変数を利用して Node.js のプロセス数を制御できる仕組みになっています。これにより、負荷が高いサービスに対して多くのプロセスを割り当てることができます。
SDK を利用することで、アプリケーション開発者はマルチプロセスの制御ロジックを実装することなく、システム全体のパフォーマンスを向上させることができます。この機能は、開発者が簡単かつ効果的にマルチプロセス環境を活用できるように設計されています。
ワーカースレッド
Node.js は、イベントループがメインスレッドで実行されるため、「シングルスレッド」と表現されますが、ワーカースレッドを使うことでマルチスレッドの処理が可能です。ワーカースレッドは、CPU 負荷の高い処理を別スレッドに分散させ、効率的に処理するための仕組みです。メインスレッド上で重い処理を行うと、他の処理が止まってしまう欠点がありますが、ワーカースレッドを使用することでこの問題を回避できます。
Node.js は、シングルスレッドであるため、多数のリクエストを同時に処理する際には、非同期処理を利用することが必要です。しかし、Node.js では CPU 負荷の高い処理を非同期で処理することはできず、処理速度が遅くなってしまいます。そこで、ワーカースレッドを使用することで、CPU 負荷の高い処理を別スレッドに分散させることができ、処理速度を向上させることができます。
ワーカースレッドは補助的なスレッドであり、メインスレッドとは別のスレッドで動作します。ワーカースレッドを利用することで、Node.jsは「シングルプロセス+マルチスレッド」の方式を採用し、高速かつ効率的な処理を実現できます。
活用シーン
ワーカースレッドは、例えば画像や動画のエンコード・デコード、大規模なデータの解析や変換、複雑なアルゴリズムの計算など、CPU インテンシブなタスクに特に適しています。これらのタスクをワーカースレッドで実行することで、メインスレッドの処理をブロックせずに、効率的にリソースを利用することができます。
具体的には、ユーザーが画像をアップロードすると、ワーカースレッドがそれを受け取り、エンコード・デコードやリサイズ処理などを実行します。ワーカースレッドはメインスレッドとは別に処理を行うため、メインスレッドはユーザーの操作に対して迅速に応答することができます。
ワーカースレッドが処理を完了すると、その結果をメインスレッドに返すことができます。例えば、リサイズ処理を行った場合は、その結果としてリサイズされた画像を返すことができます。このように、ワーカースレッドが処理結果を返すことで、メインスレッドはその結果をユーザーに返し、ユーザーはアップロードした画像をその場で表示することができます。
worker_threads モジュール
ワーカースレッドは、worker_threads モジュールを利用します。libuv の内部スレッド(デフォルト: 4)を使用します。以下は実装例です。
// main.js
// ワーカースレッドを起動する
const {Worker} = require('worker_threads')
const worker = new Worker('./worker.js', {
workerData: 'message from main.js!',
})
worker.on('message', message => {
console.log('Main thread received message: %o', message)
})
// worker.js
// ワーカースレッドで実行する処理
const {workerData} = require('worker_threads')
console.log(workerData)
workerData.postMessage('Hello!')
現時点では、LaKeel SDK はワーカースレッドに対応していません。今後の開発において、ワーカースレッドの対応を検討し、よりパフォーマンスや効率性を向上させるためのアップデートを行っていく予定です!!
おわりに
Node.js の特性上、イベントループを実行するメインスレッドの処理がブロックされると全体の処理が止まってしまうという問題があります。このため、非同期処理をうまく実装しなければならず、初学者にとっては難しいことがあります。
例えば、ファイルを読み込んでその内容をクライアントに返す処理を実装する場合、同期的に処理を行うとファイルの読み込みが完了するまで待たなければならず、その間に他のリクエストの処理ができなくなってしまいます。しかし、非同期処理を行えば、ファイルの読み込み中に他のリクエストを処理することができます。
このような非同期処理をうまく実装するために、コールバック関数やPromise などの概念を理解する必要があります。開発を始めて間もないときは難しいことがありますが、Node.js の非同期処理を理解することで、高速で効率的なアプリケーションの開発が可能になります。この非同期処理の実装方法については、別の機会で触れていこうと思います。
今回ご紹介した内容が少しでもお役に立てればと思います。最後まで読んでくださり、ありがとうございました!!