Node.js のワーカー スレッドの詳細な理解

Node.js のワーカー スレッドの詳細な理解

概要

長年にわたり、Node.js は、主に JavaScript のシングルスレッドの性質により、CPU を集中的に使用するアプリケーションを実装するための最適な選択肢ではありませんでした。この問題の解決策として、Node.js v10.5.0 では、worker_threads モジュールを通じて「ワーカー スレッド」という実験的な概念が導入され、Node.js v12 LTS から安定した機能になりました。この記事では、その仕組みと、ワーカー スレッドを使用して最高のパフォーマンスを得る方法について説明します。

Node.js における CPU バウンド アプリケーションの歴史

ワーカー スレッドが登場する前は、Node.js で CPU を集中的に使用するアプリケーションを実行する方法は複数ありました。いくつか例を挙げると:

  • child_processモジュールを使用して、子プロセスでCPUを集中的に使用するコードを実行します。
  • クラスタモジュールを使用して、複数のプロセスで複数のCPU集中型操作を実行します。
  • MicrosoftのNapa.jsなどのサードパーティモジュールの使用

しかし、パフォーマンスの制限、複雑さの増加、採用率の低さ、ドキュメントの不足などにより、これらのソリューションはいずれも広く採用されていません。

CPUを集中的に使用する操作にはワーカースレッドを使用する

worker_threads は JavaScript の同時実行性の問題に対する優れたソリューションですが、JavaScript 自体にマルチスレッド機能をもたらすものではありません。対照的に、worker_threads は、Node によって提供されるワーカーと親ワーカー間の通信を使用して、複数の分離された JavaScript ワーカーを使用してアプリケーションを実行することで並行性を実現します。混乱していますか? ‍♂️

Node.js では、各ワーカーに独自の V8 インスタンスとイベント ループが存在します。しかし、child_process とは異なり、ワーカーはメモリを共有しません。

上記の概念については後で説明します。まず、ワーカー スレッドの使用方法を簡単に見てみましょう。単純な使用例は次のようになります。

// ワーカーシンプル.js

const {Worker、isMainThread、parentPort、workerData} = require('worker_threads');
if (isMainThread) {
 const ワーカー = 新しいワーカー (__filename、{ワーカーデータ: {数値: 5}});
 ワーカー.once('メッセージ', (結果) => {
 console.log('5の2乗は​​:', result);
 })
} それ以外 {
 親ポート.postMessage(ワーカーデータ.num * ワーカーデータ.num)
}

上記の例では、各ワーカーに数値を渡してその二乗値を計算しました。計算後、子ワーカーは結果をメインワーカースレッドに送り返します。一見シンプルに見えますが、Node.js を初めて使用する人にとっては少し混乱する可能性があります。

ワーカースレッドはどのように機能しますか?

JavaScript 言語にはマルチスレッド機能がありません。したがって、Node.js ワーカー スレッドは、他の多くの高水準言語の従来のマルチスレッドとは異なる動作をします。

Node.js では、ワーカーの役割は、親ワーカーによって提供されるコード (ワーカー スクリプト) を実行することです。このワーカー スクリプトは他のワーカーとは独立して実行され、自身と親ワーカーの間でメッセージを渡すことができます。ワーカー スクリプトは、スタンドアロン ファイルでも、eval によって解析できるテキスト スクリプトでもかまいません。この場合、親ワーカー コードと子ワーカー コードの両方が同じスクリプト ファイルにあり、isMainThread プロパティによってその役割が決定されるため、__filename をワーカー スクリプトとして使用します。

各ワーカーは、メッセージ チャネルを介して親ワーカーに接続されます。子ワーカーは、parentPort.postMessage() 関数を使用してメッセージ チャネルにメッセージを書き込むことができ、親ワーカーはワーカー インスタンスで worker.postMessage() 関数を呼び出してメッセージ チャネルにメッセージを書き込みます。図1をご覧ください。

メッセージ チャネルは、「ポート」と呼ばれる 2 つの端を持つ単純な通信チャネルです。 JavaScript/NodeJS の用語では、メッセージ チャネルの両端は port1 と port2 と呼ばれます。

Node.js ワーカーはどのように並列動作するのでしょうか?

