Node.js内部の探索
公開: 2022-03-102009年11月8日にヨーロッパのJSConfでRyanDahlがNode.jsを紹介して以来、テクノロジー業界全体で広く使用されています。 Netflix、Uber、LinkedInなどの企業は、Node.jsが大量のトラフィックと同時実行性に耐えることができるという主張に信頼を寄せています。
基本的な知識を身につけたNode.jsの初心者および中級の開発者は、「これは単なるランタイムです!」という多くのことに苦労しています。 「イベントループがあります!」 「Node.jsはJavaScriptのようにシングルスレッドです!」
これらの主張のいくつかは真実ですが、Node.jsランタイムを深く掘り下げ、JavaScriptの実行方法を理解し、実際にシングルスレッドであるかどうかを確認し、最後に、コア依存関係であるV8とlibuvの間の相互接続をよりよく理解します。 。
前提条件
- JavaScriptの基礎知識
- Node.jsセマンティクスに精通している(
require
、fs
)
Node.jsとは何ですか?
多くの人がNode.jsについて信じていることを推測したくなるかもしれません。最も一般的な定義は、JavaScript言語のランタイムであるということです。 これを検討するには、何がこの結論に至ったのかを理解する必要があります。
Node.jsは、多くの場合、C ++とJavaScriptの組み合わせとして説明されます。 C ++の部分は、コンピューターに接続されているハードウェアにアクセスできるようにする低レベルのコードを実行するバインディングで構成されています。 JavaScriptの部分は、JavaScriptをソースコードとして受け取り、V8エンジンという名前の言語の一般的なインタープリターで実行します。
この理解により、Node.jsは、JavaScriptとC ++を組み合わせてブラウザー環境の外部でプログラムを実行する独自のツールとして説明できます。
しかし、実際にそれをランタイムと呼ぶことができますか? それを決定するために、ランタイムとは何かを定義しましょう。
ランタイムとは何ですか? https://t.co/eaF4CoWecX
— Christian Nwamba(@codebeast)2020年3月5日
StackOverflowに関する彼の回答の1つで、DJNAはランタイム環境を「プログラムを実行するために必要なすべてのものですが、プログラムを変更するためのツールはありません」と定義しています。 この定義によれば、コードの実行中に発生するすべてのことは(任意の言語で)ランタイム環境で実行されていると自信を持って言えます。
他の言語には独自のランタイム環境があります。 Javaの場合、これはJavaランタイム環境(JRE)です。 .NETの場合、これは共通言語ランタイム(CLR)です。 Erlangの場合はBEAMです。
それにもかかわらず、これらのランタイムの一部には、それらに依存する他の言語があります。 たとえば、Javaには、JREが理解できるコードにコンパイルされるプログラミング言語であるKotlinがあります。 ErlangにはElixirがあります。 また、.NET開発には多くのバリエーションがあり、それらはすべて.NETFrameworkと呼ばれるCLRで実行されます。
これで、ランタイムはプログラムを正常に実行できるようにするために提供される環境であり、V8とC ++ライブラリのホストによってNode.jsアプリケーションの実行が可能になることがわかりました。 Node.js自体は、すべてをバインドしてこれらのライブラリをエンティティにする実際のランタイムであり、Node.jsの構築に関係なく、1つの言語(JavaScript)のみを理解します。
Node.jsの内部構造
コマンドnode index.js
を使用してコマンドラインからNode.jsプログラム( index.js
など)を実行しようとすると、Node.jsランタイムが呼び出されます。 前述のように、このランタイムは2つの独立した依存関係、V8とlibuvで構成されています。
V8は、Googleによって作成および保守されているプロジェクトです。 JavaScriptのソースコードを受け取り、ブラウザ環境の外で実行します。 node
コマンドを使用してプログラムを実行すると、ソースコードがNode.jsランタイムによってV8に渡されて実行されます。
libuvライブラリには、オペレーティングシステムへの低レベルのアクセスを可能にするC ++コードが含まれています。 ネットワーク、ファイルシステムへの書き込み、同時実行などの機能は、JavaScriptコードを実行するNode.jsの一部であるV8ではデフォルトで出荷されていません。 ライブラリのセットを使用して、libuvはNode.js環境でこれらのユーティリティなどを提供します。
Node.jsは、2つのライブラリをまとめる接着剤であり、それによって独自のソリューションになります。 スクリプトの実行中、Node.jsは、どのプロジェクトにいつ制御を渡すかを理解します。
サーバーサイドプログラム用の興味深いAPI
JavaScriptの歴史を少し調べてみると、JavaScriptがブラウザのページに機能とインタラクションを追加することを意図していることがわかります。 また、ブラウザでは、ページを構成するドキュメントオブジェクトモデル(DOM)の要素を操作します。 このために、一連のAPIが存在し、まとめてDOMAPIと呼ばれます。
DOMはブラウザにのみ存在します。 これは、ページをレンダリングするために解析されるものであり、基本的にHTMLと呼ばれるマークアップ言語で記述されています。 また、ブラウザはウィンドウ内に存在するため、 window
オブジェクトは、JavaScriptコンテキストでページ上のすべてのオブジェクトのルートとして機能します。 この環境はブラウザ環境と呼ばれ、JavaScriptのランタイム環境です。
Node.js環境では、ページやブラウザーのようなものはありません。これにより、グローバルウィンドウオブジェクトに関する知識が無効になります。 私たちが持っているのは、オペレーティングシステムと対話してJavaScriptプログラムに追加機能を提供する一連のAPIです。 Node.js用のこれらのAPI( fs
、 path
、 buffer
、 events
、 HTTP
など)は、Node.jsに対してのみ存在し、Node.js(それ自体がランタイム)によって提供されるため、 Node.js用に作成されたプログラムを実行できます。
実験: fs.writeFile
が新しいファイルを作成する方法
V8がブラウザーの外部でJavaScriptを実行するために作成され、Node.js環境がブラウザーと同じコンテキストまたは環境を持たない場合、ファイルシステムにアクセスしたり、HTTPサーバーを作成したりするにはどうすればよいでしょうか。
例として、現在のディレクトリのファイルシステムにファイルを書き込む単純なNode.jsアプリケーションを見てみましょう。
const fs = require("fs") fs.writeFile("./test.txt", "text");
示されているように、ファイルシステムに新しいファイルを書き込もうとしています。 この機能はJavaScript言語では使用できません。 Node.js環境でのみ使用できます。 これはどのように実行されますか?
これを理解するために、Node.jsコードベースのツアーを見てみましょう。
Node.jsのGitHubリポジトリに移動すると、 src
とlib
の2つのメインフォルダーが表示されます。 lib
フォルダーには、すべてのNode.jsインストールにデフォルトで含まれているモジュールの優れたセットを提供するJavaScriptコードが含まれています。 src
フォルダーには、libuv用のC ++ライブラリが含まれています。
lib
フォルダーを調べてfs.js
ファイルを調べると、印象的なJavaScriptコードでいっぱいであることがわかります。 1880行目には、 exports
ステートメントがあります。 このステートメントは、 fs
モジュールをインポートすることでアクセスできるすべてのものをエクスポートし、 writeFile
という名前の関数をエクスポートしていることがわかります。
function writeFile(
(関数が定義されている場所)を検索すると、1303行目に移動します。ここで、関数が4つのパラメーターで定義されていることがわかります。
function writeFile(path, data, options, callback) { callback = maybeCallback(callback || options); options = getOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'w' }); const flag = options.flag || 'w'; if (!isArrayBufferView(data)) { validateStringAfterArrayBufferView(data, 'data'); data = Buffer.from(data, options.encoding || 'utf8'); } if (isFd(path)) { const isUserFd = true; writeAll(path, isUserFd, data, 0, data.byteLength, callback); return; } fs.open(path, flag, options.mode, (openErr, fd) => { if (openErr) { callback(openErr); } else { const isUserFd = false; writeAll(fd, isUserFd, data, 0, data.byteLength, callback); } }); }
1315行目と1324行目では、いくつかの検証チェックの後に、単一の関数writeAll
が呼び出されていることがわかります。 この関数は、同じfs.js
ファイルの1278行目にあります。
function writeAll(fd, isUserFd, buffer, offset, length, callback) { // write(fd, buffer, offset, length, position, callback) fs.write(fd, buffer, offset, length, null, (writeErr, written) => { if (writeErr) { if (isUserFd) { callback(writeErr); } else { fs.close(fd, function close() { callback(writeErr); }); } } else if (written === length) { if (isUserFd) { callback(null); } else { fs.close(fd, callback); } } else { offset += written; length -= written; writeAll(fd, isUserFd, buffer, offset, length, callback); } }); }
このモジュールがそれ自体を呼び出そうとしていることに注意することも興味深いです。 これは1280行目にあり、 fs.write
を呼び出しています。 write
機能を探すと、ちょっとした情報が見つかります。
write
関数は571行目から始まり、約42行実行されます。 この関数には繰り返しパターンがあります。594行目と612行目に見られるように、 binding
モジュールの関数を呼び出す方法です。 binding
モジュールの関数は、この関数だけでなく、エクスポートされる実質的にすべての関数で呼び出されます。 fs.js
ファイルファイル内。 何か特別なものに違いない。
binding
変数はファイルの最上部の58行目で宣言されており、その関数呼び出しをクリックすると、GitHubを使用していくつかの情報が表示されます。
このinternalBinding
関数は、loadersという名前のモジュールにあります。 ローダーモジュールの主な機能は、すべてのlibuvライブラリをロードし、それらをV8プロジェクトを介してNode.jsに接続することです。 これをどのように行うかはかなり魔法のようなものですが、詳細については、 fs
モジュールによって呼び出されるwriteBuffer
関数を詳しく調べることができます。
これがlibuvと接続する場所と、V8が登場する場所を確認する必要があります。ローダーモジュールの上部にあるいくつかの優れたドキュメントには、次のように記載されています。
// This file is compiled and run by node.cc before bootstrap/node.js // was called, therefore the loaders are bootstraped before we start to // actually bootstrap Node.js. It creates the following objects: // // C++ binding loaders: // - process.binding(): the legacy C++ binding loader, accessible from user land // because it is an object attached to the global process object. // These C++ bindings are created using NODE_BUILTIN_MODULE_CONTEXT_AWARE() // and have their nm_flags set to NM_F_BUILTIN. We do not make any guarantees // about the stability of these bindings, but still have to take care of // compatibility issues caused by them from time to time. // - process._linkedBinding(): intended to be used by embedders to add // additional C++ bindings in their applications. These C++ bindings // can be created using NODE_MODULE_CONTEXT_AWARE_CPP() with the flag // NM_F_LINKED. // - internalBinding(): the private internal C++ binding loader, inaccessible // from user land unless through `require('internal/test/binding')`. // These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL() // and have their nm_flags set to NM_F_INTERNAL. // // Internal JavaScript module loader: // - NativeModule: a minimal module system used to load the JavaScript core // modules found in lib/**/*.js and deps/**/*.js. All core modules are // compiled into the node binary via node_javascript.cc generated by js2c.py, // so they can be loaded faster without the cost of I/O. This class makes the // lib/internal/*, deps/internal/* modules and internalBinding() available by // default to core modules, and lets the core modules require itself via // require('internal/bootstrap/loaders') even when this file is not written in // CommonJS style.
ここで学習するのは、Node.jsプロジェクトのJavaScriptセクションのbinding
オブジェクトから呼び出されたすべてのモジュールについて、 src
フォルダーのC ++セクションに同等のものがあるということです。
fs
ツアーから、これを行うモジュールがnode_file.cc
にあることがわかります。 モジュールを介してアクセスできるすべての関数は、ファイルで定義されています。 たとえば、2258行目にwriteBuffer
があります。C++ファイルでのそのメソッドの実際の定義は1785行目です。また、ファイルへの実際の書き込みを行うlibuvの部分の呼び出しは、1809行目と1815、libuv関数uv_fs_write
が非同期的に呼び出されます。
この理解から何が得られますか?
他の多くのインタプリタ言語ランタイムと同様に、Node.jsのランタイムはハッキングされる可能性があります。 理解を深めることで、ソースを調べるだけでは、標準の配布では不可能なことができるようになります。 ライブラリを追加して、一部の関数の呼び出し方法を変更することができます。 しかし何よりも、この理解はさらなる調査の基盤です。
Node.jsはシングルスレッドですか?
node.jsはlibuvとV8にあり、ブラウザーで実行されている一般的なJavaScriptエンジンにはないいくつかの追加機能にアクセスできます。
ブラウザで実行されるJavaScriptはすべて、シングルスレッドで実行されます。 プログラムの実行中のスレッドは、プログラムが実行されているCPUの上にあるブラックボックスのようなものです。 Node.jsコンテキストでは、一部のコードは、マシンが実行できる限り多くのスレッドで実行される可能性があります。
この特定の主張を検証するために、簡単なコードスニペットを調べてみましょう。
const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test.txt", "test", (err) => { If (error) { console.log(err) } console.log("1 Done: ", Date.now() — startTime) });
上記のスニペットでは、現在のディレクトリのディスクに新しいファイルを作成しようとしています。 これにかかる時間を確認するために、スクリプトの開始時間を監視するための小さなベンチマークを追加しました。これにより、ファイルを作成しているスクリプトの期間がミリ秒単位で示されます。
上記のコードを実行すると、次のような結果が得られます。
$ node ./test.js -> 1 Done: 0.003s
これは非常に印象的です:わずか0.003秒。
しかし、本当に面白いことをしましょう。 まず、新しいファイルを生成するコードを複製し、ログステートメントの番号を更新してそれらの位置を反映させましょう。
const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test1.txt", "test", function (err) { if (err) { console.log(err) } console.log("1 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test2.txt", "test", function (err) { if (err) { console.log(err) } console.log("2 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test3.txt", "test", function (err) { if (err) { console.log(err) } console.log("3 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test4.txt", "test", function (err) { if (err) { console.log(err) } console.log("4 Done: %ss", (Date.now() — startTime) / 1000) });
このコードを実行しようとすると、心を打たれるようなものが得られます。 これが私の結果です:
まず、結果に一貫性がないことに気付くでしょう。 第二に、時間が増加していることがわかります。 何が起こっていますか?
低レベルのタスクが委任される
私たちが今知っているように、Node.jsはシングルスレッドです。 Node.jsの一部はJavaScriptで記述され、その他の部分はC ++で記述されています。 Node.jsは、ブラウザー環境でよく知っているイベントループと呼び出しスタックの同じ概念を使用します。つまり、Node.jsのJavaScript部分はシングルスレッドです。 ただし、オペレーティングシステムとの通信を必要とする低レベルのタスクは、シングルスレッドではありません。
Node.jsによって呼び出しがlibuvを対象としていると認識されると、このタスクがlibuvに委任されます。 その操作では、libuvはそのライブラリの一部にスレッドを必要とするため、必要なときにNode.jsプログラムを実行する際にスレッドプールを使用します。
デフォルトでは、libuvによって提供されるNode.jsスレッドプールには4つのスレッドがあります。 スクリプトの先頭でprocess.env.UV_THREADPOOL_SIZE
を呼び出すことで、このスレッドプールを増減できます。
// script.js process.env.UV_THREADPOOL_SIZE = 6; // … // …
私たちのファイル作成プログラムで何が起こるか
コードを呼び出してファイルを作成すると、Node.jsはそのコードのlibuv部分にヒットし、このタスク専用のスレッドになります。 libuvのこのセクションは、ファイルで作業する前に、ディスクに関するいくつかの統計情報を取得します。
この統計チェックは、完了するまでに時間がかかる場合があります。 したがって、統計チェックが完了するまで、スレッドは他のいくつかのタスクのために解放されます。 チェックが完了すると、libuvセクションは使用可能なスレッドを占有するか、スレッドが使用可能になるまで待機します。
呼び出しは4つ、スレッドは4つしかないため、十分なスレッドがあります。 唯一の問題は、各スレッドがそのタスクを処理する速度です。 スレッドプールに入れる最初のコードが最初に結果を返し、コードの実行中に他のすべてのスレッドをブロックすることに気付くでしょう。
結論
これで、Node.jsとは何かがわかりました。 ランタイムであることはわかっています。 ランタイムとは何かを定義しました。 そして、Node.jsによって提供されるランタイムを構成するものを深く掘り下げました。
私たちは長い道のりを歩んできました。 また、GitHubのNode.jsリポジトリの小さなツアーから、ここで行ったのと同じプロセスに従って、関心のあるAPIを探索できます。 Node.jsはオープンソースなので、確かにソースに飛び込むことができますね。
Node.jsランタイムで発生する低レベルのいくつかに触れましたが、すべてを知っていると想定してはなりません。 以下のリソースは、知識を構築するための情報を示しています。
- Node.jsの紹介
公式ウェブサイトであるNode.devは、Node.jsとそのパッケージマネージャーについて説明し、その上に構築されたWebフレームワークを一覧表示します。 - 「JavaScript&Node.js」、ノード初心者向けブック
Manuel Kiesslingによるこの本は、両方が同じ言語で書かれていても、ブラウザーのJavaScriptがNode.jsのJavaScriptと同じではないことを警告した後、Node.jsを説明する素晴らしい仕事をしています。 - Node.jsの開始
この初心者向けの本は、ランタイムの説明を超えています。 パッケージとストリーム、およびExpressフレームワークを使用したWebサーバーの作成について説明します。 - LibUV
これは、Node.jsランタイムのサポートC ++コードの公式ドキュメントです。 - V8
これは、JavaScriptを使用してNode.jsを記述できるようにするJavaScriptエンジンの公式ドキュメントです。