Read OSS

C++↔JavaScript ブリッジ:BaseObject、Wrap、そしてバインディング

上級

前提知識

  • 第1回:architecture-overview
  • 第2回:startup-and-bootstrap
  • C++ の基礎知識(テンプレート、RAII、スマートポインタ)
  • V8 の埋め込みに関する概念(Isolate、Context、HandleScope、FunctionCallbackInfo、ObjectTemplate)

C++↔JavaScript ブリッジ:BaseObject、Wrap、そしてバインディング

TCP ソケットを開いたり、ファイルを読んだり、タイマーをセットしたりするとき、Node.js では内部で C++ オブジェクトが生成され、JavaScript オブジェクトに紐付けられています。このバインディングの仕組みは、コードベース全体を通じてもっともアーキテクチャ的に重要なパターンです。Node.js が V8 という JavaScript エンジンをフルランタイムへと変貌させる核心がここにあります。クラス階層、バインディングローダー、JS↔C++ 境界をまたぐデータフローを理解することは、Node.js へのコントリビューションやネイティブアドオン開発において欠かせない知識です。

Wrap クラス階層

Node.js のすべての I/O プリミティブの土台となるのが、BaseObject をルートとするクラス階層です。この階層は、V8 の内部フィールド機構を通じて C++ オブジェクトと JavaScript オブジェクトを対応付けます。

classDiagram
    class BaseObject {
        +Realm* realm_
        +Global~Object~ persistent_
        +object() Local~Object~
        +env() Environment*
        +MakeWeak()
    }
    
    class AsyncWrap {
        +ProviderType provider_type_
        +double async_id_
        +double trigger_async_id_
        +MakeCallback()
        +EmitAsyncInit()
    }
    
    class HandleWrap {
        +uv_handle_t* handle_
        +Close()
        +Ref() / Unref()
        +GetHandle()
    }
    
    class ReqWrap~T~ {
        +T req_
        +Dispatch(fn, args...)
        +Cancel()
    }
    
    class LibuvStreamWrap {
        +ReadStart() / ReadStop()
        +DoShutdown()
        +DoWrite()
    }
    
    class ConnectionWrap~WrapType UVType~ {
        +UVType handle_
        +OnConnection()
        +AfterConnect()
    }
    
    class TCPWrap {
        +Initialize()
        +New()
        +Bind() / Listen()
    }
    
    BaseObject <|-- AsyncWrap
    AsyncWrap <|-- HandleWrap
    AsyncWrap <|-- ReqWrap
    HandleWrap <|-- LibuvStreamWrap
    LibuvStreamWrap <|-- ConnectionWrap
    ConnectionWrap <|-- TCPWrap

BaseObject はこの階層のルートです。persistent_ を通じて V8 の Object への弱参照または強参照を保持し、所属する Realm へのポインタも持っています。肝となるのはコンストラクタの動作で、C++ オブジェクト自身へのポインタ(this)を JavaScript オブジェクトの内部フィールドスロット(kSlot)に格納します。これにより、ネイティブリソースをラップした JavaScript オブジェクトがあれば、対応する C++ オブジェクトを O(1) で取り出せます。

AsyncWrap は非同期トラッキングの層を加えます。すべての非同期操作は async_hooks API 向けに async_idtrigger_async_id を持ちます。また MakeCallback() を提供しており、これが C++ から JavaScript を安全にコールバックする唯一の正しい方法です。非同期フックのライフサイクル(init / before / after / destroy)とマイクロタスクのチェックポイントを適切に処理します。

HandleWrap は libuv の uv_handle_t をラップします。TCP ソケット、タイマー、ファイルシステムウォッチャーのように、長期間存在するリソースに対応します。重要なのは ref / unref の仕組みです。参照されているハンドルはイベントループを生かし続けますが、unref されたハンドルはそうしません。setTimeout() がプロセスを終了させない一方、unref() されたタイマーはプロセスを停止させる理由がここにあります。

