从 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 内核开发、构建嵌入式应用,或排查启动性能问题的开发者来说都至关重要。
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 内置了一份预构建的 V8 堆快照,记录了 bootstrap 完成后 JavaScript 环境的状态。借助这份快照,启动时无需重新执行所有 bootstrap 脚本,极大地缩短了启动时间。
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 allocator,并创建 IsolateData —— 这是一个存储 per-isolate 状态的对象,持有 libuv 事件循环、平台指针以及预先驻留的字符串。
在构建阶段,一个名为 node_mksnapshot 的独立二进制文件会运行 bootstrap 脚本,并将最终的 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 agent,以及 Node.js 所需的所有执行上下文相关数据。我们将在第 3 篇文章中对其进行深入剖析。
从快照反序列化时,Environment 及其 PrincipalRealm 会从序列化数据中直接还原。若不使用快照,CreateMainEnvironment() 则会创建全新的 V8 context 和 Environment,并触发完整的 JavaScript bootstrap 链。
JavaScript Bootstrap 链
Bootstrap 按照固定的顺序执行,由 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将 JavaScript 内置方法(如Array.prototype.push)的冻结副本保存下来,确保内部模块不受用户代码 monkey-patching 的影响。这一步在 V8 context 创建时就已执行。 -
realm.js(源码)负责初始化三个绑定加载器(internalBinding、process.binding、process._linkedBinding),以及内部模块用于相互require()的BuiltinModule系统。 -
node.js(源码)负责配置process对象和全局对象的属性。 -
Web API 脚本将
URL、EventTarget、fetch、TextEncoder等标准 Web 平台 API 挂载到全局对象上。 -
切换脚本用于配置线程相关的行为(例如,
process.exit()只能在主线程中使用)。
StartExecution 分发表
Bootstrap 完成后,LoadEnvironment() 会调用 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(),标记 bootstrap 完成,然后通过 Module.runMain() 加载用户脚本。
这套设计非常简洁:每种入口模式都是 lib/internal/main/ 目录下一个独立的 JavaScript 模块。因此新增模式非常方便 —— 当测试运行器功能加入时,只需在分发表中新增一个 case,指向 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 行的核心循环看似简单,却大有深意:调用 uv_run() 处理 libuv 事件,再调用 DrainTasks() 处理 V8 平台任务(如垃圾回收回调和 WebAssembly 编译任务)。如果循环中已没有待处理的工作,则触发 beforeExit 事件,给用户代码最后一次调度任务的机会。若 beforeExit 处理程序中调度了新的 I/O,循环将继续运行;否则,EmitProcessExitInternal() 会触发 exit 事件,进程随之终止。
提示:
beforeExit事件正是顶层await能够正常工作的关键机制。当顶层await完成并调度微任务时,uv_loop_alive()可能返回 false(没有待处理的 I/O),但beforeExit处理程序可以检测到仍有未完成的 Promise,从而让事件循环继续运转。
下一步
至此,我们已经完整追踪了从操作系统创建进程,到事件循环开始运转的全过程。但还有一个关键问题尚未触及:JavaScript 对象与 C++ 对象之间究竟是如何通信的?下一篇文章将深入探讨 C++↔JavaScript 桥接层 —— 包括 BaseObject 类层次结构、Environment 这一核心对象,以及连接两个世界的 binding 系统。