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() は次の処理を順番に実行します。
- デバッグ用環境変数の解析
- プラットフォーム初期化(
PlatformInit)の実行 - CLI 引数と環境変数(
NODE_OPTIONSを含む)の解析 - パフォーマンス向上のための静的コードのラージページへのマッピング(オプション)
- 早期終了フラグ(
--version、--help、--v8-options)の処理 - OpenSSL の初期化(コンパイル時に含まれている場合)
- 国際化のための ICU の初期化
- 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.jsはArray.prototype.pushなどの JavaScript 組み込みメソッドのフリーズされたコピーを取得します。これにより、内部モジュールがユーザーランドのモンキーパッチの影響を受けないようにします。V8 context の生成の一部として実行されます。 -
realm.js(ソース) は 3 つのバインディングローダー(internalBinding、process.binding、process._linkedBinding)と、内部モジュールが互いにrequire()するためのBuiltinModuleシステムをセットアップします。 -
node.js(ソース) はprocessオブジェクトとグローバルオブジェクトのプロパティを設定します。 -
Web API スクリプトは
URL、EventTarget、fetch、TextEncoderなどの標準 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.js は internal/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 つの世界をつなぐバインディングシステムについて掘り下げます。