ここで重要な疑問が浮かびます。JavaScript は並行性を直接提供しないので、2 つの Node.js ワーカーを並列に実行するにはどうすればよいでしょうか。答えはV8アイソレートです。

V8 アイソレートは、独自の JS スタックとマイクロタスク キューを備えた、Chrome V8 ランタイムの個別のインスタンスです。これにより、各 Node.js ワーカーは他のワーカーから完全に分離して JavaScript コードを実行できるようになります。欠点は、ワーカーが他のワーカーのヒープ データに直接アクセスできないことです。

さらに読む: JS はブラウザと Node でどのように機能しますか?

したがって、各ワーカーは、親ワーカーや他のワーカーから独立した libuv イベント ループの独自のコピーを持つことになります。

JS/C++の境界を越える

新しいワーカーのインスタンス化と親/兄弟 JS スクリプトとの通信の提供はすべて、ワーカーの C++ バージョンによって実行されます。執筆時点での実装はworker.cc (https://github.com/nodejs/node/blob/921493e228/src/node_worker.cc) です。

ワーカー実装は、worker_threads モジュールを介してユーザーレベルの JavaScript スクリプトとして公開されます。 JS 実装は 2 つのスクリプトに分割されており、次のように呼ばれます。

  • 初期化スクリプト worker.js — ワーカー インスタンスを初期化し、親ワーカーと子ワーカー間の初期通信を確立して、ワーカー メタデータが親ワーカーから子ワーカーに渡されるようにします。 (https://github.com/nodejs/node/blob/921493e228/lib/internal/worker.js)
  • スクリプト worker_thread.js を実行します。ユーザーによって提供された workerData データと親ワーカーによって提供されたその他のメタデータに従って、ユーザーのワーカー JS スクリプトを実行します。 (https://github.com/nodejs/node/blob/921493e228/lib/internal/main/worker_thread.js)

図 2 はこのプロセスをより明確に説明しています。

上記に基づいて、ワーカーのセットアップ プロセスを 2 つの段階に分けることができます。

  • ワーカーの初期化
  • ワーカーの実行

各段階で何が起こるか見てみましょう。

初期化手順

1. ユーザーレベルのスクリプトはworker_threadsを使用してワーカーインスタンスを作成します。

2. ノードの親ワーカー初期化スクリプトが C++ を呼び出して、空のワーカー オブジェクトを作成します。この時点では、作成されたワーカーは、まだ開始されていない単純な C++ オブジェクトです。

3. C++ワーカーオブジェクトが作成されると、スレッドIDを生成し、それを自身に割り当てる。

4. 同時に、親ワーカーによって空の初期化メッセージ チャネル (IMC と呼びます) が作成されます。これは、図 2 の灰色の「初期化メッセージ チャネル」セクションに示されています。

5. ワーカー初期化スクリプトによってパブリック JS メッセージ チャネル (PMC と呼ばれる) が作成されます。このチャネルは、ユーザーレベルの JS によって、親ワーカーと子ワーカー間でメッセージを渡すために使用されます。この部分は主に図 1 で説明されており、図 2 でも赤でマークされています。

6. ノード親ワーカー初期化スクリプトは C++ を呼び出し、ワーカー実行スクリプトに送信する必要がある初期メタデータを IMC に書き込みます。

初期メタデータとは何ですか?つまり、スクリプト名、ワーカー データ、PMC のポート 2、およびその他の情報など、ワーカーを起動するためにスクリプトが知っておく必要のあるデータです。

この例では、初期化メタデータは次のようになります。

:phone: やあ!ワーカーはスクリプトを実行します。ワーカー データ {num: 5} などを使用して、worker-simple.js を実行してください。ワーカーが PMC からデータを読み取れるように、PMC のポート 2 も渡してください。

次のスニペットは、初期化データが IMC に書き込まれる方法を示しています。

const kPublicPort = シンボル('kPublicPort');
// ...

const { port1, port2 } = 新しい MessageChannel();
this[kPublicPort] = ポート1;
this[kPublicPort].on('message', (message) => this.emit('message', message));
// ...

this[kPort].postMessage({
  タイプ: 'loadScript',
  ファイル名、
  doEval: !!options.eval,
  cwdCounter: cwdCounter || workerIo.sharedCwdCounter、
  ワーカーデータ: options.workerData、
  パブリックポート: port2、
  // ...
  標準入力あり: !!options.stdin
}, [ポート2]);

コード内のこの[kPort]は、初期化スクリプト内のIMCのエンドポイントです。ワーカー初期化スクリプトは IMC にデータを書き込みますが、ワーカー実行スクリプトはそのデータにアクセスできません。

実行手順

この時点で初期化は完了です。次に、ワーカー初期化スクリプトが C++ を呼び出してワーカー スレッドを開始します。

1. 新しい V8 アイソレートが作成され、ワー​​カーに割り当てられます。前述したように、「v8 isolate」は Chrome V8 ランタイムの別のインスタンスです。これにより、ワーカー スレッドの実行コンテキストがアプリケーション コードの残りの部分から分離されます。

2.libuvが初期化されます。これにより、ワーカー スレッドがアプリケーションの他の部分から独立して独自のイベント ループを維持することが保証されます。

3. ワーカー実行スクリプトが実行され、ワー​​カーのイベント ループが開始されます。

4. ワーカーは C++ を呼び出すスクリプトを実行し、IMC から初期化メタデータを読み取ります。

5. ワーカーはスクリプトを実行し、対応するファイルまたはコード (この場合は worker-simple.js) を実行して、ワーカーとして実行を開始します。

ワーカー実行スクリプトが IMC からデータを読み取る方法については、次のコード スニペットを参照してください。

const publicWorker = require('worker_threads');

// ...

port.on('メッセージ', (メッセージ) => {
  メッセージタイプ === 'loadScript' の場合 {
    定数{
      cwdカウンタ、
      ファイル名、
      実行、
      ワーカーデータ、
      パブリックポート、
      マニフェストSrc、
      マニフェストURL、
      標準入力を持つ
    } = メッセージ;

    // ...
    CJSLoader() を初期化します。
    ESMLoader() を初期化します。
    
    publicWorker.parentPort = publicPort;
    ワーカーデータを作成します。

    // ...
    
    ポートの参照を解除します。
    ポート.postMessage({ タイプ: UP_AND_RUNNING });
    if (doEval) {
      const { evalScript } = require('internal/process/execution');
      evalScript('[ワーカーeval]', ファイル名);
    } それ以外 {
      process.argv[1] = filename; // スクリプトファイル名
      'module' が必要です。runMain();
    }
  }
  // ...

上記のスニペットで、workerData プロパティと parentPort プロパティが publicWorker オブジェクトに割り当てられていることに気付きましたか?後者は、ワーカー実行スクリプトの require('worker_threads') によって導入されます。

そのため、workerData プロパティと parentPort プロパティは子ワーカー スレッド内でのみ使用でき、親ワーカーのコード内では使用できません。

親ワーカー コードでいずれかのプロパティにアクセスしようとすると、null が返されます。

ワーカースレッドを最大限に活用する

Node.js ワーカー スレッドがどのように機能するかを理解したので、ワーカー スレッドを使用するときに最高のパフォーマンスを得るのに役立ちます。 worker-simple.js よりも複雑なアプリケーションを作成する場合、留意すべき主な考慮事項が 2 つあります。

ワーカー スレッドは実際のプロセスよりも軽量ですが、ワーカーを頻繁に重い作業に割り当てるとコストがかかる可能性があります。

並列 I/O 操作を処理するためにワーカー スレッドを使用することは、依然としてコスト効率が良くありません。これは、同じことを実行するワーカー スレッドを最初から開始するよりも、Node.js のネイティブ I/O メカニズムの方が高速な方法だからです。

ポイント 1 の問題を克服するには、「ワーカー スレッド プール」を実装する必要があります。

ワーカースレッドプール

Node.js ワーカー スレッド プールは、実行中であり、後続のタスクで使用できるワーカー スレッドのセットです。新しいタスクが到着すると、親子メッセージ チャネルを介して利用可能なワーカーに渡すことができます。タスクが完了すると、子ワーカーは同じメッセージ チャネルを通じて結果を親ワーカーに返すことができます。

スレッド プールを適切に実装すると、新しいスレッドを作成するオーバーヘッドが削減され、パフォーマンスが大幅に向上します。また、効果的に実行できる並列スレッドの数は常にハードウェアによって制限されるため、膨大な数のスレッドを作成してもうまく機能する可能性は低いことにも注意してください。

次の図は、文字列を受信し、12 ラウンドのソルト処理を施した Bcrypt ハッシュを返す 3 つの Node.js サーバーのパフォーマンス比較です。 3 つのサーバーは次のとおりです。

  • マルチスレッドなし
  • マルチスレッド、スレッドプールなし
  • 4つのスレッドを持つスレッドプール

一見すると、スレッド プールを使用すると、負荷が増大してもオーバーヘッドが大幅に減少することがわかります。

ただし、この記事の執筆時点では、スレッド プールは Node.js のネイティブ機能ではありません。したがって、サードパーティの実装に依存するか、独自のワーカー プールを作成する必要があります。

これで、ワーカー スレッドの仕組みについて十分に理解し、ワーカー スレッドを試して活用し、CPU 依存のアプリケーションを作成できるようになると思います。

上記は、Node.js のワーカースレッドを深く理解するための詳細な内容です。Node.js の詳細については、123WORDPRESS.COM の他の関連記事に注目してください。

以下もご興味があるかもしれません:
  • プロセス解析を使用する Javascript Web Worker
  • Yii2 と Workerman の Websocket の例を組み合わせた詳細な説明
  • JavaScript での Web ワーカー マルチスレッド API の研究
  • 複数ページ通信を実現する JavaScript の sharedWorker の詳細な例
  • nodejs で worker_threads を使用して新しいスレッドを作成する方法
  • Javascript ワーカー サブスレッド コード例
  • JavaScript でのワーカー イベント API の理解
  • JS で webWorker を使用する方法

<<:  MySQLの文字セット設定を5分で理解しましょう

>>:  Linux の文字端末でマウスを使って赤い四角形を移動する方法

推薦する

VMware vSAN 入門概要

1. 背景1. vSphere の共有ストレージの背景を簡単に紹介するvSphere の重要な機能は...

CSSは5つの一般的な2D変換を実装します

CSS の 2D 変換を使用すると、移動、回転、拡大縮小、変形などの基本的な変換操作を 2 次元空間...

コーディングスキルを向上させるためのJavaScriptのヒント

目次1. 一意の値をフィルタリングする2. 短絡評価2.1 シナリオ例3. ブール変換4. 文字列を...

Bootstrap 3.0 学習ノートのボタンとドロップダウン メニュー

前回の記事はBootstrap CSS部分の簡単なレビューであり、多くの詳細が見落とされていました。...

つまり、フィルターコレクション

IE は開発の初期段階では頭を悩ませましたが、他のブラウザとは異なります。他のブラウザがサポートして...

Dockerコンテナ接続実装手順の分析

一般的に言えば、コンテナが起動した後、ポート マッピングを通じてコン​​テナが提供するサービスを使用...

MySQL マルチマスターと 1 スレーブのデータバックアップ方法のチュートリアル

概要いずれかのデータベースに対する操作は他のデータベースに自動的に適用され、2 つのデータベースのデ...

スタイルを書く際の背景色宣言の重要性

タイトルの通り、ページを修正すると以下のような状況が発生する可能性があります。現在、古いページを改修...

Nginx 転送ソケットポート設定の詳細な説明

Nginx によるソケット ポート転送の一般的なシナリオ: オンライン学習アプリケーションでは、通常...

MySQL ルートパスワードエラー番号 1045 の解決方法

MySQLサービスを停止するWindowsでは、マイコンピュータを右クリック--管理--サービスと...

MySQL 5.7 MGR シングルマスター決定マスターノード方式の詳細説明

当銀行のMGRは年末に開始されます。公式文書を読んだり、毎日テストを受けたりしなければなりません。毎...

Vue ページに img 画像を導入する方法

HTMLを学ぶとき、画像タグ<img>は画像を導入します <img src=&qu...

Dockerは元のタグのイメージの再タグ付けと削除を実装します

docker イメージ ID は一意であり、イメージを物理的に識別できます。repository: ...

Vue はファジークエリを実装します - MySQL データベースデータ

目次1. 需要2. 実装3. 結果1. 需要入力ボックスにデータを入力し、入力結果に基づいてデータベ...

MySql のグループ化と各グループからランダムに 1 つのデータを取得する

アイデア: 最初にランダムに並べ替えてからグループ化します。 1. テーブルを作成します。 テーブル...