Skip to content

イベントループ (またはワーカープール) をブロックしない

このガイドを読むべきか?

短いコマンドラインスクリプト以上の複雑なものを書いている場合は、これを読むことで、より高性能で安全なアプリケーションを作成できるようになります。

このドキュメントは 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 には、イベントループ (メインループ、メインスレッド、イベントスレッドなどとも呼ばれます) と、ワーカープール内の k ワーカーのプール (スレッドプールとも呼ばれます) の 2 種類のスレッドがあります。

スレッドがコールバック (イベントループ) またはタスク (ワーカー) の実行に時間がかかっている場合、それを「ブロックされた」と呼びます。 スレッドが 1 つのクライアントのためにブロックされている間、他のクライアントからのリクエストを処理できません。 これは、イベントループとワーカープールの両方をブロックしないことに対する 2 つの動機を提供します。

  1. パフォーマンス: どちらかのタイプのスレッドで定期的に高負荷のアクティビティを実行すると、サーバーの スループット (リクエスト/秒) が低下します。
  2. セキュリティ: 特定の入力に対してスレッドの 1 つがブロックされる可能性がある場合、悪意のあるクライアントがこの「悪意のある入力」を送信し、スレッドをブロックさせ、他のクライアントの処理を妨げる可能性があります。 これは、サービス拒否攻撃 になります。

Node の簡単な復習

Node.js はイベント駆動型アーキテクチャを使用します。オーケストレーションのためのイベントループと、負荷の高いタスクのためのワーカープールがあります。

イベントループで実行されるコードは何ですか?

Node.js アプリケーションは、開始時にまず初期化フェーズを完了し、モジュールを require してイベントのコールバックを登録します。その後、Node.js アプリケーションはイベントループに入り、適切なコールバックを実行して受信クライアントリクエストに応答します。このコールバックは同期的に実行され、完了後に処理を継続するための非同期リクエストを登録する場合があります。これらの非同期リクエストのコールバックも、イベントループで実行されます。

イベントループは、ネットワーク I/O など、コールバックによって行われたノンブロッキングの非同期リクエストも実行します。

要約すると、イベントループはイベントに登録された JavaScript コールバックを実行し、ネットワーク I/O などのノンブロッキングの非同期リクエストを実行する役割も担っています。

ワーカープールで実行されるコードは何ですか?

Node.js のワーカープールは libuv (ドキュメント) で実装されており、一般的なタスク送信 API を公開しています。

Node.js は、ワーカープールを使用して「負荷の高い」タスクを処理します。これには、オペレーティングシステムがノンブロッキングバージョンを提供していない I/O と、特に CPU 負荷の高いタスクが含まれます。

以下は、このワーカープールを使用する Node.js モジュール API です。

  1. I/O 集中型
    1. DNS: dns.lookup(), dns.lookupService().
    2. [ファイルシステム][/api/fs]: fs.FSWatcher() と明示的に同期的なものを除くすべてのファイルシステム API は、libuv のスレッドプールを使用します。
  2. CPU 集中型
    1. Crypto: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair().
    2. Zlib: 明示的に同期的なものを除くすべての zlib API は、libuv のスレッドプールを使用します。

多くの Node.js アプリケーションでは、これらの API がワーカープールのタスクの唯一のソースです。C++ アドオン を使用するアプリケーションとモジュールは、他のタスクをワーカープールに送信できます。

完全を期すために、イベントループ上のコールバックからこれらの API のいずれかを呼び出すと、イベントループは、その API の Node.js C++ バインディングに入り、タスクをワーカープールに送信する際に、いくつかのわずかなセットアップコストを支払うことに注意してください。これらのコストは、タスク全体のコストに比べればごくわずかであるため、イベントループはそれをオフロードしています。これらのタスクのいずれかをワーカープールに送信するとき、Node.js は Node.js C++ バインディング内の対応する C++ 関数へのポインタを提供します。

Node.js はどのようにして次に実行するコードを決定するのか?

抽象的には、イベントループとワーカープールは、それぞれ保留中のイベントと保留中のタスクのキューを保持します。

実際には、イベントループは実際にはキューを保持しません。代わりに、epoll (Linux)、kqueue (OSX)、イベントポート (Solaris)、または IOCP (Windows) のようなメカニズムを使用して、オペレーティングシステムに監視を依頼するファイルディスクリプタのコレクションを持っています。これらのファイルディスクリプタは、ネットワークソケット、監視しているファイルなどに対応します。オペレーティングシステムがこれらのファイルディスクリプタのいずれかが準備完了になったと言うと、イベントループはそれを適切なイベントに変換し、そのイベントに関連付けられたコールバックを呼び出します。このプロセスの詳細については、こちらをご覧ください。

対照的に、ワーカープールは、エントリが処理されるタスクである実際のキューを使用します。ワーカーはこのキューからタスクをポップして処理し、完了すると、ワーカーはイベントループに対して「少なくとも 1 つのタスクが完了した」イベントを発生させます。

