深圳幻海软件技术有限公司 欢迎您!

通过快照加速 Node.js 的启动

2023-02-28

前言:随着Node.js的越来越强大,代码量也变得越来越多,不可避免地拖慢了Node.js的启动速度,针对这个问题,Node.js社区通过V8的snapshot技术对Node.js的启动做了优化,在github有很多关于此的issue讨论,大家有兴趣也可以去看一下。通过快照加速启动是一个非常复杂的过

前言:随着 Node.js 的越来越强大,代码量也变得越来越多,不可避免地拖慢了 Node.js 的启动速度,针对这个问题,Node.js 社区通过 V8 的 snapshot 技术对 Node.js 的启动做了优化,在 github 有很多关于此的 issue 讨论,大家有兴趣也可以去看一下。通过快照加速启动是一个非常复杂的过程,这需要对 V8 有深入的理解。本文介绍一下如何在 Node.js 中使用快照加速 Node.js 的启动。以 v16.13.1 为例,社区一直在优化这里面的速度,不同的版本的速度可能不一样。

Node.js 默认开启了快照功能。编译后会生成一个 node_snapshot.cc 文件。里面定义了几个方法和保存了快照的数据,在 Node.js 启动的时候会用到。我们也可以在编译的时候关闭这个功能,具体执行 ./configure --without-node-snapshot。除了控制编译时是否生成快照,还可以控制启动时是否使用快照,默认是使用,可以通过 --no-node-snapshot 关闭。我们看看效果。

const { performance } = require('perf_hooks');
console.log(performance.nodeTiming.bootstrapComplete - performance.nodeTiming.nodeStart);
  • 1.
  • 2.

可以通过 perf_hooks 模块拿到 Node.js 在启动过程的时间,这里我们首先在不使用快照的情况下看看时间。具体执行 node --no-node-snapshot test.js,我的电脑上是 42.165621001273394 毫秒。然后再看使用快照时的时间。具体执行 node test.js,我电脑是 24.800417000427842 毫秒,我们看到速度有了很大的提升。接下来我们看看 Node.js 关于这部分的大致实现。首先看编译配置。

['node_use_node_snapshot=="true"', {
  'dependencies': [
    'node_mksnapshot',
  ],
  'actions': [
    {
      'action_name': 'node_mksnapshot',
      'process_outputs_as_sources': 1,
      'inputs': [
        '<(node_mksnapshot_exec)',
      ],
      'outputs': [
        '<(SHARED_INTERMEDIATE_DIR)/node_snapshot.cc',
      ],
      'action': [
        '<@(_inputs)',
        '<@(_outputs)',
      ],
    },
  ],}, {
  'sources': [
    'src/node_snapshot_stub.cc'
   ],

}],
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.

我们看到这里根据 node_use_node_snapshot 判断要不要生成快照,这个变量在 configure.py 中设置,也就是前面说的 --without-node-snapshot。

如果 node_use_node_snapshot 为 false,则编译 node_snapshot_stub.cc。

node_snapshot_stub.cc 提供了一个默认的实现,因为 Node.js 的 C++ 代码里会用到这几个函数。如果 node_use_node_snapshot 为 true 则执行 node_mksnapshot.cc 并且把快照写入到文件 node_snapshot.cc。接下来看一下 node_mksnapshot.cc 是如何生成快照的。

