Read OSS

main() から最初の JavaScript 実行まで

上級

前提知識

  • 記事1:architecture-overview(ディレクトリ構造とビルドシステム)
  • V8 の基本概念(Isolate、Context)の理解
  • イベントループの概念への親しみ

main() から最初の JavaScript 実行まで

node app.js と入力してから最初の JavaScript が実行されるまで、およそ 200 ミリ秒の時間が経過します。その間に Node.js は、C++ ランタイムの初期化、V8 isolate の生成、ヒープスナップショットのデシリアライズ(オプション)を行います。さらにグローバル環境を構築する一連の JavaScript ブートストラップスクリプトを実行し、CLI フラグに応じた 13 種類のエントリーモードのいずれかへディスパッチします。この起動シーケンスの理解は、Node.js の内部実装や embedder アプリケーション構築、起動パフォーマンスのデバッグに欠かせません。

C++ エントリーポイントチェーン

すべての始まりは src/node_main.cc です。Unix ではすぐに node::Start() を呼び出すシンプルな main() 関数です。Windows では wmain()wchar_t* 型の引数を UTF-8 に変換してから同じ node::Start() を呼び出す、ワイド文字対応バリアントです。

sequenceDiagram
    participant OS as Operating System
    participant NM as node_main.cc
    participant NC as node.cc
    participant NMI as NodeMainInstance

    OS->>NM: main(argc, argv)
    Note over NM: Windows: wmain() converts wchar_t to UTF-8
    NM->>NC: node::Start(argc, argv)
    NC->>NC: StartInternal(argc, argv)
    NC->>NC: uv_setup_args(argc, argv)
    NC->>NC: InitializeOncePerProcessInternal()
    Note over NC: Parse CLI args, init OpenSSL, V8, ICU
    NC->>NC: LoadSnapshotData()
    NC->>NMI: NodeMainInstance(snapshot, loop, platform, args)
    NMI->>NMI: Run()

実際の処理は StartInternal() で行われます。この関数は起動シーケンスの骨格とも言えます。uv_setup_args() を呼び出して libuv に process.title の管理を委ねたのち、重量級の初期化処理を担う InitializeOncePerProcessInternal() を呼び出します。

InitializeOncePerProcessInternal() は次の処理を順番に実行します。

  1. デバッグ用環境変数の解析
  2. プラットフォーム初期化(PlatformInit)の実行
  3. CLI 引数と環境変数(NODE_OPTIONS を含む)の解析
  4. パフォーマンス向上のための静的コードのラージページへのマッピング(オプション)
  5. 早期終了フラグ(--version--help--v8-options)の処理
  6. OpenSSL の初期化(コンパイル時に含まれている場合)
  7. 国際化のための ICU の初期化
  8. V8 プラットフォームとエンジンの初期化

ヒント: Node.js に新しい CLI フラグを追加する場合は、まずオプションが定義されている src/node_options.h から始め、次に node.cc 内の InitializeNodeWithArgsInternal() をたどってパース処理の流れを確認しましょう。

V8 Isolate の生成とスナップショットの読み込み

プロセスレベルの初期化が完了すると、StartInternal() はスナップショットデータを読み込みます。Node.js にはブートストラップ後の JavaScript 環境の状態を記録した、あらかじめビルドされた V8 ヒープスナップショットが同梱されています。これにより、ブートストラップスクリプトを毎回実行する必要がなくなり、起動時間を大幅に短縮できます。

flowchart TD
    START["StartInternal()"] --> SNAP_CHECK{"--no-node-snapshot?"}
    SNAP_CHECK -->|Yes| NO_SNAP["snapshot_data = nullptr"]
    SNAP_CHECK -->|No| LOAD["LoadSnapshotData()"]
    LOAD --> CUSTOM{"--snapshot-blob set?"}
    CUSTOM -->|Yes| READ_FILE["Read from custom file"]
    CUSTOM -->|No| EMBED["GetEmbeddedSnapshotData()"]
    EMBED --> BUILD{"Embedded snapshot exists?"}
    BUILD -->|Yes| USE_SNAP["Use embedded snapshot"]
    BUILD -->|No| NO_SNAP
    
    NO_SNAP --> CREATE_ISOLATE["NodeMainInstance constructor<br/>NewIsolate() + CreateIsolateData()"]
    USE_SNAP --> CREATE_ISOLATE
    READ_FILE --> CREATE_ISOLATE

NodeMainInstance コンストラクタは、スナップショットデータを使って V8 isolate を生成し、array buffer アロケータをセットアップします。さらに libuv イベントループやプラットフォームポインタ、事前インターン済み文字列を保持する isolate ごとの状態オブジェクト IsolateData を作成します。