これはアプリケーション設計にとって何を意味するのか?

Apache のようなクライアントごとに 1 つのスレッドを持つシステムでは、保留中の各クライアントに独自のスレッドが割り当てられます。1 つのクライアントを処理するスレッドがブロックされた場合、オペレーティングシステムはそれを中断し、別のクライアントに順番を与えます。したがって、オペレーティングシステムは、少量の作業しか必要としないクライアントが、より多くの作業を必要とするクライアントによって不利にならないようにします。

Node.js は少ないスレッドで多くのクライアントを処理するため、1 つのスレッドが 1 つのクライアントのリクエストの処理をブロックすると、保留中のクライアントのリクエストは、スレッドがコールバックまたはタスクを完了するまで順番が回ってこない可能性があります。したがって、クライアントの公正な扱いは、アプリケーションの責任です。これは、単一のコールバックまたはタスクで、クライアントに対してあまりにも多くの作業を行うべきではないことを意味します。

これが Node.js がうまくスケールできる理由の一部ですが、公正なスケジューリングを確保する責任があることも意味します。次のセクションでは、イベントループとワーカープールの公正なスケジューリングを確保する方法について説明します。

イベントループをブロックしない

イベントループは、新しいクライアント接続を認識し、応答の生成を調整します。すべての受信リクエストと送信レスポンスは、イベントループを通過します。これは、イベントループがどこかの時点で長時間を費やすと、現在および新規のクライアントは順番待ちができなくなることを意味します。

イベントループを絶対にブロックしないようにする必要があります。言い換えれば、JavaScriptの各コールバックは迅速に完了する必要があります。これはもちろん、awaitPromise.thenなどにも当てはまります。

これを確実にする良い方法は、コールバックの"計算量"について考えることです。コールバックが引数に関係なく一定の手順数で完了する場合、保留中のすべてのクライアントに公平な順番を与えることになります。コールバックが引数に応じて異なる手順数を要する場合、引数の長さについて検討する必要があります。

例1:定数時間のコールバック。

js
app.get('/constant-time', (req, res) => {
  res.sendStatus(200);
});

例2:O(n)のコールバック。このコールバックは、nが小さい場合は高速に実行され、nが大きい場合は低速に実行されます。

js
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)の例よりもはるかに低速に実行されます。

js
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はJavaScriptにGoogle V8エンジンを使用しており、多くの一般的な操作では非常に高速です。このルールの例外は、以下で説明する正規表現とJSON操作です。

ただし、複雑なタスクでは、入力を制限し、長すぎる入力を拒否することを検討する必要があります。そうすれば、コールバックの複雑さが大きい場合でも、入力を制限することで、コールバックが許容される最長の入力に対して最悪の場合よりも時間がかからないようにすることができます。次に、このコールバックの最悪の場合のコストを評価し、その実行時間がコンテキストで許容できるかどうかを判断できます。

イベントループのブロック:REDOS

イベントループを悲惨なほどブロックする一般的な方法は、「脆弱な」正規表現を使用することです。

脆弱な正規表現を避ける

正規表現(regexp)は、パターンに対して入力文字列を照合します。通常、正規表現のマッチングは、入力文字列を一度だけ処理する --- O(n) 時間(n は入力文字列の長さ)が必要であると考えます。多くの場合、一度の処理で十分です。残念ながら、正規表現のマッチングには、入力文字列を指数関数的に何度も処理する --- O(2^n) 時間が必要になる場合があります。指数関数的な回数の処理とは、エンジンがマッチングを判断するために x 回の処理を必要とする場合、入力文字列に 1 文字追加するだけで 2*x 回の処理が必要になることを意味します。処理回数は必要な時間に線形的に関連しているため、この評価の影響はイベントループをブロックすることになります。

脆弱な正規表現 とは、正規表現エンジンが指数関数的な時間を要する可能性があり、それによって「悪意のある入力」に対する REDOS の危険にさらされるものです。正規表現パターンが脆弱であるかどうか(つまり、正規表現エンジンが指数関数的な時間を要する可能性があるかどうか)は、実際に答えるのが難しい問題であり、Perl、Python、Ruby、Java、JavaScript などを使用しているかどうかによって異なりますが、以下はこれらのすべての言語に共通する経験則です。

  1. (a+)* のようなネストされた量指定子を避けてください。V8 の正規表現エンジンは、これらのいくつかを迅速に処理できますが、脆弱なものもあります。
  2. (a|a)* のように、重複する句を持つ OR を避けてください。繰り返しますが、これらは高速な場合もあれば、そうでない場合もあります。
  3. (a.*) \1 のように、後方参照の使用を避けてください。どの正規表現エンジンも、これらを線形時間で評価することを保証できません。
  4. 単純な文字列マッチングを行う場合は、indexOf またはローカルの同等のものを使用してください。より安価であり、O(n) を超えることはありません。

