Node.jsでCPUバウンドな処理を書く際は注意!!
はじめに
エックスポイントワンでリードエンジニアをしているRenです。
本記事ではNode.jsのアーキテクチャを解説しながら、なぜCPUバウンドな処理を書く際は注意する必要があるかを紐解いていきます。
Node.jsとは
Node.jsは大量の同時接続をさばけるアプリケーションの構築を目的に設計されたJavaScript環境です。その根幹にあるのが「シングルスレッド」で動作する「イベントループ」です。
シングルスレッドとマルチスレッド
プログラムの中で命令を実行する最小の作業単位を「スレッド(Thread)」と呼びます。
スレッドには大きく分けてシングルスレッドとマルチスレッドの2種類があり、シングルスレッドでは複数の処理を順番に1つずつ実行します。
一方、マルチスレッドでは複数のスレッドを同時に動かすことで、並列処理が可能になります。
なおスレッドとは別に「プロセス」という処理単位もありますが本記事では割愛します。
マルチスレッドプログラミングの分野は奥が深いので気になる方はぜひ調べてみてください。
イベントループ
イベントループとは、シングルスレッドの環境で「非同期処理」をうまく管理するためのスケジューラーのようなしくみです。その名の通りループしています。
説明のためにイベントループのコードを簡単に書きました。
実際はもっと複雑ですが、ものすごくシンプルに書くとこんな感じです。
while (true) {
const task = queue.get();
if (task) {
task.run();
}
}
クライアントからリクエストが送られると、その処理はまずイベントループのキューに登録されます。
イベントループはキューからタスクを順次取り出してメインスレッド上で実行します。
タスク内で I/O(DBとの通信やファイル読み書きなど)が発生すると、そのタスクは別スレッドにオフロードされ並列実行されます。
メインスレッドでは次のタスクをキューから取り出し処理を続けます。
やがてI/O の処理が完了すると、その結果を受け取るコールバックがキューに戻され、イベントループによって再実行されます。
このループを高速に繰り返すことで、大量の同時接続を効率よくさばくことができるのです。
イベントループのデメリット
便利なイベントループですが、弱点もあります。それはブロッキングです。
ブロッキングとは、時間がかかる処理によってイベントループを止めてしまうことを言います。
時間がかかる処理は主に2つに分類できます。
- I/Oバウンドな処理
→ DBとの通信やファイル処理など - CPUバウンドな処理
→ 画像のエンコードやデコード、大規模な数値計算や
大きな JSON オブジェクトのパースや文字列化など
I/Oバウンドな処理は前述のとおりイベントループ内で次のタスクに処理を委譲するためブロッキングは発生しません(※)
しかしCPUバウンドな処理が含まれるとイベントループ内でブロッキングが発生します。
ブロッキングされてる間、他のタスクは一切実行されなくなります。
実際に動作確認
Node.jsをコンテナで起動し、ブロッキングを実際に確認してみます。
Node.jsのフレームワークであるexpressを用いて簡単に実装しました。
import express from 'express';
const app = express();
const port = 8080;
app.get('/quick', (req, res) => {
console.log('START QUICK');
console.log('END QUICK');
res.send({});
});
app.get('/io-bound', async(req, res) => {
console.log('START IO-BOUND');
await new Promise(resolve => setTimeout(resolve, 5000));
console.log('END IO-BOUND');
res.send({});
});
app.get('/cpu-bound', (req, res) => {
console.log('START CPU-BOUND');
for (let i = 0; i < 10000000000; i++) {
Math.sqrt(i);
}
console.log('END CPU-BOUND');
res.send({});
});
app.listen(port, () => {
console.log('server is running');
});
/quick
は実行するとすぐ処理を終えますが、/io-bound
は5秒、/cpu-bound
は私の環境だと約10秒かかります。
まずは、/io-bound
を実行したあとすぐに /quick
を実行してみましょう。
Node.jsのログは以下のようになりました。
sample-nodejs-app | [2025/6/17 13:43:54] server is running
sample-nodejs-app | [2025/6/17 13:44:04] START IO-BOUND
sample-nodejs-app | [2025/6/17 13:44:05] START QUICK
sample-nodejs-app | [2025/6/17 13:44:05] END QUICK
sample-nodejs-app | [2025/6/17 13:44:09] END IO-BOUND
同時に処理ができていますね。
次に /cpu-bound
を実行したあとすぐに /quick
を実行してみましょう。
sample-nodejs-app | [2025/6/17 13:45:33] server is running
sample-nodejs-app | [2025/6/17 13:45:37] START CPU-BOUND
sample-nodejs-app | [2025/6/17 13:45:46] END CPU-BOUND
sample-nodejs-app | [2025/6/17 13:45:46] START QUICK
sample-nodejs-app | [2025/6/17 13:45:46] END QUICK
/cpu-bound
でブロッキングが発生しその間、他のリクエストをさばくことができていません。
非常にやばいですね。
ブロッキングの対策
CPU バウンドな処理は、可能であればまずクライアント側にオフロードし、サーバーには最小限のリクエストのみを送るように設計しましょう。
どうしてもサーバー側で重い計算を行う必要がある場合は、以下のようにメインスレッドから切り離して実行してください。
- worker_threads モジュールで別スレッドで実行する
- child_process や Cluster を使って別プロセスで実行する
おわりに
弊社ではフロントエンドからバックエンドまで一貫してTypeScriptを採用し、言語の垣根を取り払うことで開発効率と生産性を向上させています。
同様の構成を採用する企業も増えていますが、そのメリットを最大限に引き出すには、Node.jsのアーキテクチャを正しく理解したうえで実装を行うことが不可欠です。
本記事が皆様のUniversal JavaScript開発をより快適に進める一助となれば幸いです。
Discussion