ビルド時には node_mksnapshot という専用バイナリがブートストラップスクリプトを実行し、その結果の V8 ヒープをシリアライズします。実行時には NewIsolate() がこのスナップショットデータを V8 に渡し、V8 はすべての JavaScript をゼロから実行する代わりにヒープをデシリアライズします。これが Node.js の起動時間が約 200ms ではなく約 30ms で済む理由です。

Environment と Realm の生成

isolate の準備が整うと、NodeMainInstance::Run() はメインの Environment を作成して処理を開始します。

sequenceDiagram
    participant NMI as NodeMainInstance
    participant ENV as Environment
    participant REALM as PrincipalRealm
    participant JS as JavaScript Bootstrap

    NMI->>NMI: Run() — acquire Locker, HandleScope
    NMI->>ENV: CreateMainEnvironment()
    Note over ENV: Creates Environment + PrincipalRealm
    ENV->>REALM: PrincipalRealm(env, context)
    Note over REALM: If no snapshot: CreateProperties()
    NMI->>NMI: LoadEnvironment(env)
    Note over NMI: Triggers StartExecution()
    NMI->>NMI: SpinEventLoopInternal(env)

Environment オブジェクト(src/env.h で定義)は中央の状態コンテナです。V8 isolate、libuv イベントループ、CLI オプション、async hooks の状態、inspector エージェントなど、Node.js が必要とする実行コンテキストごとのあらゆる値を保持しています。詳細については記事 3 で掘り下げます。

スナップショットからデシリアライズする場合、Environment とその PrincipalRealm はシリアライズされたデータから復元されます。スナップショットなしで実行する場合は、CreateMainEnvironment() が新しい V8 context と Environment を作成し、JavaScript ブートストラップチェーン全体が実行されます。

JavaScript ブートストラップチェーン

ブートストラップは Realm::RunBootstrapping()PrincipalRealm::BootstrapRealm() によって制御され、決まった順序で実行されます。

sequenceDiagram
    participant REALM as Realm::RunBootstrapping
    participant BOOT as BootstrapRealm()
    
    Note over REALM: Step 1 — already run by V8 context setup
    REALM->>REALM: primordials.js<br/>Freeze copies of JS builtins
    
    Note over REALM: Step 2
    REALM->>REALM: ExecuteBootstrapper("internal/bootstrap/realm")<br/>Set up internalBinding, BuiltinModule
    
    Note over REALM: Step 3
    REALM->>BOOT: BootstrapRealm()
    BOOT->>BOOT: ExecuteBootstrapper("internal/bootstrap/node")<br/>Set up process object, globals
    
    Note over BOOT: Step 4 — Web APIs
    BOOT->>BOOT: "internal/bootstrap/web/exposed-wildcard"<br/>URL, EventTarget, AbortController, TextEncoder...
    BOOT->>BOOT: "internal/bootstrap/web/exposed-window-or-worker"<br/>fetch, WebSocket, crypto.subtle...
    
    Note over BOOT: Step 5 — Thread-specific
    BOOT->>BOOT: "internal/bootstrap/switches/is_main_thread"<br/>OR "is_not_main_thread"
    BOOT->>BOOT: "internal/bootstrap/switches/does_own_process_state"<br/>OR "does_not_own_process_state"

各ステップにはそれぞれ明確な役割があります。

  • primordials.jsArray.prototype.push などの JavaScript 組み込みメソッドのフリーズされたコピーを取得します。これにより、内部モジュールがユーザーランドのモンキーパッチの影響を受けないようにします。V8 context の生成の一部として実行されます。

  • realm.js (ソース) は 3 つのバインディングローダー(internalBindingprocess.bindingprocess._linkedBinding)と、内部モジュールが互いに require() するための BuiltinModule システムをセットアップします。

  • node.js (ソース) は process オブジェクトとグローバルオブジェクトのプロパティを設定します。

  • Web API スクリプトURLEventTargetfetchTextEncoder などの標準 Web プラットフォーム API をグローバルオブジェクトに公開します。

  • スイッチスクリプトはスレッド固有の動作を設定します(例:process.exit() はメインスレッドでのみ動作します)。

StartExecution ディスパッチテーブル

ブートストラップが完了すると、LoadEnvironment()StartExecution() を呼び出します。StartExecution() には、CLI フラグと実行コンテキストに基づいてどの「メイン」スクリプトを実行するかを選択するディスパッチテーブルが含まれています。