ReqWrap<T> は libuv の uv_req_t をラップします。ファイル読み込み、DNS ルックアップ、接続試行など、一度限りのリクエストに使われます。テンプレートメソッドである Dispatch() が特に巧みで、リクエストを libuv に送出しつつ、コールバックが Wrap 経由で戻ってくる仕組みを自動でセットアップします。

Environment:神オブジェクト

Environment クラスは 1,264 行のヘッダーファイルで、Node.js の実行コンテキストに必要なすべてを抱えています。「神オブジェクト」と呼ぶのは言い過ぎではなく、これは設計上の意図的な選択です。

classDiagram
    class Environment {
        +Isolate* isolate_
        +uv_loop_t* event_loop_
        +PrincipalRealm* principal_realm_
        +ImmediateInfo immediate_info_
        +TickInfo tick_info_
        +AsyncHooks async_hooks_
        +Permission permission_
        +InspectorAgent* inspector_agent_
        +EnvironmentOptions* options_
        +HandleWrapQueue handle_wrap_queue_
        +ReqWrapQueue req_wrap_queue_
        +GetCurrent(isolate) Environment*
        +CreateEnvironment()
        +RunBootstrapping()
    }

すべての HandleWrapReqWrap のインスタンスは、Environment のキューに自身を登録します。これによりシャットダウンが機能します。Environment が破棄される際、未完了のハンドルやリクエストをすべて走査してクリーンに閉じられます。

GetCurrent() 静的メソッドは、C++ コールバック関数が自分の Environment を見つける手段です。V8 のコールバックは Isolate*FunctionCallbackInfo を受け取りますが、Environment::GetCurrent() は V8 コンテキストの埋め込みデータスロットから Environment を取り出します。負荷の高い Node.js プロセスでは、1 秒間に数百回呼ばれることもあります。

Tips: C++ バインディングを書いていて Environment にアクセスしたい場合は、コールバックに渡された FunctionCallbackInfo を使って Environment::GetCurrent(args) を呼びましょう。Environment のポインタを非同期境界をまたいでキャッシュしてはいけません。無効になる可能性があります。

Realm とバインディングデータ

第2回で見たように、Realm は ECMAScript のレルム(realm)を抽象化したものです。PrincipalRealm はユーザーコードが動く主レルムで、ShadowRealm インスタンスは ShadowRealm JavaScript API によって生成されます。

各 Realm はそれぞれ以下を持ちます。

  • バインディングデータストアBindingDataType でインデックスされた BaseObject 弱ポインタの配列。各 C++ バインディングモジュールはレルムごとのデータをここに登録できます。
  • ベースオブジェクトリスト:このレルムで生成されたすべての BaseObject インスタンスを追跡します。
  • 組み込みモジュールキャッシュ:コードキャッシュを使って、あるいは使わずにコンパイルされた組み込みモジュールを記録します。

Realm の RunBootstrapping() メソッドはまず realm.js(モジュールローダーのセットアップ)を実行し、続いてレルム固有のセットアップのため BootstrapRealm() に処理を委譲します。主レルムの場合は node.js、Web API スクリプト、スレッド切り替えスクリプトの実行が行われます。

X マクロプロパティパターン

Node.js では、"message""code""stack" といった文字列やシンボル、オブジェクトテンプレートなど、何百もの V8 値に素早くアクセスする必要があります。毎回名前でルックアップするのはコストが高くつきます。そこで src/env_properties.h では X マクロパターンを使い、ストレージとアクセサを自動生成しています。

仕組みはこうです。マクロで (プロパティ名, 文字列値) のタプルのリストを一度定義し、別のマクロがそのリストをさまざまな形で「展開」します。

// In env_properties.h — define the list once
#define PER_ISOLATE_PRIVATE_SYMBOL_PROPERTIES(V)                \
  V(arrow_message_private_symbol, "node:arrowMessage")          \
  V(contextify_context_private_symbol, "node:contextify:context") \
  // ... dozens more

