Node.js はなぜイベントループを使うのか?
結論
Node.js は並行処理をマルチスレッドではなく、イベントループで処理します。イベントループはシングルスレッドで動作するため、マルチスレッドによって引き起こされるパフォーマンスやアプリケーションの安全性に関する問題を回避していると私は理解しています。これこそが、タイトルの「Node.js はなぜイベントループを使うのか?」の回答なのですが、もう少し紐解いて書いていこうと思います。
シングルスレッドとマルチスレッド
Node.js の特徴の内、一番最初に思い浮かぶのは、並行処理を行う上でマルチスレッドを使用していないことなのですが、そもそものシングルスレッドとマルチスレッドの理解の整理をします。シングルスレッドは、以下のようなイメージで処理Aと処理Bを一つのスレッドで処理します。一方、マルチスレッドでの処理の場合は、処理Aと処理Bを異なるスレッドで処理します。
# シングルスレッド
(スレッド1)処理A : →→ →→→ →→→
(スレッド1)処理B : →→→ →→→→→→
# マルチスレッド
(スレッド1)処理A : →→ →→→ →→→→
(スレッド2)処理B : →→ →→→ →→→→
webアプリケーションでは、ネットワークリクエストやファイル読み書きなどが多くのケースで発生しますが、これらの処理には通常時間がかかります。例えば、外部APIへのリクエストの場合、レスポンスが返るまで待機する必要があります。このような場合にマルチスレッドを扱うプログラミング言語では、待ち時間の間は、スレッドを切り替えて他の処理を実行します。このスレッドの切り替えによって(=マルチスレッドでの処理)、発生する深刻な問題がいくつかあります。
マルチスレッドの問題点
1つの目の問題点はC10K問題です。
以前のApacheでは、1つのリクエストに対して1つのプロセスを割り当てて処理をする方式が一般的でした(今では他にもいくつか処理方式があります)。
https://knowledge.sakura.ad.jp/24148/
Apache をウェブサーバーとして使用している場合、1つのリクエストに対して1つのプロセス(=1以上のスレッド)が割り当てられます。1つのスレッドに割り当てられるメモリ領域が仮に2MBだとして、10,000(10K)のリクエストをウェブサーバーが同時に処理する場合、2GB(2MB*10,000)のメモリが必要になります。1つ1つのスレッドで使用するメモリに余裕があったとしても、ウェブサーバーとしては2GBのメモリが必要ということになります。このような場合にリクエスト数の上限=スレッド数となります。また、マルチスレッドの場合は、この状態でさらにスレッドの切り替え(コンテキストスイッチ)があるため、オーバーヘッドは大きくなります。
2つ目の問題点にスレッドセーフがあります。複数のスレッドが同時に共有されたデータやリソースにアクセスする際に、それらのスレッドが互いに干渉することなく、安全に操作を行うことを指しており、例えば、num
という変数が複数のスレッドからアクセスされた場合に、期待している通りのカウントアップができない可能性があります。
const num = num + 1
イベントループによる解決
イベントループはシングルスレッドで動作するため、スレッドを多く使用するがゆえに起きるC10K問題は起きません。また、スレッドセーフも同様に、シングルスレッドで処理をするため、他のスレッドとデータを共有する、ということはありません。
元々、イベントループは、ブラウザで実装されていた仕組みで、それをバックエンドの言語に持ち込んだのが Node.js であるため、シングルスレッドで並行処理を実現する仕組みとして元から存在はしていました。
改めてタイトルの「Node.js はなぜイベントループを使うのか?」に触れると、シングルスレッドで並行処理を実現する仕組みとして存在していたイベントループを採用することで、マルチスレッドで発生する問題を回避した、ということになると思います。
タイトルの疑問は回収しきった気がするのですが、ここで終わると薄味な記事になるので、イベントループの仕組みとイベントループの問題点に触れようと思います。
イベントループとは?
マルチスレッドは待ち時間の間、スレッドを切り替えますが、イベントループがどのように処理しているか...なのですが、Node.js では待ち時間が発生するタスクの実行後にキューに積み(厳密にはタスク処理完了後にキューに積まれる)、待ち時間の間は、他のタスクを進めます。イベントループはキューに積まれた実行すべきタスクをグルグルと監視しており、処理対象となったタスクを1つづつシングルスレッドで実行していくので、待ち時間が終了してキューに積まれたタスクは、どこかで拾われて、続きの処理が継続されます。
詳細のイベントループの説明については以下の記事をご参照ください。
以上が簡易なイベントループの説明になりますが、イベントループが完璧なソリューションなのか...と言われると当然そうではなく、ブロッキングという問題が発生しやすくなります。
ブロッキングのヤバさ
Because Node.js handles many clients with few threads, if a thread blocks handling one client's request, then pending client requests may not get a turn until the thread finishes its callback or task.
https://nodejs.org/en/guides/dont-block-the-event-loop#what-does-this-mean-for-application-design
ブロッキングとは、1つのクライアントの処理がスレッドをブロックをすることであり、ブロッキングが発生すると、処理待ちのクライアントに順番が回らない可能性があります。
以下は、ブロッキングが発生する Node.js のシンプルなサーバーで、各エンドポイントは以下のような処理時間になるように書いています。
/constant-time
の処理時間は常に一定です。
/countToN
の処理時間は n
の大きくなれば、大きくなる分だけ長くなります。
/constant-time
の処理時間は n
の大きさの2乗の長さになります。
const http = require("node:http");
const hostname = "127.0.0.1";
const port = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain");
// 処理時間は一定
if (req.url === "/constant-time") {
console.log("constant-time");
return res.end("constant-time\n");
}
// 処理時間はO(n)
else if (req.url === "/countToN") {
let n = 10;
for (let i = 0; i < n; i++) {
console.log(`Iter ${i}`);
}
console.log("countToN");
return res.end("countToN\n");
}
// 処理時間はO(n^2)
else if (req.url === "/countToN2") {
let n = 1000;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
console.log(`Iter ${i}`);
}
}
console.log("countToN2");
return res.end("countToN2\n");
}
return res.end("Hello, World!\n");
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
通常、/constant-time
からのレスポンスはとても早く、私のローカルだと23ミリ秒で返ってきます。しかし、/countToN2
からのレスポンスはとても遅く、私のローカルだと13秒で返ってきます。
/countToN2
にアクセスをして、直後に /constant-time
にアクセスすると、レスポンスはとても遅くなります(普通なら /constant-time
は23ミリ秒でレスポンスが返ってくるはず...)。
これは、Node.js の処理が /countToN2
によってブロッキングされており、/constant-time
の処理に待ち時間が発生しているためです。
Node.js ではこのようなブロッキングを避けて実装する必要があります。
/countToN2
のように処理時間が爆発的に伸びるような実装は必ず避けなければいけないですし、/countToN
のようなエンドポイントも他の処理をブロッキングする可能性は十分にあります。
よくあるブロッキングの例
例えば、脆弱な正規表現がこれにあたります。入力された文字列に対して、指数関数的な回数の走査が必要だと、実装者が意図していないとは言え、ブロッキングが発生します。
app.get('/redos-me', (req, res) => {
let filePath = req.query.filePath;
// REDOS
if (filePath.match(/(\/.+)+$/)) {
console.log('valid path');
} else {
console.log('invalid path');
}
res.sendStatus(200);
});
意図せず、ブロッキングが発生する例以外にも、暗号化/圧縮などは大量の計算が必要なので、ブロッキングが発生する可能性があります。
ブロッキングを避けるために
一つのタスクを細切れ(例えば、I/Oを細かい単位で実行する)にして、タスクをキューに積み上げることで、ブロッキングを避けることができます。これをパーティショニングと言います。
先のコード例の /countToN2
を書き直して、パーティショニングさせた /partition
というエンドポイントを書きました。Node.js でタスクを細切れにするには、例えば、setTimeout
をする方法があり、今回は、その方法を使いました。
const http = require("node:http");
const hostname = "127.0.0.1";
const port = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain");
// 処理時間は一定
if (req.url === "/constant-time") {
console.log("constant-time");
return res.end("constant-time\n");
}
if (req.url === "/countToN2") {
let n = 1000;
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
console.log(`Iter ${i}`);
}
}
console.log("countToN2");
return res.end("countToN2\n");
}
if (req.url === "/partition") {
let n = 1000;
const runPartition = (start_i, start_j) => {
let i = start_i;
let j = start_j;
setTimeout(function () {
console.log(`Iter ${i} ${j}`);
j++;
if (j < n) {
runPartition(i, j);
} else {
i++;
if (i < n) {
runPartition(i, 0);
}
}
}, 0);
};
runPartition(0, 0);
return res.end("countToN2\n");
}
return res.end("Hello, World!\n");
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
/partition
からのレスポンスはすぐに返ってきますし、/partition
の処理を実行している間も /constant-time
にアクセスしてもレスポンスはすぐに返ってきます。ブロッキングの発生を防げています。
ただ、より複雑なことを行う必要がある場合、パーティショニングでの書き直しは微妙な選択です。コードが読みづらくなる、というのもありますが、パーティショニングではイベントループのみが使用されるので、複数のコアが利用可能になってもメリットが得られないためです。複雑なタスクの場合は、ワーカープールという仕組みを利用するのが良さそうです。
まとめ
Node.js は何でイベントループを使うのか?という問いに対しては、マルチスレッドによる処理方式で問題となっていたものを解決できるためだと思います。しかし、イベントループはブロッキングが発生しやすいので、ブロッキングを避けながら実装する必要がある、という話でした。
Discussion