【翻译】可重现的 Node.js 内置快照,第一部分 - 概述与 Node.js 修复方案

1 阅读4分钟

原文链接:joyeecheung.github.io/blog/2024/0…

作者:Joyee Cheung

在最近为使 Node.js 构建过程重新可复现(至少在 Linux 上)所做的努力中,内置快照功能是最后一个待解决的大问题。我记录了使 Node.js 内置启动快照重新可复现的历程,希望能帮助其他尝试相同操作的人(或者说帮助记忆力差的未来的我)。

Node.js 内置快照与代码缓存概述

在 Node.js 中,约一半的自身代码是用 JavaScript 编写的。以下是在 Node.js 源代码目录中运行 cloc lib src 命令的最新统计结果:

-----------------------------------------------------------------------------------
Language                         files          blank        comment           code
-----------------------------------------------------------------------------------
JavaScript                         324          15056          15646          93311
C++                                175          14919           6931          79563
C/C++ Header                       206           6676           5196          30202
Markdown                             3            391              0           1308
Python                               1              0              0            154
Windows Resource File                1              6             22             39
YAML                                 1              0              3             24
JSON                                 1              0              0             22
XML                                  1              0              5             12
Assembly                             1              0              2              6
-----------------------------------------------------------------------------------
SUM:                               714          37048          27805         204641
-----------------------------------------------------------------------------------

(最终可执行文件中还包含大量第三方依赖项。此处仅列出Node.js自身维护的代码。)

作为启动优化的组成部分,在构建过程中,Node.js会运行若干初始化脚本,生成包含所有关键JavaScript值的定制V8启动快照——例如process对象和全局对象中的各类属性。该V8快照连同部分Node.js专属数据会被直接嵌入最终可执行文件。较少使用的JavaScript内部组件仅预编译为V8代码缓存(主要包含字节码),但不会在快照构建过程中实际加载(执行)。这些V8代码缓存同样会被纳入定制的Node.js快照数据。

当 Node.js 可执行文件运行时,系统只需反序列化快照堆即可完成核心内部组件的初始化,这比从头编译并执行初始化脚本来设置堆快要快得多。

$ hyperfine --warmup 3 "./node_main --no-node-snapshot ./test/fixtures/empty.js" "./node_main ./test/fixtures/empty.js"
Benchmark 1: ./node_main --no-node-snapshot ./test/fixtures/empty.js
  Time (mean ± σ):      53.1 ms ±   0.3 ms    [User: 43.6 ms, System: 11.6 ms]
  Range (min … max):    52.1 ms …  53.7 ms    56 runs

Benchmark 2: ./node_main ./test/fixtures/empty.js
  Time (mean ± σ):      17.8 ms ±   0.3 ms    [User: 9.3 ms, System: 8.6 ms]
  Range (min … max):    17.1 ms …  18.7 ms    154 runs

Summary
  './node_main ./test/fixtures/empty.js' ran
    2.98 ± 0.05 times faster than './node_main --no-node-snapshot ./test/fixtures/empty.js'

当用户加载其他内置模块或快照未包含的内部组件时,Node.js会利用内置的V8代码缓存编译这些额外的JavaScript内部组件,至少能降低编译成本。虽然仍需耗费时间运行编译后的代码来初始化内置组件,但这种方案能避免因包含不常用的对象而导致快照臃肿,堪称理想的折中方案。

$ hyperfine --warmup 3 "./node_main --no-node-snapshot ./benchmark/fixtures/require-builtins.js" "./node_main ./benchmark/fixtures/require-builtins.js"
Benchmark 1: ./node_main --no-node-snapshot ./benchmark/fixtures/require-builtins.js
  Time (mean ± σ):     101.1 ms ±   0.5 ms    [User: 88.1 ms, System: 16.0 ms]
  Range (min … max):    99.5 ms … 101.8 ms    29 runs

Benchmark 2: ./node_main ./benchmark/fixtures/require-builtins.js
  Time (mean ± σ):      35.1 ms ±   0.3 ms    [User: 24.1 ms, System: 12.4 ms]
  Range (min … max):    34.4 ms …  35.9 ms    83 runs

Summary
  './node_main ./benchmark/fixtures/require-builtins.js' ran
    2.88 ± 0.03 times faster than './node_main --no-node-snapshot ./benchmark/fixtures/require-builtins.js'

此项优化自 Node.js v12.5.0 版本起已正式发布,并持续得到改进。近期我们重新研究了 Node.js 可执行文件的可重现性问题,发现内置快照和代码缓存功能破坏了 Node.js 的可重现性,而回归测试并未覆盖此问题。因此要恢复 Node.js 可执行文件的可重现性,必须首先修复内置快照和代码缓存的可重现性问题。

Node.js 内置快照的构建过程

在常规发布构建中,首先构建 Node.js 自有代码的静态库 libnode(不含快照和代码缓存),将其与空快照一同链接成 node_mksnapshot可执行文件

随后运行node_mksnapshot生成内置快照与代码缓存,并将数据以静态常量形式写入C++文件。在ninja构建的发布版本中,可通过以下命令重现该构建流程:

