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

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

短いコマンドラインスクリプトより複雑なものを書くのであれば、これを読むことで、より高性能で安全なアプリケーションを書くのに役立つはずです。

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

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

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

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. ファイルシステム: 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つのクライアントのリクエストを処理してブロックされると、保留中のクライアントのリクエストはそのスレッドがコールバックやタスクを完了するまで順番が回ってこない可能性があります。したがって、クライアントの公正な扱いはアプリケーションの責任です。これは、どのクライアントに対しても、単一のコールバックやタスクで過剰な作業を行うべきではないことを意味します。

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

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

イベントループは、新しいクライアント接続ごとに気づき、レスポンスの生成を調整します。すべての受信リクエストと送信レスポンスはイベントループを通過します。これは、イベントループがどの時点でも長すぎると、現在および新しいすべてのクライアントが順番を得られなくなることを意味します。

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

これを保証する良い方法は、コールバックの「計算量」について考えることです。コールバックが引数に関係なく一定のステップ数を取る場合、保留中のすべてのクライアントに常に公正な順番を与えることになります。コールバックが引数によって異なるステップ数を取る場合は、引数がどれくらい長くなる可能性があるかを考えるべきです。

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

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

例2:O(n) のコールバック。このコールバックは、小さい n では速く実行され、大きい n ではより遅くなります。

app.get('/countToN', (, ) => {
  const  = .query.n;

  // n iterations before giving someone else a turn
  for (let  = 0;  < ; ++) {
    .(`Iter ${}`);
  }

  .sendStatus(200);
});

例3:O(n^2) のコールバック。このコールバックは、小さい n ではまだ速く実行されますが、大きい n では前の O(n) の例よりもはるかに遅くなります。

app.get('/countToN2', (, ) => {
  const  = .query.n;

  // n^2 iterations before giving someone else a turn
  for (let  = 0;  < ; ++) {
    for (let  = 0;  < ; ++) {
      .(`Iter ${}.${}`);
    }
  }

  .sendStatus(200);
});

どれくらい注意すべきか?

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

しかし、複雑なタスクについては、入力を制限し、長すぎる入力を拒否することを検討すべきです。そうすれば、コールバックの計算量が大きくても、入力を制限することで、コールバックが許容される最長の入力で最悪ケースの時間以上かかることはないと保証できます。その後、このコールバックの最悪ケースのコストを評価し、その実行時間があなたのコンテキストで許容できるかどうかを判断できます。

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

イベントループを壊滅的にブロックする一般的な方法の1つは、「脆弱な」正規表現を使用することです。

脆弱な正規表現を避ける

正規表現(regexp)は、入力文字列をパターンに照合します。私たちは通常、正規表現の一致には入力文字列を1回通過するだけで済むと考えています --- O(n) 時間、ここで n は入力文字列の長さです。多くの場合、1回の通過で十分です。残念ながら、場合によっては正規表現の一致に指数関数的な回数の入力文字列の走査が必要になることがあります --- 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 にさらす脆弱な正規表現の例です。