int main(int argc, char* argv[]) {
  argv = uv_setup_args(argc, argv);
  v8::V8::SetFlagsFromString("--random_seed=42");
  std::ofstream out;
  out.open(argv[1], std::ios::out | std::ios::binary);
  node::InitializationResult result = node::InitializeOncePerProcess(argc, argv);
  {
    std::string snapshot =
        node::SnapshotBuilder::Generate(result.args, result.exec_args);
    out << snapshot;
    out.close();
  }
  node::TearDownOncePerProcess();
  return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

InitializeOncePerProcess 做了一些初始化操作,重点是 Generate(直接看底层的 Generate)。

void SnapshotBuilder::Generate(SnapshotData* out,
                               const std::vector<std::string> args,
                               const std::vector<std::string> exec_args) {
  Isolate* isolate = Isolate::Allocate();
  isolate->SetCaptureStackTraceForUncaughtExceptions(
      true, 10, v8::StackTrace::StackTraceOptions::kDetailed);
  per_process::v8_platform.Platform()->RegisterIsolate(isolate,
                                                       uv_default_loop());
  std::unique_ptr<NodeMainInstance> main_instance;
  std::string result;
  {
    const std::vector<intptr_t>& external_references =
        NodeMainInstance::CollectExternalReferences();
    SnapshotCreator creator(isolate, external_references.data());
    Environment* env;
    {
      main_instance =
          NodeMainInstance::Create(isolate,
                                   uv_default_loop(),
                                   per_process::v8_platform.Platform(),
                                   args,
                                   exec_args);
      HandleScope scope(isolate);
      creator.SetDefaultContext(Context::New(isolate));
      out->isolate_data_indices = main_instance->isolate_data()->Serialize(&creator);
      Local<Context> context;
      {
        TryCatch bootstrapCatch(isolate);
        context = NewContext(isolate);
      }
      Context::Scope context_scope(context);
      // Create the environment
      env = new Environment(main_instance->isolate_data(),
                            context,
                            args,
                            exec_args,
                            nullptr,
                            node::EnvironmentFlags::kDefaultFlags,
                            {});
      // Run scripts in lib/internal/bootstrap/
      {
        TryCatch bootstrapCatch(isolate);
        v8::MaybeLocal<Value> result = env->RunBootstrapping();
        result.ToLocalChecked();
      }
      // Serialize the native states
      out->env_info = env->Serialize(&creator);
      // Serialize the context
      size_t index = creator.AddContext(context, {SerializeNodeContextInternalFields, env});
    }
    // Must be out of HandleScope
    out->blob =
        creator.CreateBlob(SnapshotCreator::FunctionCodeHandling::kClear);
  }
  per_process::v8_platform.Platform()->UnregisterIsolate(isolate);
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.

可以看到就是模拟了 Node.js 的启动过程,然后把相关的数据写入到快照中。最终生成了一个文件。

这个文件和前面默认的 node_snapshot_stub.cc 文件类似,多了快照数据。有了快照再来看一下怎么使用。

int Start(int argc, char** argv) {
  InitializationResult result = InitializeOncePerProcess(argc, argv);
  if (result.early_return) {
    return result.exit_code;
  }
  {
    Isolate::CreateParams params;
    const std::vector<size_t>* indices = nullptr;
    const EnvSerializeInfo* env_info = nullptr;
    // 是否使用快照
    bool use_node_snapshot =
        per_process::cli_options->per_isolate->node_snapshot;
    if (use_node_snapshot) {
      // 获取快照信息
      v8::StartupData* blob = NodeMainInstance::GetEmbeddedSnapshotBlob();
      if (blob != nullptr) {
        params.snapshot_blob = blob;
        indices = NodeMainInstance::GetIsolateDataIndices();
        env_info = NodeMainInstance::GetEnvSerializeInfo();
      }
    }
    uv_loop_configure(uv_default_loop(), UV_METRICS_IDLE_TIME);
    // 通过快照初始化
    NodeMainInstance main_instance(&params,
                                   uv_default_loop(),
                                   per_process::v8_platform.Platform(),
                                   result.args,
                                   result.exec_args,
                                   indices);
    result.exit_code = main_instance.Run(env_info);
  }
  TearDownOncePerProcess();
  return result.exit_code;

}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.

Start 是 Node.js 启动时执行的函数,在上面代码中可以看到如果开启了快照并且生成了快照,那么就通过快照进行初始化,否则走正常初始化流程,下面是 IsolateData 的初始化逻辑。

if (indexes == nullptr) {
 CreateProperties();} else {
 DeserializeProperties(indexes);

}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

我们可以看到有快照则反序列化后进行初始化就行,否则需要进行创建。我们对比一下使用和没有使用快照进行初始化的代码的对比,以 async_wrap_providers_ 为例。下面是没有使用快照。

void IsolateData::CreateProperties() {
 async_wrap_providers_[AsyncWrap::PROVIDER_ ## Provider].Set(                \
      isolate_,                                                               \
      String::NewFromOneByte(                                                 \
        isolate_,                                                             \
        reinterpret_cast<const uint8_t*>(#Provider),                          \
        NewStringType::kInternalized,                                         \
        sizeof(#Provider) - 1).ToLocalChecked());
  NODE_ASYNC_PROVIDER_TYPES(V)

}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

上面是通过宏初始化 async_wrap_providers_ 数组的逻辑,可以看到使用 V8 的 API 创建字符串然后设置到 async_wrap_providers_ 中。接下来看使用了快照的初始化逻辑。

void IsolateData::DeserializeProperties(const std::vector<size_t>* indexes) {
  size_t i = 0;
  HandleScope handle_scope(isolate_);
  for (size_t j = 0; j < AsyncWrap::PROVIDERS_LENGTH; j++) {
    MaybeLocal<String> maybe_field = isolate_->GetDataFromSnapshotOnce<String>((*indexes)[i++]);
    Local<String> field;
    async_wrap_providers_[j].Set(isolate_, field);
  }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

可以看到直接通过快照信息调用 GetDataFromSnapshotOnce 获取对应的数据,然后设置到 async_wrap_providers_。

总结:可以看到通过快照极大加速了 Node.js 的启动过程,而快照技术的思想很简单,就是保存副本避免每次重新创建一样的数据,但是实现上是非常复杂的。甚至有同学提出是否可以在任意时刻给进程当前状态打一个快照,这样进程挂掉后就可以直接恢复到之前的状态,这听起来很美好,但是实现起来可能会非常复杂。