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 内核开发、构建嵌入式应用,或排查启动性能问题的开发者来说都至关重要。

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 内置了一份预构建的 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源码)负责初始化三个绑定加载器(internalBindingprocess.bindingprocess._linkedBinding),以及内部模块用于相互 require()BuiltinModule 系统。

  • node.js源码)负责配置 process 对象和全局对象的属性。

  • Web API 脚本URLEventTargetfetchTextEncoder 等标准 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 系统。