#define PER_ISOLATE_STRING_PROPERTIES(V)                        \
  V(__filename_string, "__filename")                             \
  V(__dirname_string, "__dirname")                               \
  // ... hundreds more

IsolateDataEnvironment の中でこれらのマクロがメンバ変数、ゲッター、初期化コードを生成します。"__filename" という文字列は Isolate の生成時に一度だけインターンされ、以降の使用はすべて文字列ルックアップではなく安価なポインタ比較になります。

このパターンは Node.js 全体に登場します。冗長ではありますが、パフォーマンス上の問題とタイポによるバグを根本から排除できます。

3 つのバインディングローダー

Node.js には JavaScript コードが C++ の機能にアクセスするための仕組みが 3 つあります。realm.js のヘッダーコメントにその説明があります。

flowchart TD
    JS["JavaScript Code"] --> IB["internalBinding(name)<br/>Primary mechanism<br/>Internal only"]
    JS --> PB["process.binding(name)<br/>Legacy, deprecated<br/>User-accessible"]
    JS --> LB["process._linkedBinding(name)<br/>For embedders<br/>Linked modules"]
    
    IB --> REG_INT["NODE_BINDING_CONTEXT_AWARE_INTERNAL()<br/>nm_flags = NM_F_INTERNAL"]
    PB --> REG_BUILT["NODE_BUILTIN_MODULE_CONTEXT_AWARE()<br/>nm_flags = NM_F_BUILTIN"]
    LB --> REG_LINK["NODE_BINDING_CONTEXT_AWARE_CPP()<br/>nm_flags = NM_F_LINKED"]
    
    REG_INT --> LOOKUP["node_binding.cc<br/>FindModule() lookup"]
    REG_BUILT --> LOOKUP
    REG_LINK --> LOOKUP

バインディングの登録は src/node_binding.h で定義されています。NODE_BINDINGS_WITH_PER_ISOLATE_INIT マクロは、Isolate ごとの初期化が必要なバインディングの一覧を列挙しています。async_wrapfshttp_parsermodule_wrapworker などがここに含まれます。各バインディングモジュールは Initialize() 関数を持ち、V8 のファンクションテンプレートを生成してターゲットオブジェクトに紐付けます。

JavaScript が internalBinding('fs') を呼ぶと、C++ 側では次の処理が走ります。

  1. バインディングレジストリからモジュールを名前で検索する
  2. モジュールの Initialize() またはコンテキスト対応の登録関数を呼び出す
  3. 結果をキャッシュし、以降の呼び出しでは同じオブジェクトを返す
  4. そのオブジェクトを JavaScript に返す

BuiltinLoader と js2c パイプライン

lib/ にあるすべての JavaScript ファイルは、ビルド時に tools/js2c.cc によって Node.js バイナリへコンパイルされます。このツールは各 JavaScript ファイルを読み込み、その内容を静的データとして含む C++ ソース(効率的な表現のため UnionBytes を使用)を出力します。

flowchart LR
    LIB["lib/**/*.js<br/>~200 JavaScript files"] --> JS2C["tools/js2c.cc"]
    JS2C --> NODE_JS_CC["node_javascript.cc<br/>Static byte arrays"]
    NODE_JS_CC --> LOADER["BuiltinLoader<br/>(node_builtins.cc)"]
    LOADER --> COMPILE["V8 ScriptCompiler<br/>Compile + optional<br/>code cache"]
    COMPILE --> EXEC["Execute in Realm"]

実行時には BuiltinLoader が、埋め込まれたソースのコンパイルとキャッシュを管理します。組み込みモジュールが初めてロードされるとき、BuiltinLoader は次のように動作します。

  1. 静的データからソースを取り出す
  2. 標準パラメータ(exportsrequiremodule__filename__dirname、さらに internalBindingprimordials など Node.js 固有のもの)を引数に持つ関数でラップする
  3. コードキャッシュを有効にして V8 の ScriptCompiler でコンパイルする
  4. コンパイル済みの関数をキャッシュして再利用に備える

