メインコンテンツへ移動

Node.jsアーキテクチャの徹底解剖

無料2020-04-26#Node#Nodejs C++ Bindings#Nodejs运行原理#Nodejs底层实现#Nodejs事件队列#Nodejs与libuv

毎日使っているNode.js。それがどのように実現されているか考えたことはありますか?

1. Node.jsが創り上げた伝説

I have a job now, and this guy is the reason why I have that now. His hobby project is what I use for living. Thanks. —— Shajan Jacob

2009年、Ryan Dahl氏はJSConf EUにてNode.jsを発表しました。当初は、非同期モデルを通じて従来のWebサーバーの高並列処理におけるボトルネックを打破することを目的としていましたが、その後着実に発展を遂げ、利用シーンは広がり続け、今や繁栄を極めるNode.jsエコシステムが形成されました。

Node.jsのおかげでブラウザの外に飛び出したJavaScriptは、勢いが止まらなくなりました

Any application that can be written in JavaScript, will eventually be written in JavaScript. —— Jeff Atwood

The Principle of Least Power より抜粋)

早くも2017年には、NPMは膨大なコミュニティモジュールを背景に世界最大のパッケージレジストリとなり、現在のモジュール数は125万を超え、今なお急速に成長し続けています(1日あたり900以上増加)。

Node.jsエンジニアという新しい職業まで誕生しましたが、では、伝説的な色彩を帯びたNode.js自体はどのように実現されているのでしょうか?

2. Node.jsアーキテクチャ概要

JSコードはV8エンジン上で動作し、Node.jsに内蔵されたfshttpなどのコアモジュールは、C++ Bindingsを通じてlibuv、c-ares、llhttpなどのC/C++ライブラリを呼び出し、OSが提供するプラットフォーム機能にアクセスします。

その中でも、最も重要な部分はV8libuvです

3. ソースコードの依存関係

V8

V8 is Google’s open source high-performance JavaScript and WebAssembly engine, written in C++. It is used in Chrome and in Node.js, among others.

C++で書かれたJavaScriptエンジンで、Googleによってメンテナンスされており、ChromeブラウザやNode.jsなどで使用されています。

libuv

libuv is cross-platform support library which was originally written for Node.js. It’s designed around the event-driven asynchronous I/O model.

Node.jsのために特別に作られた、Cで書かれたクロスプラットフォームの非同期I/Oライブラリです。非ブロックファイルシステム、DNS、ネットワーク、子プロセス、パイプ、信号、ポーリング、ストリーミング処理のメカニズムを提供します:

OSレベルで非同期に処理できないタスク(ファイルI/OやDNSクエリなど)については、スレッドプールを使用して完了させます。詳細な理由はComplexities in File I/Oを参照してください。

P.S. スレッドプールの容量は設定可能で、デフォルトは4スレッドです。詳細はThread pool work schedulingを参照してください。

さらに、Node.jsにおけるイベントループやイベントキューも、すべてlibuvによって提供されています

Libuv provides the entire event loop functionality to NodeJS including the event queuing mechanism.

具体的な動作メカニズムは以下の通りです:

その他の依存ライブラリ

また、以下のようなC/C++ライブラリにも依存しています:

  • llhttp:TypeScriptとCで書かれた軽量なHTTP解析ライブラリです。従来のhttp_parserより1.5倍高速で、システムコールやメモリ割り当てを一切行わない(データもキャッシュしない)ため、リクエストごとのメモリ消費量が極めて少なくなっています。

  • c-ares:非同期のDNSリクエストを処理するためのCライブラリで、Node.jsのdnsモジュールが提供するresolve()系のメソッドに対応しています。

  • OpenSSL:汎用的な暗号ライブラリで、主にネットワーク転送におけるTLSやSSLプロトコルの実装に使用されます。Node.jsのtlscryptoモジュールに対応しています。

  • zlib:高速な圧縮・解凍機能を提供します。

P.S. Node.jsのソースコード依存関係に関する詳細は、Dependenciesを参照してください。

4. コアモジュール

ブラウザが提供するDOM/BOM APIのように、Node.jsはJavaScript実行環境を提供するだけでなく、一連のプラットフォームAPIも拡張しています。例えば:

  • ファイルシステム関連:fsモジュールに対応

  • HTTP通信:httpモジュールに対応

  • OS関連:osモジュールに対応

  • マルチプロセス:child_processclusterモジュールに対応

これらの内蔵モジュールはコアモジュールと呼ばれ、ブラウザの世界から飛び出したJavaScriptに手足を与えました

5. C++ Bindings

コアモジュールの下には、C++ Bindingsの層があり、上層のJavaScriptコードと下層のC/C++ライブラリを橋渡ししています