app.get('/redos-me', (, ) => {
  const  = .query.filePath;

  // REDOS
  if (.match(/(\/.+)+$/)) {
    .('valid path');
  } else {
    .('invalid path');
  }

  .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.parseJSON.stringify は、他に高コストになる可能性のある操作です。これらは入力の長さに対して O(n) ですが、大きな n の場合、驚くほど時間がかかることがあります。

サーバーが JSON オブジェクト、特にクライアントからのものを操作する場合、イベントループで扱うオブジェクトや文字列のサイズに注意する必要があります。

例:JSON ブロッキング。サイズ 2^21 のオブジェクト obj を作成し、それを JSON.stringify し、その文字列に対して indexOf を実行し、その後 JSON.parse します。JSON.stringify された文字列は 50MB です。オブジェクトを文字列化するのに 0.7 秒、50MB の文字列に対して indexOf を実行するのに 0.03 秒、そして文字列をパースするのに 1.3 秒かかります。

let  = { : 1 };
const  = 20;

// Expand the object exponentially by nesting it
for (let  = 0;  < ; ++) {
   = { : , :  };
}

// Measure time to stringify the object
let  = .();
const  = .();
let  = .();
.('JSON.stringify took', );

// Measure time to search a string within the JSON
 = .();
const  = .('nomatch'); // Always -1
 = .();
.('String.indexOf took', );

// Measure time to parse the JSON back to an object
 = .();
const  = .();
 = .();
.('JSON.parse took', );

非同期 JSON API を提供する npm モジュールがあります。例えば、以下を参照してください。

  • JSONStream、ストリーム API を持っています。
  • Big-Friendly JSON、ストリーム API と、以下で概説するイベントループでの分割パラダイムを使用した標準 JSON API の非同期版を持っています。

イベントループをブロックせずに複雑な計算を行う

イベントループをブロックせずに JavaScript で複雑な計算を行いたい場合、2つの選択肢があります:分割またはオフロードです。

分割

計算を分割して、それぞれがイベントループ上で実行されるが、定期的に他の保留中のイベントに譲る(順番を譲る)ようにすることができます。JavaScript では、以下の例2に示すように、進行中のタスクの状態をクロージャに保存するのは簡単です。

簡単な例として、1からnまでの数値の平均を計算したいとします。

例1:分割されていない平均、コストは O(n)

for (let  = 0;  < n; ++) {
  sum += ;
}

const  = sum / n;
.('avg: ' + );

例2:分割された平均、n 個の非同期ステップのそれぞれが O(1) のコスト。

function (, ) {
  // Save ongoing sum in JS closure.
  let  = 0;
  function (, ) {
     += ;
    if ( == ) {
      ();
      return;
    }

    // "Asynchronous recursion".
    // Schedule next operation asynchronously.
    (.(null,  + 1, ));
  }

  // Start the helper, with CB to call avgCB.
  (1, function () {
    const  =  / ;
    ();
  });
}

(n, function () {
  .('avg of 1-n: ' + );
});

この原則を配列の反復などに適用できます。

オフロード

もっと複雑なことをする必要がある場合、分割は良い選択肢ではありません。これは、分割がイベントループのみを使用し、あなたのマシンでほぼ確実に利用可能な複数のコアの恩恵を受けられないためです。覚えておいてください、イベントループはクライアントリクエストを調整するべきであり、それ自体で実行するべきではありません。 複雑なタスクについては、作業をイベントループからワーカプールに移動してください。

オフロードの方法

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

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

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

オフロードの欠点

オフロードアプローチの欠点は、通信コストという形でオーバーヘッドが発生することです。イベントループだけがアプリケーションの「名前空間」(JavaScript の状態)を見ることができます。ワーカーから、イベントループの名前空間にある JavaScript オブジェクトを操作することはできません。代わりに、共有したいオブジェクトをシリアライズおよびデシリアライズする必要があります。その後、ワーカーはこれらのオブジェクトの独自のコピーを操作し、変更されたオブジェクト(または「パッチ」)をイベントループに返すことができます。

シリアライゼーションに関する懸念については、JSON DOS のセクションを参照してください。

オフロードに関するいくつかの提案

CPU 集約的なタスクと I/O 集約的なタスクは、著しく異なる特性を持つため、区別したいかもしれません。

CPU集約的なタスクは、そのワーカーがスケジュールされたときにのみ進行し、そのワーカーはマシンの論理コアの1つにスケジュールされる必要があります。4つの論理コアがあり、5つのワーカーがある場合、これらのワーカーのうち1つは進行できません。結果として、このワーカーに対してオーバーヘッド(メモリとスケジューリングコスト)を支払い、それに対する見返りは得られません。

I/O 集約的なタスクは、外部サービスプロバイダ (DNS, ファイルシステムなど) への問い合わせとその応答を待つことを含みます。I/O 集約的なタスクを持つワーカーが応答を待っている間、他にやることはなく、オペレーティングシステムによってデスケジュールされ、別のワーカーがリクエストを送信する機会を得ることができます。したがって、I/O 集約的なタスクは、関連するスレッドが実行されていない間も進行します。データベースやファイルシステムのような外部サービスプロバイダは、多くの保留中のリクエストを同時に処理するために高度に最適化されています。たとえば、ファイルシステムは、競合する更新をマージし、最適な順序でファイルを取得するために、多数の保留中の書き込みおよび読み取りリクエストを検査します。

もし1つのワーカプール、例えば Node.js のワーカプールだけに依存している場合、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() を使用することにしました。しかし、v10 以前の 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 を呼び出す場合は、コストを再確認してください。開発者に文書化を依頼するか、自分でソースコードを調べてください(そして、コストを文書化する PR を提出してください)。

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

結論

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

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