$ out/Release/node_mksnapshot out/Release/gen/node_snapshot.cc

该文件随后将与 libnode 进行编译和链接,生成最终的node可执行文件。

要打印 node_mksnapshot 的调试日志,可使用两个便捷的环境变量: NODE_DEBUG_NATIVE=MKSNAPSHOT 用于数据生成,NODE_DEBUG_NATIVE=SNAPSHOT_SERDES 用于数据序列化(若需同时获取两类日志,可设置为 NODE_DEBUG_NATIVE=MKSNAPSHOT,SNAPSHOT_SERDES)。

当前发布版本的快照生成由 node::SnapshotBuilder::GenerateAsSource 实现。生成的代码大致如下:

// Data of the V8 snapshot blob encoded in octal string literals
static const char *v8_snapshot_blob_data = "...";
static const int v8_snapshot_blob_size = 1579172; // Snapshot blob size in bytes
// Code cache for lib/vm.js encoded in octal string literals
static const uint8_t *vm_cache_data = reinterpret_cast<const uint8_t *>("...");
// Code cache for lib/util.js in octal string literals
static const uint8_t *util_cache_data = reinterpret_cast<const uint8_t *>("...");

const SnapshotData snapshot_data {
  SnapshotData::DataOwnership::kNotOwned,

  // Metadata:
  {
    SnapshotMetadata::Type::kDefault, // type
    "23.0.0-pre", // node_version
    "arm64", // node_arch
    "darwin", // node_platform
    3850758994, // v8_cache_version_tag
    static_cast<SnapshotFlags>(0), // flags
  },

   // V8 snapshot blob data:
  { v8_snapshot_blob_data, v8_snapshot_blob_size },

  // Per-isolate data:
  {
    // snapshot indexes of per-isolate primitive values
    { 0, 1, ...  },
    {
      // Name, macro list index and snapshot index of per-isolate v8::Templates:
      { "async_wrap_ctor_template", 0, 451 },
      ...
    }
  },

  // Main environment data:
  {
    ...,
    {  // principal realm data:
      {
        // names of built-ins loaded in the snapshot
        "async_hooks",
        ...
      },
      {
        // Name, macro list index and snapshot index of main context persistent v8::Values
        { "async_hooks_after_function", 0, 13 },
        ...
      },
      {
        // Type name, cleanup queue index and snapshot index of embedder objects
        { "modules::BindingData", 0, 41 },
        { "mksnapshot::BindingData", 1, 43 },
        ...
      },
      56,  // context index
    },
  },

  // Pre-compiled cache for built-in modules:
  {
    // Built-in name, code cache data pointer and cache size
    { "vm", {vm_cache_data, 5144, } },
    { "util", {util_cache_data, 10936, } },
    ...
  }
};

const SnapshotData* SnapshotBuilder::GetEmbeddedSnapshotData() {
  return &snapshot_data;
}

因此,要找出内置快照中不可重现的部分,我们可以这样做:

$ ./configure --ninja
$ ninja -C out/Release node_mksnapshot
$ out/Release/node_mksnapshot ./a.cc
$ out/Release/node_mksnapshot ./a.cc
$ diff a.cc b.cc

Node.js 快照数据的差异性

如上所述,Node.js 内置快照数据部分源自 V8 引擎,其余则来自 Node.js 自身。V8 部分以二进制数据块形式存在,理解起来较为困难;而 Node.js 部分采用 C++ 聚合初始化器编写,因此更易于理解。

在我的调查过程中,发现快照中Node.js部分首个可变数据是嵌入对象的排序顺序——即此部分:

{
  // Type name, cleanup queue index and snapshot index of embedder objects
  { "modules::BindingData", 0, 41 },
  { "mksnapshot::BindingData", 1, 43 },
  ...
  // The order of the items in this list changed from run to run.
},

Node.js 内置快照包含若干嵌入器对象——主要是 internalBinding()(旧版 process.binding() 的内部版本)返回的绑定对象。这些对象用于将 C++ 函数封装器和 C++ 环境创建的对象传递至 JavaScript 环境。快照初始化脚本创建的嵌入器对象集在不同运行中保持恒定,这本是好事,但实际发现它们的序列化顺序会随运行变化,导致快照不可复现。

这个问题并不难解决。Node.js通过“清理队列”结构追踪嵌入器对象,该队列实现为std::unordered_set<CleanupHookCallback>(采用unordered_set以加快插入速度),其中CleanupHookCallback结构包含记录插入顺序的索引。Node.js确实会在关闭时使用记录的插入顺序清理嵌入对象,但我们在序列化过程中忘记执行相同操作,而是直接遍历了std::unordered_set。由于快照序列化过程中嵌入对象的插入顺序具有确定性,修复方案很简单——只需按插入顺序进行序列化即可。

接下来:修复V8代码缓存及解析V8启动快照

完成上述修复后,在生成的node_snapshot.cc文件中,仅v8_snapshot_blob_data和代码缓存数据发生变化。我们将在下篇文章中深入探讨这些部分。