V8 加速啟動的快照技術
V8 每次啟動時都需要創建新的 Isolate 和 Context,這需要一定的時間,V8 啟動快照是一種用于加速啟動性能的技術,它利用事先創建的快照直接初始化 Isolate 和 Context,加快啟動時間。同時 V8 還提供了一系列 API 給使用者,使用者可以把自己的數據寫入快照,然后在啟動的時候直接恢復數據,加快啟動速度,比如 Node.js 支持啟動快照。我最近給自己之前寫的玩具 JS 運行時 No.js 加入了啟動快照的功能,以此為例簡單介紹下 V8 的快照技術。
以下 No.js 的啟動代碼。
int main(int argc, char* argv[]) {
if (argc > 1) {
if (strcmp(argv[1], "--build_snapshot") == 0) {
BuildSnapshot(argc, argv);
} else {
Start(argc, argv);
}
}
return 0;
}如果啟動時傳了 --build_snapshot 表示需要在進程退出時構建快照,并且把構建的快照寫入到當前目錄到 snapshot.blob 文件中。否則就是正常啟動,如果正常啟動時傳了 --snapshop_blob snapshot.blob 則表示通過快照啟動。
構建快照
void BuildSnapshot(int argc, char* argv[]) {
{
// 表示快照的信息
No::Snapshot::SnapshotData snapshot_data;
// No.js 實現的 C++ API 的地址數組
No::ExternalReferenceRegistry external_reference_registry;
conststd::vector<intptr_t>& external_references = external_reference_registry.external_references();
v8::SnapshotCreator creator(external_references.data());
// 啟動代碼
Isolate* isolate = creator.GetIsolate();
{
Isolate::Scope isolate_scope(isolate);
HandleScope handle_scope(isolate);
Local<ObjectTemplate> global = ObjectTemplate::New(isolate);
Local<Context> context = Context::New(isolate, nullptr, global);
Context::Scope context_scope(context);
Environment * env = new Environment(context);
{
No::MicroTask::MicroTaskScope microTaskScope(env);
// 執行用戶 JS 代碼,啟動事件循環
No::Core::Run(env);
}
// 執行完畢,處理快照的邏輯
env->run_snapshot_serial_callback();
// 設置上下文到快照中
creator.SetDefaultContext(context, v8::SerializeInternalFieldsCallback());
// 把 No.js 內部的信息寫入快照
env->serialize(&creator, &snapshot_data);
delete env;
}
// 創建快照
snapshot_data.blob = creator.CreateBlob(v8::SnapshotCreator::FunctionCodeHandling::kKeep);
// 把快照內存寫到文件
// ...
}構建快照的過程和一般的啟動過程是類似的,區別是在進程退出前會執行構建和寫快照的步驟。下面來看下構建快照的過程。creator.SetDefaultContext 會把當前執行的 context 加入到快照中,假設我們在 JS 里設置了一個全局變量,那么后續通過快照啟動時,該全局變量還會存在,可以直接使用,除了把 V8 context 加入快照,No.js 還會把自己相關的信息加入快照中。
void Environment::serialize(v8::SnapshotCreator* creator, No::Snapshot::SnapshotData* snapshot_data) {
uint32_t id = 0;
#define V(PropertyName, TypeName) \
if (!PropertyName().IsEmpty()) { \
size_t index = creator->AddData(GetContext(), PropertyName()); \
snapshot_data->env_info.props.push_back({#PropertyName, index, id++}); \
}
PER_ISOLATE_TEMPLATE_PROPERTIES(V)
PER_ISOLATE_OBJECT_PROPERTIES(V)
PER_ISOLATE_FUNCTION_PROPERTIES(V)
#undef V
}Environment 是 No.js 運行時用到的一些公共信息,serialize 里是把 Environment 的各種信息加入到快照中,下面看一個例子。
if (!snapshot_serialize_cb.IsEmpty()) {
size_t index = creator->AddData(GetContext(), snapshot_serialize_cb());
snapshot_data->env_info.props.push_back({"snapshot_serialize_cb", index, id++});
}上面代碼通過 V8 的 AddData API 把 V8 對象加入到快照中,V8 會返回一個索引,我們需要記錄這個索引,后續通過快照啟動時需要用到(這是實現快照加速非常關鍵的邏輯)。執行完這些操作后,最后通過 V8 的 creator.CreateBlob API 創建快照,然后寫入文件中。
std::ofstream out("snapshot.blob", std::ios::out | std::ios::binary);
// 寫入一些元信息
for (size_t i = 0; i < snapshot_data.env_info.props.size(); i++) {
std::string name = snapshot_data.env_info.props[i].name;
std::string id = std::to_string(snapshot_data.env_info.props[i].id);
std::string index = std::to_string(snapshot_data.env_info.props[i].index);
out.write(name.data(), name.size());
out.write(":", 1);
out.write(id.data(), id.size());
out.write(":", 1);
out.write(index.data(), index.size());
if (i != (snapshot_data.env_info.props.size() - 1)) {
out.write("|", 1);
}
}
out.write("\n", 1);
// 寫入 V8 快照信息
out.write(snapshot_data.blob.data, snapshot_data.blob.raw_size);
out.close();前面說過我們把額外的信息加入 V8 快照時會返回一個索引,我們需要收集這些索引并寫入文件中,后續通過快照啟動時使用,否則無法從快照中找到我們需要的信息。
通過快照啟動
有了快照后,我們就可以通過快照啟動了。
void Start(int argc, char* argv[]) {
Isolate::CreateParams create_params;
No::Snapshot::SnapshotData snapshot_data;
No::ExternalReferenceRegistry external_reference_registry;
bool startup_from_snapshot = false;
// 通過快照啟動
if (strcmp(argv[1], "--snapshot_blob") == 0) {
// buffer 表示快照的內容
std::string s(buffer.data(), buffer.size());
int end = s.find("\n");
// 讀取 No.js 自己寫入的索引元信息
std::string prop_data = s.substr(0, end);
std::vector<std::string> props = No::Util::Split(prop_data, '|');
for (auto& prop : props) {
std::vector<std::string> prop_fields = No::Util::Split(prop, ':');
snapshot_data.env_info.props.push_back({prop_fields[0],static_cast<size_t>(std::stoi(prop_fields[1])), static_cast<uint32_t>(std::stoi(prop_fields[2]))});
}
// 讀取 V8 的快照信息
s = s.substr(end + 1);
char * blob = newchar[s.size()];
memcpy(blob, s.data(), s.size());
snapshot_data.blob = v8::StartupData{blob, static_cast<int>(s.size())};
// 設置到啟動參數 create_params 重
create_params.snapshot_blob = &snapshot_data.blob;
conststd::vector<intptr_t>& external_references = external_reference_registry.external_references();
create_params.external_references = external_references.data();
startup_from_snapshot = true;
}
Isolate* isolate = Isolate::New(create_params);
{
Isolate::Scope isolate_scope(isolate);
HandleScope handle_scope(isolate);
// 創建 Context,因為 create_params 中設置了快照,這里會直接從快照構建 Context,而不是重新創建
Local<Context> context = Context::New(isolate);
Context::Scope context_scope(context);
Environment * env = new Environment(context);
// 從快照中獲取之前設置到信息
env->deserialize(&snapshot_data);
env->run_snapshot_deserial_callback();
// 正常啟動,執行 JS 和事件循環
{
No::MicroTask::MicroTaskScope microTaskScope(env);
if (startup_from_snapshot && !env->snapshot_deserialize_main().IsEmpty()) {
env->run_snapshot_deserialize_main();
} else {
No::Core::Run(env);
}
}
delete env;
}
}首先讀取快照文件內容,內容分為兩部分,分別是 No.js 本身的元信息和 V8 的快照內容,前者 No.js 自己使用,后者提供給 V8 用于啟動初始化,等 V8 初始化完畢后我們通過 env->deserialize 從快照中恢復額外的數據。
void Environment::deserialize(No::Snapshot::SnapshotData* snapshot_data) {
#define V(PropertyName, TypeName) \
for (auto& prop : snapshot_data->env_info.props) {\
if (prop.name == #PropertyName) {\
Local<TypeName> obj = GetContext()->GetDataFromSnapshotOnce<TypeName>(prop.index).ToLocalChecked();\
set_##PropertyName(obj);\
}\
}
PER_ISOLATE_TEMPLATE_PROPERTIES(V)
PER_ISOLATE_OBJECT_PROPERTIES(V)
PER_ISOLATE_FUNCTION_PROPERTIES(V)
#undef V
}以上代碼大致就是執行 context->GetDataFromSnapshotOnce(prop.index) API 并傳入對應的索引從快照中獲取之前設置的信息并設置到 Environment 中,這樣就完成了數據的恢復。
內部模塊快照
JS 運行時有很多內置模塊,每次執行時都需要編譯執行浪費時間,我們可以把它直接寫入快照中,后續通過快照啟動時直接使用,避免重復處理。下面看了一個例子。
const snapshot = require("snapshot");
if (snapshot.isBuildSnapshot()) {
snapshot.hello="world"
} else {
console.log(snapshot.hello)
}snapshot 是 No.js 的內置模塊,第一次執行時我們在 snapshot 對象上設置了一個 hello 屬性,然后再次啟動時輸出該屬性可以看到還存在。下面看看實現原理。No.js 的 C++ 模塊和 JS 模塊都是寫到一個對象中的,大概如下:
const No = {
buildin: { "tcp": {} },
libs: { "net": {} },
};下面是 No.js 啟動時的邏輯。
void No::Core::Run(Environment * env) {
Isolate * isolate = env->GetIsolate();
Local<Context> context = env->GetContext();
// 不是通過快照啟動則創建新的對象,否則復用快照的對象
if (!env->has_startup_snapshot()) {
// 創建新的則設置到 env,進程退出后 No 會被寫入快照,再次通過快照啟動時可以直接復用
env->set_no(Object::New(isolate));
}
Local<Object> No = env->no();
// 執行 No.js 文件
func->Call(context, context->Global(), 1, argv).ToLocalChecked();
{
No::MicroTask::MicroTaskScope microTaskScope(env);
}
// 啟動事件循環
uv_run(env->loop(), UV_RUN_DEFAULT);
}No.js 文件如下。
const {
loader,
process,
snapshot,
} = No.buildin;
// 內置 JS 模塊
class NativeModule {
exports
constructor(filename) {
this.filename = filename;
this.exports = {};
}
load() {
const func = loader.compileNative(this.filename)
func.call(null, loader.compile, this.exports, this, No);
}
}
// 加載內置 JS 模塊
function require(filename) {
constmodule = new NativeModule(filename);
module.load();
returnmodule.exports;
}
function loaderNativeModules() {
const modules = [
{
filename: 'libs/uv/index.js',
name: 'uv',
},
];
No.libs = {};
for (let i = 0; i < modules.length; i++) {
const { name, filename } = modules[i];
No.libs[name] = require(filename);
}
}
// 如果通過快照啟動則不需要重新加載內置 JS 模塊,直接復用快照的
if (!snapshot.hasStartupSnapshot()) {
loaderNativeModules();
}
function runMain() {
constmodule = require("libs/module/index.js");
let entry;
for (let i = 0; i < process.argv.length; i++) {
if (process.argv[i].endsWith('.js')) {
entry = process.argv[i];
}
}
if (process.isMainThread) {
module.load(entry);
}
}
runMain();用戶自定義快照
除了可以把 No.js 內部的信息寫入快照外,No.js 也支持用戶把自己的信息寫入快照。看下面的一個使用例子。
const snapshot = require("snapshot");
let now = Date.now();
if (snapshot.isBuildSnapshot()) {
snapshot.addSerialCallback(function() {
console.log("serial:now=", now);
});
snapshot.addDeSerialCallback(function() {
console.log("deserial:now=", now);
});
}addSerialCallback 的函數參數會在構建快照的過程中被調用,用戶可以在這里把信息寫入快照,addDeSerialCallback 的函數參數會在通過快照啟動時被執行,這里可以進行數據恢復。
- No --bild_snapshot demo.js 時會輸出:serial:sum=100000000。
- No --snapshot_blob snapshot.blob 時會輸出:serial:sum=100000000。 實現原理是通過把一個函數記錄到快照中,然后通過閉包記錄了之前的上下文,當通過快照啟動時恢復該快照并找到之前的上下文。下面看下實現。
function addSerialCallback(callback, data) {
serialCallbackQueue.push(new SnapshotTask(callback, data));
if (serialCallbackQueue.length === 1) {
snapshot.addSerialCallback(runSerialCallback)
}
}
```js
addSerialCallback 在 JS 層維護了一個函數隊列,并設置 runSerialCallback 到 C++ 層。
```c++
void SetSerializeCallback(V8_ARGS) {
Environment *env = Environment::GetCurrent(args.GetIsolate());
// snapshot_serialize_cb 函數會被寫入快照,在通過快照啟動時被恢復,可以參考之前介紹的內容
env->set_snapshot_serialize_cb(args[0].As<Function>());
}runSerialCallback 會在進程退出時被執行,從而執行 addSerialCallback 中的函數數組。
{
No::MicroTask::MicroTaskScope microTaskScope(env);
No::Core::Run(env);
}
// 用戶代碼執行完畢,執行 runSerialCallback
env->run_snapshot_serial_callback();
creator.SetDefaultContext(context, v8::SerializeInternalFieldsCallback());
env->serialize(&creator, &snapshot_data);runSerialCallback 就是遍歷函數逐個執行。
function runSerialCallback() {
serialCallbackQueue.forEach(task => {
task.callback()(task.data());
})
}addDeSerialCallback 的實現是一樣的,區別是執行時機不同,addDeSerialCallback 的函數參數也會被記錄到快照中,在通過快照啟動時被執行。
單體應用
一般來說,執行 JS 運行時需要提供一個入口文件,單體應用則不需要,因為它可以在快照中記錄入口函數,從而在通過快照啟動時直接執行入口函數,并且可以使用之前的上下文信息。下面是一個例子。
const snapshot = require("snapshot");
// 第一次啟動時計算一個值
let sum = 0;
for (let i = 0; i < 100000000; i++) {
sum += 1;
}
if (snapshot.isBuildSnapshot()) {
// 進程退出前執行
snapshot.addSerialCallback(function() {
console.log("serial:sum=", sum);
});
// 通過快照啟動時執行
snapshot.addDeSerialCallback(function() {
sum++;
console.log("deserial:sum=", sum);
});
// 通過快照啟動時執行
snapshot.setDeserializeMain(function() {
console.log("setDeserializeMain", sum);
});
}執行 No --biuld_snapshot demo.js 時 addSerialCallback 的函數參數會被執行,執行 No --snapshot_blob snapshot.blob 時就會執行 addDeSerialCallback 函數參數,最后執行 setDeserializeMain 函數參數啟動進程。
外部 C++ API
JS 運行時會設置很多 C++ 層的 API 到 JS 層使用,而這些 C++ API 的函數地址在不同進程(每次啟動時)是不一樣的,所以我們需要把這些信息告訴 V8。
class ExternalReferenceRegistry {
public:
ExternalReferenceRegistry();
const std::vector<intptr_t>& external_references();
bool is_empty() { return external_references_.empty(); }
template <typename T>
void Register(T* address) {
external_references_.push_back(reinterpret_cast<intptr_t>(address));
}
private:
bool is_finalized_ = false;
std::vector<intptr_t> external_references_;
};實現比較簡單,每個注冊到 JS 層的 C++ API 都調 Register 把自己的地址注冊到 ExternalReferenceRegistry 中,在創建快照和通過快照啟動時都傳入順序一樣的函數地址數組給 V8 即可,假設我們有個 C++ API A,第一次啟動構建快照時,寫入快照的地址是 0x1,后續通過快照啟動時 A 的地址變成了 0x2,V8 還通過 0x1 訪問機會報錯,所以我們每次都需要把最新的地址告訴 V8,V8 會重寫函數對應的地址。
- 構建快照時:
No::ExternalReferenceRegistry external_reference_registry;
const std::vector<intptr_t>& external_references = external_reference_registry.external_references();
v8::SnapshotCreator creator(external_references.data());
Isolate* isolate = creator.GetIsolate();
creator.CreateBlob(...);- 通過快照啟動時:
Isolate::CreateParams create_params;
No::ExternalReferenceRegistry external_reference_registry;
const std::vector<intptr_t>& external_references = external_reference_registry.external_references();
create_params.external_references = external_references.data();總結
V8 快照的內容還是挺多的,對于 JS 運行時來說需要處理的細節也比較多,很多地方的代碼可能都需要改造。但是對于需要啟動加速的場景是非常有意義的,比如 serverless,下面是一個啟動加速的例子。
const snapshot = require("snapshot");
if (snapshot.isBuildSnapshot()) {
global.sum = 0
for (let i = 0; i < 100000000; i++) {
global.sum += 1;
}
} else {
console.log(global.sum);
}不通過快照啟動時時間是 0.912501s,通過快照啟動時是 0.00955017s。當然這只是個例子,而且通過快照啟動時處理快照也是需要時間的,具體的情況需要具體分析。
以上是最近業余時間對快照的一些探索(可以參考 https://github.com/theanarkh/nojs),大家如果有興趣的話也可以自行研究,后面會再介紹下 Node.js 中的實現。






























