原文链接: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和代码缓存数据发生变化。我们将在下篇文章中深入探讨这些部分。