イベントループ(またはワーカープール)をブロックしないでください
このガイドを読むべきか?
簡単なコマンドラインスクリプトよりも複雑なものを記述している場合、これを読むことで、より高性能で安全性の高いアプリケーションを作成するのに役立ちます。
このドキュメントは Node.js サーバーを念頭に置いて記述されていますが、概念は複雑な Node.js アプリケーションにも適用されます。OS 固有の詳細が異なる場合、このドキュメントは Linux 中心です。
要約
Node.js はイベントループ(初期化とコールバック)で JavaScript コードを実行し、ファイル I/O などの高コストなタスクを処理するためのワーカープールを提供します。Node.js は、Apache などのより重量級のアプローチよりも優れたスケーラビリティを示す場合があります。Node.js のスケーラビリティの秘密は、多数のクライアントを処理するために少数のスレッドを使用することです。Node.js がより少ないスレッドで済む場合、システムの処理時間とメモリを、スレッドの空間的および時間的オーバーヘッド(メモリ、コンテキストスイッチング)に支払うよりも、クライアントの作業に多く費やすことができます。しかし、Node.js は少数のスレッドしか持たないため、アプリケーションを賢く使用するように構成する必要があります。
Node.js サーバーの速度を維持するための良い経験則を以下に示します。Node.js は、任意の時点で各クライアントに関連付けられた作業が「小さい」場合、高速です。
これは、イベントループのコールバックとワーカープールのタスクに適用されます。
なぜイベントループとワーカープールをブロックすべきではないのか?
Node.js は、多数のクライアントを処理するために少数のスレッドを使用します。Node.js には、1 つのイベントループ(メインループ、メインスレッド、イベントスレッドなど)と、ワーカープール(スレッドプール)内のk
個のワーカーのプールという 2 種類のスレッドがあります。
スレッドがコールバック(イベントループ)またはタスク(ワーカー)の実行に長時間かかっている場合、「ブロックされている」と言います。スレッドが 1 つのクライアントのためにブロックされている間は、他のクライアントからの要求を処理できません。これにより、イベントループとワーカープールのどちらもブロックしないことの 2 つの動機が提供されます。
- パフォーマンス:どちらの種類のスレッドでも定期的に重量級のアクティビティを実行すると、サーバーのスループット(要求/秒)が低下します。
- セキュリティ:特定の入力に対してスレッドのいずれかがブロックする可能性がある場合、悪意のあるクライアントがこの「悪意のある入力」を送信し、スレッドをブロックして他のクライアントで動作できなくすることができます。これはサービス拒否攻撃になります。
Node の簡単なレビュー
Node.js はイベント駆動アーキテクチャを使用します。オーケストレーションにはイベントループ、高負荷タスクにはワーカープールを使用します。
イベントループで実行されるコード
Node.js アプリケーションは開始時にまず初期化フェーズを完了し、モジュールを require
し、イベントのコールバックを登録します。その後、Node.js アプリケーションはイベントループに入り、着信するクライアントリクエストに応答して適切なコールバックを実行します。このコールバックは同期的に実行され、完了後に処理を継続するための非同期リクエストを登録する場合があります。これらの非同期リクエストのコールバックもイベントループで実行されます。
イベントループは、そのコールバックによって行われた非ブロッキングの非同期リクエスト(例:ネットワーク I/O)も実行します。
要約すると、イベントループはイベントに登録された JavaScript コールバックを実行し、ネットワーク I/O などの非ブロッキング非同期リクエストの実行も担当します。
ワーカープールで実行されるコード
Node.js のワーカープールは libuv (docs) で実装されており、一般的なタスク送信 API を公開しています。
Node.js はワーカープールを使用して「高負荷」タスクを処理します。これには、オペレーティングシステムが非ブロッキングバージョンを提供しない I/O と、特に CPU 負荷の高いタスクが含まれます。
ワーカープールを使用する Node.js モジュール API は次のとおりです。
- I/O 集約型
- DNS:
dns.lookup()
、dns.lookupService()
。 - [ファイルシステム][/api/fs]:
fs.FSWatcher()
と明示的に同期的なものを除くすべてのファイルシステム API は、libuv のスレッドプールを使用します。
- DNS:
- CPU 集約型
多くの Node.js アプリケーションでは、これらの API がワーカープールのタスクの唯一のソースです。C++ アドオン を使用するアプリケーションとモジュールは、他のタスクをワーカープールに送信できます。
完全性を期すために、イベントループ上のコールバックからこれらの API のいずれかを呼び出すと、イベントループはその API の Node.js C++バインディングに入り、ワーカープールにタスクを送信する際に若干のセットアップコストが発生することに注意してください。これらのコストはタスクの全体的なコストに比べて無視できるため、イベントループはタスクをオフロードします。これらのタスクの 1 つをワーカープールに送信する場合、Node.js は Node.js C++バインディング内の対応する C++関数へのポインタを提供します。
Node.js は次にどのコードを実行するかをどのように決定するのか?
抽象的には、イベントループとワーカープールはそれぞれ、保留中のイベントと保留中のタスクのためのキューを保持しています。
実際には、イベントループはキューを保持していません。代わりに、epoll(Linux)、kqueue(OSX)、イベントポート(Solaris)、またはIOCP(Windows)のようなメカニズムを使用して、オペレーティングシステムに監視させるファイル記述子の集合を持っています。これらのファイル記述子は、ネットワークソケット、監視対象のファイルなどに相当します。オペレーティングシステムがこれらのファイル記述子のいずれかが準備完了であると通知すると、イベントループはそのファイル記述子を適切なイベントに変換し、そのイベントに関連付けられたコールバックを呼び出します。このプロセスについては、こちらで詳しく知ることができます。
対照的に、ワーカープールは、エントリが処理されるタスクである実際のキューを使用します。ワーカーはこのキューからタスクを取り出し、処理し、完了すると、「少なくとも 1 つのタスクが完了しました」というイベントをイベントループに送ります。
アプリケーション設計にとってこれは何を意味するのか?
Apache のような 1 クライアントにつき 1 スレッドのシステムでは、保留中の各クライアントに独自の thread が割り当てられます。あるクライアントを処理しているスレッドがブロックした場合、オペレーティングシステムはそれを中断し、別のクライアントに処理を回します。このように、オペレーティングシステムは、少量の作業を必要とするクライアントが、より多くの作業を必要とするクライアントによってペナルティを受けないようにします。
Node.js は少数のスレッドで多くのクライアントを処理するため、あるクライアントのリクエストを処理しているスレッドがブロックした場合、スレッドがコールバックまたはタスクを終了するまで、保留中のクライアントリクエストは処理を回されません。したがって、クライアントの公平な処理は、アプリケーションの責任となります。つまり、任意のコールバックやタスクで、クライアントのためにあまりにも多くの作業を行わないようにする必要があります。
これは Node.js がスケールしやすい理由の一部ですが、公平なスケジューリングを確保する責任もあなたにあることを意味します。次のセクションでは、イベントループとワーカープールの公平なスケジューリングを確保する方法について説明します。
イベントループをブロックしない
イベントループは新しいクライアント接続をそれぞれ検知し、レスポンス生成をオーケストレートします。すべての着信リクエストと発信レスポンスはイベントループを通過します。つまり、イベントループがどこかの時点で時間をかけすぎると、現在および新規のクライアントは順番に処理されなくなります。
イベントループをブロックしないようにする必要があります。言い換えれば、各 JavaScript コールバックは迅速に完了する必要があります。これはもちろん、await
、Promise.then
などにも適用されます。
これを保証する良い方法は、コールバックの計算量について考えることです。コールバックが引数に関係なく一定数のステップで完了する場合、すべての保留中のクライアントに公平な順番を与えます。コールバックが引数に応じて異なる数のステップを取る場合、引数の長さを考慮する必要があります。
例 1:定数時間コールバック。
app.get('/constant-time', (req, res) => {
res.sendStatus(200)
})
例 2:O(n)
コールバック。このコールバックは小さなn
に対しては迅速に実行されますが、大きなn
に対しては遅くなります。
app.get('/countToN', (req, res) => {
let n = req.query.n
// 他の人に順番を与える前のn回の反復
for (let i = 0; i < n; i++) {
console.log(`Iter ${i}`)
}
res.sendStatus(200)
})
例 3:O(n^2)
コールバック。このコールバックは小さなn
に対しては迅速に実行されますが、大きなn
に対しては、前のO(n)
の例よりもはるかに遅くなります。
app.get('/countToN2', (req, res) => {
let n = req.query.n
// 他の人に順番を与える前のn^2回の反復
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
console.log(`Iter ${i}.${j}`)
}
}
res.sendStatus(200)
})
どの程度注意する必要があるか?
Node.js は、多くの一般的な操作に対して非常に高速な Google V8 エンジンを JavaScript で使用しています。この規則の例外は、下記で説明する正規表現と JSON 操作です。
ただし、複雑なタスクの場合、入力に上限を設定し、長すぎる入力を拒否することを検討する必要があります。そうすることで、コールバックの複雑さが大きくても、入力に上限を設定することで、コールバックが最長の許容入力で最悪のケースの時間以上かかることがなくなります。次に、このコールバックの最悪のケースのコストを評価し、その実行時間がコンテキストで許容できるかどうかを判断できます。
イベントループのブロック:REDOS
イベントループを悲惨なまでにブロックする一般的な方法の 1 つは、「脆弱な」正規表現を使用することです。
脆弱な正規表現の回避
正規表現(regexp)は、入力文字列をパターンと照合します。通常、regexp の照合は、入力文字列を 1 回通過するだけで済むものと考えます --- O(n)
時間(n
は入力文字列の長さ)。多くの場合、実際には 1 回通過するだけで済みます。残念ながら、場合によっては、regexp の照合に指数関数的な回数だけ入力文字列を通過する必要がある場合があります --- O(2^n)
時間。指数関数的な回数の通過とは、エンジンが照合を決定するために x 回の通過を必要とする場合、入力文字列にさらに 1 文字追加するだけで2*x
回の通過が必要になることを意味します。通過回数は必要な時間に線形に関係するため、この評価の影響はイベントループのブロックとなります。
脆弱な正規表現とは、正規表現エンジンが指数関数的な時間を要する可能性があり、「悪意のある入力」に対してREDOSを招く可能性のあるものです。正規表現パターンが脆弱であるかどうか(つまり、regexp エンジンが指数関数的な時間を要する可能性があるかどうか)は、実際には答えにくい問題であり、Perl、Python、Ruby、Java、JavaScript などを使用しているかによって異なりますが、これらの言語すべてに適用される経験則をいくつか示します。
(a+)*
のような入れ子になった量子化子は避けてください。V8 の regexp エンジンはこれらのいくつかを迅速に処理できますが、他のものは脆弱です。(a|a)*
のような、重複する句を含む OR は避けてください。これも、高速な場合があります。(a.*) \1
のようなバックリファレンスを使用しないでください。どの regexp エンジンも、これらを線形時間で評価できると保証できません。- 単純な文字列の一致を行う場合は、
indexOf
またはローカルの同等物を使いましょう。より安価で、O(n)
を超えることはありません。
正規表現が脆弱かどうかが不明な場合は、Node.js は、脆弱な regexp と長い入力文字列であっても、通常は一致を報告することに問題はありません。指数関数的な動作は、不一致がある場合にトリガーされますが、Node.js は入力文字列の多くのパスを試すまで確実ではありません。
REDOS の例
サーバーを REDOS にさらす脆弱な正規表現の例を以下に示します。
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)
})
この例で脆弱な正規表現は、Linux で有効なパスをチェックする(間違った!)方法です。これは、「/a/b/c
」のような、「/」で区切られた名前のシーケンスである文字列と一致します。これはルール 1 に違反しているため危険です。二重にネストされた量子化子を含んでいます。
クライアントがfilePath
に///.../\n
(100 個の'/'の後に正規表現の「.」が一致しない改行文字が続く)でクエリを送信すると、イベントループは事実上永遠に続き、イベントループをブロックします。このクライアントの REDOS 攻撃により、正規表現の一致が完了するまで、他のすべてのクライアントは順番にアクセスできません。
このため、複雑な正規表現を使用してユーザー入力を検証することに関しては注意が必要です。
REDOS 対策のリソース
正規表現の安全性をチェックするツールがいくつかあります。
ただし、これらのツールはすべての脆弱な正規表現を検出するわけではありません。
別の方法として、異なる正規表現エンジンを使用できます。Google の非常に高速なRE2正規表現エンジンを使用するnode-re2モジュールを使用できます。ただし、RE2 は V8 の正規表現と 100%互換性がないため、node-re2 モジュールを交換して正規表現を処理する場合は、回帰を確認してください。特に複雑な正規表現は、node-re2 ではサポートされていません。
URL やファイルパスなど、「明白な」ものと一致させようとしている場合は、正規表現ライブラリの例を見つけるか、npm モジュール(例:ip-regex)を使用してください。
イベントループのブロック:Node.js コアモジュール
いくつかの Node.js コアモジュールには、同期的な高コスト API があります。これらには以下が含まれます。
これらの API は、暗号化、圧縮などのかなりの計算を伴い、I/O(ファイル I/O)を必要とするか、または両方(子プロセス)を必要とするため、コストがかかります。これらの API はスクリプトの利便性を目的としていますが、サーバーコンテキストでの使用を目的としたものではありません。イベントループで実行すると、通常の JavaScript 命令よりも完了までに時間がかかり、イベントループがブロックされます。
サーバーでは、これらのモジュールから次の同期 API を使用しないでください。
- 暗号化:
crypto.randomBytes
(同期バージョン)crypto.randomFillSync
crypto.pbkdf2Sync
- 暗号化と復号化ルーチンに大きな入力を提供することについても注意が必要です。
- 圧縮:
zlib.inflateSync
zlib.deflateSync
- ファイルシステム:
- 同期ファイルシステム API を使用しないでください。たとえば、アクセスするファイルが分散ファイルシステム(例:NFS)にある場合、アクセス時間は大きく異なる可能性があります。
- 子プロセス:
child_process.spawnSync
child_process.execSync
child_process.execFileSync
このリストは、Node.js v9 時点ではかなり完全です。
イベントループのブロック:JSON DOS
JSON.parse
とJSON.stringify
は、潜在的に負荷の高い操作です。これらは入力の長さに対して O(n)ですが、n が大きいと驚くほど時間がかかることがあります。
サーバーが JSON オブジェクト、特にクライアントからの JSON オブジェクトを操作する場合、イベントループで扱うオブジェクトや文字列のサイズに注意する必要があります。
例:JSON によるブロック。2^21 サイズのオブジェクトobj
を作成し、JSON.stringify
を実行し、文字列に対して indexOf を実行してからJSON.parse
を実行します。JSON.stringify
された文字列は 50MB です。オブジェクトの文字列化に 0.7 秒、50MB 文字列の indexOf に 0.03 秒、文字列の解析に 1.3 秒かかります。
let obj = { a: 1 }
let niter = 20
let before, str, pos, res, took
for (let i = 0; i < niter; i++) {
obj = { obj1: obj, obj2: obj } // 各反復でサイズが倍になる
}
before = process.hrtime()
str = JSON.stringify(obj)
took = process.hrtime(before)
console.log('JSON.stringify took ' + took)
before = process.hrtime()
pos = str.indexOf('nomatch')
took = process.hrtime(before)
console.log('Pure indexof took ' + took)
before = process.hrtime()
res = JSON.parse(str)
took = process.hrtime(before)
console.log('JSON.parse took ' + took)
非同期 JSON API を提供する npm モジュールがあります。例えば:
- JSONStreamは、ストリーム API を持っています。
- Big-Friendly JSONは、ストリーム API と、下記で概説するイベントループでのパーティショニングパラダイムを使用した標準 JSON API の非同期バージョンを持っています。
イベントループをブロックすることなく複雑な計算を行う
JavaScript でイベントループをブロックすることなく複雑な計算を行いたいとします。パーティショニングとオフロードの 2 つの選択肢があります。
パーティショニング
計算をパーティショニングして、各計算がイベントループで実行されますが、定期的に他の保留中のイベントに処理を譲ることができます。JavaScript では、以下の例 2 に示すように、クロージャに進行中のタスクの状態を保存するのは簡単です。
簡単な例として、数値 1 から n までの平均を計算するとします。
例 1:非パーティショニング平均、コスト O(n)
for (let i = 0; i < n; i++) sum += i
let avg = sum / n
console.log('avg: ' + avg)
例 2:パーティショニング平均、n 個の非同期ステップそれぞれのコストは O(1)。
function asyncAvg(n, avgCB) {
// JSクロージャに進行中の合計を保存する。
let sum = 0
function help(i, cb) {
sum += i
if (i == n) {
cb(sum)
return
}
// 「非同期再帰」。
// 次の操作を非同期でスケジュールする。
setImmediate(help.bind(null, i + 1, cb))
}
// ヘルパーを起動し、avgCBを呼び出すCBを設定する。
help(1, function (sum) {
let avg = sum / n
avgCB(avg)
})
}
asyncAvg(n, function (avg) {
console.log('avg of 1-n: ' + avg)
})
この原理は、配列の反復処理などに適用できます。
オフロード
より複雑な処理が必要な場合、パーティショニングは適切な選択肢ではありません。パーティショニングはイベントループのみを使用するため、マシン上にほぼ確実に存在する複数のコアの恩恵を受けることができません。**イベントループはクライアントのリクエストを調整するものであり、それ自体でリクエストを処理するものではないことを忘れないでください。**複雑なタスクの場合は、イベントループからワーカープールに作業をオフロードします。
オフロードの方法
作業をオフロードする宛先ワーカープールには、2 つの選択肢があります。
- C++ アドオン を開発することで、Node.js の組み込みワーカープールを使用できます。古いバージョンの Node では、NAN を使用して C++ アドオン を構築し、新しいバージョンでは N-API を使用します。node-webworker-threads は、Node.js ワーカープールにアクセスするための JavaScript 専用の方法を提供します。
- Node.js の I/O をテーマにしたワーカープールではなく、計算専用のワーカープールを作成して管理できます。これを行う最も簡単な方法は、Child Process または Cluster を使用することです。
クライアントごとに Child Process を作成するべきではありません。クライアントのリクエストを受信する速度は、子プロセスを作成および管理する速度よりも速いため、サーバーが フォークボム になる可能性があります。
オフロードの欠点 オフロードアプローチの欠点は、通信コストというオーバーヘッドが発生することです。イベントループだけがアプリケーションの「名前空間」(JavaScript 状態)を見ることができます。ワーカーからは、イベントループの名前空間にある JavaScript オブジェクトを操作できません。代わりに、共有するオブジェクトをシリアル化および逆シリアル化する必要があります。その後、ワーカーはこれらのオブジェクトの独自の複製を操作し、変更されたオブジェクト(または「パッチ」)をイベントループに返します。
シリアル化に関する懸念事項については、JSON DOS に関するセクションを参照してください。
オフロードに関するいくつかの提案
CPU 集中型タスクと I/O 集中型タスクは特性が著しく異なるため、区別することが望ましい場合があります。
CPU 集中型タスクは、そのワーカーがスケジュールされた場合にのみ進捗があり、ワーカーはマシンの 論理コア のいずれかにスケジュールされる必要があります。4 つの論理コアと 5 つのワーカーがある場合、これらのワーカーの 1 つは進捗できません。その結果、このワーカーに対してオーバーヘッド(メモリとスケジューリングのコスト)を支払っており、それに対する見返りは得られません。
I/O 集中型タスクには、外部サービスプロバイダー(DNS、ファイルシステムなど)へのクエリと、その応答の待機が含まれます。I/O 集中型タスクを持つワーカーが応答を待っている間は、他に何もすることがなく、オペレーティングシステムによってスケジュール解除され、別のワーカーがリクエストを送信する機会が与えられます。したがって、関連するスレッドが実行されていない場合でも、I/O 集中型タスクは進捗しています。データベースやファイルシステムなどの外部サービスプロバイダーは、多くの保留中のリクエストを同時に処理するように高度に最適化されています。たとえば、ファイルシステムは、多くの保留中の書き込みリクエストと読み取りリクエストを調べて、競合する更新をマージし、最適な順序でファイルを取得します。
Node.js ワーカープールなど、1 つのワーカープールにのみ依存する場合、CPU バウンドの作業と I/O バウンドの作業の異なる特性により、アプリケーションのパフォーマンスが低下する可能性があります。
このため、別の計算ワーカープールを維持することが望ましい場合があります。
オフロード:結論
単純なタスク(例えば、任意の長さの配列の要素を反復処理するなど)の場合、パーティショニングは良い選択肢かもしれません。計算がより複雑な場合は、オフロードの方が良いアプローチです。つまり、イベントループとワーカープール間でシリアライズされたオブジェクトを渡すオーバーヘッドである通信コストは、複数のコアを使用するメリットによって相殺されます。
しかし、サーバーが複雑な計算に大きく依存している場合、Node.js が本当に適切かどうかを検討する必要があります。Node.js は I/O バウンドな作業に優れていますが、高価な計算には最適な選択肢ではない可能性があります。
オフロードアプローチを採用する場合は、「ワーカープールをブロックしない」セクションを参照してください。
ワーカープールをブロックしない
Node.js には、k 個のワーカーで構成されるワーカープールがあります。上記で説明したオフロードパラダイムを使用している場合、同じ原則が適用される別の計算ワーカープールがあるかもしれません。いずれの場合も、k は同時に処理する可能性のあるクライアントの数よりもはるかに小さいと仮定しましょう。これは、Node.js のスケーラビリティの秘訣である「多くのクライアントに対する 1 つのスレッド」という哲学と一致しています。
上記のように、各ワーカーは、ワーカープールのキューにある次のタスクに進む前に、現在のタスクを完了します。
ここで、クライアントのリクエストを処理するために必要なタスクのコストにはばらつきがあります。一部のタスクは迅速に完了できます(例:短いかキャッシュされたファイルの読み取り、または少数のランダムバイトの生成)。一方、他のタスクはより時間がかかります(例:大きくキャッシュされていないファイルの読み取り、またはより多くのランダムバイトの生成)。目標は、タスク時間のばらつきを最小限に抑えることになり、そのためにはタスクのパーティショニングを使用する必要があります。
タスク時間のばらつきの最小化
ワーカーの現在のタスクが他のタスクよりもはるかに高価な場合、そのワーカーは他の保留中のタスクに取り組むことができなくなります。言い換えれば、比較的長いタスクごとに、完了するまでワーカープールのサイズが事実上 1 つ減少します。これは望ましくありません。なぜなら、ある程度までは、ワーカープール内のワーカーが多ければ多いほど、ワーカープールのスループット(タスク/秒)が高くなり、サーバーのスループット(クライアントリクエスト/秒)が高くなるからです。比較的高価なタスクを持つ 1 つのクライアントは、ワーカープールのスループットを低下させ、結果としてサーバーのスループットを低下させます。
これを回避するには、ワーカープールに送信するタスクの長さのばらつきを最小限に抑えるように努める必要があります。I/O リクエスト(DB、FS など)によってアクセスされる外部システムをブラックボックスとして扱うことは適切ですが、これらの I/O リクエストの相対的なコストを認識し、特に時間がかかることが予想されるリクエストの送信を避ける必要があります。
タスク時間のばらつきの可能性を示す 2 つの例を以下に示します。
変動例:長時間実行されるファイルシステム読み込み
サーバーがクライアントのリクエストを処理するためにファイルを読み込む必要があるとします。Node.js のファイルシステム API を参照した後、簡潔さのためにfs.readFile()
を使用することにしました。しかし、fs.readFile()
は(現在)パーティション化されていません。ファイル全体にわたる単一のfs.read()
タスクを送信します。一部のユーザーに対しては短いファイルを読み込み、他のユーザーに対しては長いファイルを読み込む場合、fs.readFile()
はタスクの長さに大きなばらつきをもたらし、ワーカープールのスループットを低下させる可能性があります。
最悪のシナリオとして、攻撃者がサーバーに任意のファイルの読み込みをさせることができる(これはディレクトリトラバーサル脆弱性です)と仮定します。サーバーが Linux を実行している場合、攻撃者は非常に遅いファイル、/dev/random
を指定できます。実際上、/dev/random
は無限に遅く、/dev/random
の読み込みを要求されたワーカーは、そのタスクを完了することができません。攻撃者は k 個のリクエストを、各ワーカーに 1 つずつ送信し、ワーカープールを使用する他のクライアントリクエストは進捗しません。
変動例:長時間実行される暗号化操作
サーバーがcrypto.randomBytes()
を使用して暗号化的に安全なランダムバイトを生成するとします。crypto.randomBytes()
はパーティション化されていません。要求されたバイト数だけを生成する単一のrandomBytes()
タスクを作成します。一部のユーザーに対しては少ないバイト数を作成し、他のユーザーに対しては多くのバイト数を作成する場合、crypto.randomBytes()
はタスクの長さのばらつきの別の原因となります。
タスクのパーティショニング
時間コストが可変のタスクは、ワーカープールのスループットを損なう可能性があります。タスク時間のばらつきを最小限に抑えるために、可能な限り、各タスクを比較可能なコストのサブタスクにパーティショニングする必要があります。各サブタスクが完了すると、次のサブタスクを送信する必要があり、最後のサブタスクが完了すると、送信者に通知する必要があります。
fs.readFile()
の例を続けるために、代わりにfs.read()
(手動パーティショニング)またはReadStream
(自動パーティショニング)を使用する必要があります。
同じ原則は CPU バウンドタスクにも適用されます。asyncAvg
の例はイベントループには適さないかもしれませんが、ワーカープールには適しています。
タスクをサブタスクにパーティショニングすると、短いタスクは少数のサブタスクに展開され、長いタスクは多数のサブタスクに展開されます。長いタスクの各サブタスク間で、割り当てられたワーカーは別の短いタスクからのサブタスクで作業でき、これによりワーカープールの全体的なタスクスループットが向上します。
サブタスクの完了数は、ワーカープールのスループットの有用な指標ではないことに注意してください。代わりに、完了したタスクの数に注目してください。
タスク分割の回避
タスク分割の目的は、タスク時間のばらつきを最小限に抑えることであることを思い出してください。短いタスクと長いタスク(例:配列の合計と配列のソート)を区別できる場合、各クラスのタスクごとに 1 つのワーカープールを作成できます。短いタスクと長いタスクを別々のワーカープールにルーティングすることは、タスク時間のばらつきを最小限に抑える別の方法です。
このアプローチの利点として、タスクの分割にはオーバーヘッド(ワーカープールタスク表現の作成とワーカープールキューの操作のコスト)が発生し、分割を回避することで、ワーカープールへの追加の移動コストを節約できます。また、タスクの分割で間違いを犯すこともなくなります。
このアプローチの欠点は、これらのすべてのワーカープールのワーカーが空間と時間のオーバーヘッドを被り、CPU 時間に関して互いに競合することです。各 CPU バウンドタスクは、スケジュールされている間のみ進捗することに注意してください。その結果、このアプローチは慎重な分析の後のみ検討する必要があります。
ワーカープール:結論
Node.js ワーカープールのみを使用する場合でも、別々のワーカープールを維持する場合でも、プールのタスクスループットを最適化する必要があります。
これを行うには、タスク分割を使用してタスク時間のばらつきを最小限に抑えます。
npm モジュールのリスク
Node.js のコアモジュールは、幅広いアプリケーションのビルディングブロックを提供しますが、場合によってはさらに何かが必要になります。Node.js 開発者は、数十万ものモジュールが開発プロセスを加速するための機能を提供する npm エコシステムから非常に大きな恩恵を受けています。
ただし、これらのモジュールのほとんどはサードパーティの開発者によって記述されており、一般的にベストエフォート保証のみでリリースされていることを忘れないでください。npm モジュールを使用する開発者は、2 つのことを懸念する必要がありますが、後者は頻繁に忘れられています。
- API を尊重していますか?
- その API はイベントループまたはワーカーをブロックする可能性がありますか?多くのモジュールは、API のコストを示すための努力をしておらず、コミュニティにとって有害です。
単純な API の場合、API のコストを推定できます。文字列操作のコストは簡単に理解できます。しかし、多くの場合、API のコストがどの程度になるかは不明です。
高価な処理を行う可能性のある API を呼び出す場合は、コストを再確認してください。開発者にドキュメント化を依頼するか、ソースコード自体を調べ(そしてコストを文書化する PR を送信し)てください。
API が非同期であっても、その各パーティションでワーカーまたはイベントループでどれだけの時間かかるかわかりません。たとえば、上記のasyncAvg
の例では、ヘルパー関数の各呼び出しが 1 つの数字ではなく、数字の半分を合計したとします。すると、この関数は非同期のままですが、各パーティションのコストはO(1)
ではなくO(n)
になり、n
の任意の値に対して使用するのがはるかに安全ではなくなります。
結論
Node.js には、イベントループとワーカーの 2 種類のスレッドがあります。イベントループは JavaScript のコールバックとノンブロッキング I/O を処理し、ワーカーはブロッキング I/O や CPU 集約型の作業を含む非同期リクエストを完了する C++コードに対応するタスクを実行します。どちらの種類のスレッドも、一度に 1 つ以上の作業は行いません。コールバックやタスクに時間がかかりすぎると、それを実行しているスレッドはブロックされます。アプリケーションがブロッキングコールバックやタスクを作成すると、最悪の場合、サービス拒否攻撃につながる可能性があり、少なくともスループット(クライアント/秒)の低下につながります。
高スループットで、DoS 攻撃に対する耐性が高い Web サーバーを作成するには、良性の入力と悪意のある入力の両方において、イベントループとワーカーのどちらもブロックされないようにする必要があります。