flowchart TD
    SE["StartExecution()"] --> SNAP{"Snapshot deserialize<br/>main exists?"}
    SNAP -->|Yes| SNAP_MAIN["RunSnapshotDeserializeMain()"]
    SNAP -->|No| WORKER{"Worker thread?"}
    WORKER -->|Yes| W["internal/main/worker_thread"]
    WORKER -->|No| INSPECT{"argv[1] == 'inspect'?"}
    INSPECT -->|Yes| I["internal/main/inspect"]
    INSPECT -->|No| HELP{"--help?"}
    HELP -->|Yes| H["internal/main/print_help"]
    HELP -->|No| EVAL{"--eval?"}
    EVAL -->|Yes| E["internal/main/eval_string"]
    EVAL -->|No| SYNTAX{"--check?"}
    SYNTAX -->|Yes| SC["internal/main/check_syntax"]
    SYNTAX -->|No| TEST{"--test?"}
    TEST -->|Yes| T["internal/main/test_runner"]
    TEST -->|No| WATCH{"--watch?"}
    WATCH -->|Yes| WM["internal/main/watch_mode"]
    WATCH -->|No| HAS_FILE{"Has file argument?"}
    HAS_FILE -->|Yes| RUN["internal/main/run_main_module"]
    HAS_FILE -->|No| TTY{"stdin is TTY?"}
    TTY -->|Yes| REPL["internal/main/repl"]
    TTY -->|No| STDIN["internal/main/eval_stdin"]

このディスパッチ処理は src/node.cc の 283〜401 行目 で確認できます。最も一般的なパスである node script.jsinternal/main/run_main_module に到達します。そこで prepareMainThreadExecution() を呼び出してブートストラップの完了をマークし、Module.runMain() でユーザーのスクリプトを読み込みます。

この設計はエレガントです。各エントリーモードは lib/internal/main/ 内の独立した JavaScript モジュールとして実装されています。そのため新しいモードの追加も容易で、テストランナーが追加されたときは、ディスパッチテーブルに internal/main/test_runner を指す新しいケースを追加するだけでした。

イベントループ:SpinEventLoopInternal

StartExecution() が返ると、制御は SpinEventLoopInternal() に渡されます。これはすべての Node.js プロセスの鼓動とも言える処理です。

flowchart TD
    START["SpinEventLoopInternal()"] --> MARK_START["Mark LOOP_START milestone"]
    MARK_START --> LOOP_TOP["Loop iteration"]
    LOOP_TOP --> STOP1{"env->is_stopping()?"}
    STOP1 -->|Yes| EXIT["Break"]
    STOP1 -->|No| UV["uv_run(UV_RUN_DEFAULT)<br/>Process I/O, timers, etc."]
    UV --> STOP2{"env->is_stopping()?"}
    STOP2 -->|Yes| EXIT
    STOP2 -->|No| DRAIN["platform->DrainTasks(isolate)<br/>Process V8 platform tasks"]
    DRAIN --> ALIVE{"uv_loop_alive()?"}
    ALIVE -->|Yes| LOOP_TOP
    ALIVE -->|No| BEFORE_EXIT["EmitProcessBeforeExit()"]
    BEFORE_EXIT --> SNAP_CB["RunSnapshotSerializeCallback()"]
    SNAP_CB --> ALIVE2{"uv_loop_alive() again?"}
    ALIVE2 -->|Yes| LOOP_TOP
    ALIVE2 -->|No| EXIT
    EXIT --> MARK_END["Mark LOOP_EXIT milestone"]
    MARK_END --> EMIT_EXIT["EmitProcessExitInternal()"]

36〜63 行目のコアループは一見シンプルです。libuv イベントを処理するために uv_run() を呼び出し、次にガベージコレクションコールバックや WebAssembly コンパイルなどの V8 プラットフォームタスクのために DrainTasks() を呼び出します。ループに処理すべき作業がなくなると beforeExit が発行され、ユーザーコードに最後の作業スケジュールの機会が与えられます。beforeExit ハンドラが新しい I/O をスケジュールすればループは継続します。そうでなければ EmitProcessExitInternal()exit イベントを発火し、プロセスは終了します。

ヒント: beforeExit イベントはトップレベルの await を動作させる仕組みです。トップレベルの await が解決されてマイクロタスクがスケジュールされると、保留中の I/O がないため uv_loop_alive() は false を返します。しかし beforeExit ハンドラが保留中の Promise を確認してループを継続させることができます。

次のステップ

オペレーティングシステムのプロセス生成から、動作するイベントループまでの流れを追いました。しかし、重要な疑問を飛ばしていました。JavaScript オブジェクトはどのように C++ オブジェクトと通信するのでしょうか?次の記事では、C++↔JavaScript ブリッジ、つまり BaseObject クラス階層、Environment ゴッドオブジェクト、そして 2 つの世界をつなぐバインディングシステムについて掘り下げます。