正規表現が脆弱かどうか不明な場合は、Node.js は一般に、脆弱な正規表現と長い入力文字列でも、マッチングを問題なく報告できることを覚えておいてください。指数関数的な動作は、不一致がある場合にトリガーされますが、Node.js は入力文字列を何度も処理するまで確信できません。

REDOS の例

サーバーを REDOS にさらす脆弱な正規表現の例を以下に示します。

js
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 に違反しているため危険です。二重にネストされた量指定子があります。

クライアントが ///.../\n (100 個の / の後に、正規表現の "." が一致しない改行文字) で filePath をクエリすると、イベントループは事実上永遠に実行され、イベントループをブロックします。このクライアントの 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.parseJSON.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 秒かかります。

js
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)

js
for (let i = 0; i < n; i++) sum += i;
let avg = sum / n;
console.log('avg: ' + avg);

例 2:パーティション化された平均、n 個の非同期ステップのそれぞれのコストは O(1) です。

js
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('1-n の平均: ' + avg);
});

この原則は、配列のイテレーションなどに適用できます。

オフロード

より複雑な処理が必要な場合、パーティショニングは良い選択肢ではありません。なぜなら、パーティショニングはイベントループのみを使用し、ほぼ確実にあなたのマシンで利用可能な複数のコアを活用できないからです。イベントループはクライアントのリクエストを調整するものであり、それ自体がリクエストを処理するものではないことを覚えておいてください。 複雑なタスクの場合は、イベントループからワーカプールに処理をオフロードしてください。

オフロードの方法

処理をオフロードするための宛先ワーカプールには、2つの選択肢があります。

  1. C++アドオンを開発して、Node.jsの組み込みワーカプールを使用できます。古いバージョンのNodeでは、NANを使用してC++アドオンを構築し、新しいバージョンではN-APIを使用します。node-webworker-threadsを使用すると、JavaScriptのみでNode.jsワーカプールにアクセスできます。
  2. Node.jsのI/Oをテーマにしたワーカプールではなく、計算専用の独自のワーカプールを作成および管理できます。これを行う最も簡単な方法は、子プロセスまたはクラスタを使用することです。

単にクライアントごとに子プロセスを作成すべきではありません。クライアントのリクエストは、子プロセスの作成と管理よりも早く受信できるため、サーバーがフォーク爆弾になる可能性があります。

オフロードの欠点 オフロードのアプローチの欠点は、通信コストという形でオーバーヘッドが発生することです。イベントループのみが、アプリケーションの「名前空間」(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からの読み込みを求められたすべてのワーカーはタスクを完了できません。攻撃者は、各ワーカーに対して1つずつ、k個のリクエストを送信し、ワーカープールを使用する他のクライアントリクエストは処理を進めることができなくなります。

バリエーションの例:時間のかかる暗号化操作

サーバーが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つのことを懸念する必要がありますが、後者はしばしば忘れられます。

  1. APIを尊重しているか?
  2. APIがイベントループまたはワーカをブロックする可能性があるか?多くのモジュールは、コミュニティの不利益になるように、APIのコストを示す努力をしていません。

単純なAPIについては、APIのコストを見積もることができます。文字列操作のコストは把握するのが難しくありません。しかし、多くの場合、APIのコストがどれくらいになるかは不明です。

高コストになる可能性のあるAPIを呼び出す場合は、コストを再確認してください。開発者にドキュメント化を依頼するか、自分でソースコードを調べてください(そして、コストをドキュメント化するPRを提出してください)。

APIが非同期であっても、パーティションごとにワーカまたはイベントループでどれくらいの時間を費やす可能性があるかはわかりません。たとえば、上記のasyncAvgの例で、ヘルパー関数への各呼び出しが、数値の半分を合計するのではなく、数値の1つを合計すると仮定します。すると、この関数は依然として非同期ですが、各パーティションのコストはO(1)ではなくO(n)になり、nの任意の値に対して使用するのははるかに安全ではありません。

結論

Node.jsには2種類のスレッドがあります。1つのイベントループとk個のワーカーです。イベントループはJavaScriptのコールバックとノンブロッキングI/Oを担当し、ワーカーは非同期リクエストを完了させるC++コードに対応するタスクを実行します。これにはブロッキングI/OとCPU負荷の高い作業が含まれます。どちらのタイプのスレッドも、一度に1つのアクティビティしか処理しません。コールバックまたはタスクの処理に時間がかかると、それを実行しているスレッドはブロックされます。アプリケーションがブロッキングコールバックまたはタスクを作成する場合、最悪の場合、スループット(クライアント/秒)が低下し、最悪の場合、完全なサービス拒否につながる可能性があります。

高スループットで、よりDoS対策されたWebサーバーを作成するには、良性な入力と悪意のある入力の両方で、イベントループもワーカーもブロックされないようにする必要があります。