コードキャッシュはスナップショットビルドで特に重要です。スナップショットのビルド時にモジュールをコンパイルしてコードキャッシュをシリアライズしておけば、実行時には JavaScript を改めてパース・コンパイルせず、V8 がコードキャッシュをデシリアライズするだけで済みます。

実例:fs.readFile() を追跡する

JavaScript から C++ 、libuv、そして戻ってくるまでの完全な呼び出しフローを追ってみましょう。fs.readFile('hello.txt', callback) を呼んだとき、内部では次のことが起きています。

sequenceDiagram
    participant USER as User Code
    participant FS_JS as lib/fs.js
    participant FS_CC as src/node_file.cc
    participant UV as libuv
    participant OS as Kernel

    USER->>FS_JS: fs.readFile('hello.txt', cb)
    FS_JS->>FS_JS: Validate args, create FSReqCallback
    FS_JS->>FS_CC: binding.open(path, flags, mode, req)
    Note over FS_CC: internalBinding('fs')
    FS_CC->>FS_CC: Permission check (THROW_IF_INSUFFICIENT_PERMISSIONS)
    FS_CC->>UV: uv_fs_open(loop, &req, path, ...)
    UV->>OS: open() syscall on thread pool
    OS-->>UV: file descriptor
    UV-->>FS_CC: Callback with fd
    FS_CC->>FS_JS: FSReqCallback triggers JS callback
    FS_JS->>FS_CC: binding.read(fd, buffer, ...)
    FS_CC->>UV: uv_fs_read(loop, &req, fd, ...)
    UV->>OS: read() syscall on thread pool
    OS-->>UV: data
    UV-->>FS_CC: Callback with bytes read
    FS_CC->>FS_JS: FSReqCallback triggers JS callback
    FS_JS-->>USER: callback(null, data)

主要な登場人物はこちらです。

  1. lib/fs.js は引数のバリデーション、多段階の読み込みプロセス(open → stat → read → close)の管理、Buffer と文字列エンコーディング間の変換を担います。

  2. internalBinding('fs')src/node_file.cc の C++ バインディングオブジェクトを返します。ここには openreadclosestat などの関数が公開されています。

  3. 各非同期操作では FSReqCallbackReqWrap<uv_fs_t> のサブクラス)が生成されます。これが JavaScript のコールバックを保持し、libuv へのディスパッチを行います。

  4. libuv はスレッドプール上で実際のシステムコールを実行し、結果をイベントループスレッドに通知します。

  5. イベントループスレッド上の完了コールバックが FSReqCallback::Resolve() を呼び、AsyncWrap::MakeCallback() を通じて正しい非同期コンテキストのもとで JavaScript コールバックを実行します。

パーミッションチェックも見逃せません。src/node_file.ccpermission/permission.h をインクルードしており、すべてのファイル操作で THROW_IF_INSUFFICIENT_PERMISSIONS を使用しています。パーミッションモデルが有効な場合、--allow-fs-read / --allow-fs-write の制限が確実に適用されます。

Tips: ネイティブバインディングをデバッグするときは、C++ の Initialize() 関数にブレークポイントを仕掛けてみましょう。JavaScript に何のメソッドとプロパティが公開されているかが一目でわかります。ファンクションテンプレートのセットアップを見れば、どの JavaScript 呼び出しがどの C++ 関数に対応するかが正確に把握できます。

次回予告

C++ オブジェクトと JavaScript オブジェクトがどのように接続され、ブリッジ越しにデータが流れるかを見てきました。次回は Node.js があなたのコードをどのようにロードするかを掘り下げます。CommonJS と ES モジュールのローダー、primordials による防御機構、そして TypeScript サポートを可能にするモジュールカスタマイズフックを解説します。