下層のモジュールはパフォーマンス向上のためにC/C++で実装されていますが、上層のJavaScriptコードは直接C/C++と通信できないため、橋渡し役(すなわちBinding)が必要になります:

Bindings, as the name implies, are glue codes that “bind” one language with another so that they can talk with each other. In this case (Node.js), bindings simply expose core Node.js internal libraries written in C/C++ (c-ares, zlib, OpenSSL, llhttp, etc.) to JavaScript.

また、Bindingsを通じて、信頼できる定評のあるオープンソースライブラリを再利用することができ、すべての低層モジュールを一から自作する必要がなくなります。

ファイルI/Oを例にとると、現在のJSファイルの内容を読み取って標準出力に表示する場合:

// readThisFile.js
const fs = require('fs')
const path = require('path')
const filePath = path.resolve(__filename);

// Parses the buffer into a string
function callback (data) {
  return data.toString()
}

// Transforms the function into a promise
const readFileAsync = (filePath) => {
  return new Promise((resolve, reject) => {
    fs.readFile(filePath, (err, data) => {
      if (err) return reject(err)
      return resolve(callback(data))
    })
  })
}

(() => {
  readFileAsync(filePath)
    .then(console.log)
    .catch(console.error)
})()

しかし、ここで使われているfs.readFileインターフェースは、V8が提供しているものでもJS標準のものでもなく、Node.jsがC++ Bindingの形式でlibuvを利用して実現しているものです:

// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L58
const binding = internalBinding('fs');
// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L71
const { FSReqCallback, statValues } = binding;

// https://github.com/nodejs/node/blob/v14.0.0/lib/fs.js#L297
function readFile(path, options, callback) {
  callback = maybeCallback(callback || options);
  options = getOptions(options, { flag: 'r' });
  if (!ReadFileContext)
    ReadFileContext = require('internal/fs/read_file_context');
  const context = new ReadFileContext(callback, options.encoding);
  context.isUserFd = isFd(path); // File descriptor ownership

  const req = new FSReqCallback();
  req.context = context;
  req.oncomplete = readFileAfterOpen;

  if (context.isUserFd) {
    process.nextTick(function tick() {
      req.oncomplete(null, path);
    });
    return;
  }

  path = getValidatedPath(path);
  const flagsNumber = stringToFlags(options.flags);
  binding.open(pathModule.toNamespacedPath(path),
              flagsNumber,
              0o666,
              req);
}

最後のbinding.openはC++の呼び出しであり、ファイルディスクリプタを開くために使用されます。3つの引数はそれぞれファイルパス、C++ fopenのファイルアクセスモード文字列(rw+など)、8進数形式のファイル読み書き権限(666は全員に読み書き権限があることを示す)、そして戻りデータを受け取るreqコールバックです。

その中で、internalBindingはC++ binding loaderであり、internalBinding('fs')が実際にロードするC++コードは node/src/node_file.cc にあります。

これで、重要な部分はほぼ明確になりました。では、Node.jsのコードは一体どのように動いているのでしょうか?

6. 動作原理

まず、作成されたJavaScriptコードはV8エンジンによって実行されます。実行中に登録されたイベントリスナーは保持され、対応するイベントが発生したときに通知を受け取ります。

ネットワークやファイルI/Oなどのイベントが発生すると、登録されたコールバック関数がイベントキューに並びます。その後、イベントループによって取り出され、コールスタックに積まれます。コールバック関数の実行が終了(コールスタックが空に)すると、イベントループはまた次のものを一つ取り出して積みます……

実行中にI/O操作に遭遇すると、libuvのスレッドプール内のいずれかのworkerに処理を任せます。処理が終わるとlibuvがイベントを発生させ、イベントキューに入れます。イベントループが戻りイベントを処理するときに初めて、対応するコールバック関数がメインスレッドで実行を開始します。メインスレッドはその間、他の作業を続け、ブロックされることなく待機します。

Node.jsはカフェのようなものです。店にはホールスタッフ(メインスレッド)が一人しかいません。大勢の客が押し寄せると、行列を作って待ちます(イベントキューに入る)。番号が呼ばれた客の注文はマネージャー(libuv)に伝えられ、マネージャーはバリスタ(workerスレッド)に注文を割り振ります。バリスタは様々な材料と道具(低層で依存しているC/C++モジュール)を使って、注文された様々なコーヒーを作ります。通常、4人のバリスタが当番をしていますが、ピーク時には増えることもあります。注文をマネージャーに伝えた後、コーヒーができるのを待つのではなく、すぐに次の注文を処理します。コーヒーが一杯出来上がると、受け渡しカウンター(IO Eventsキュー)に置かれます。フロントに届くと、スタッフが名前を呼び、客が受け取りに来ます。

参考資料

コメント

コメントはまだありません

コメントを書く