为什么利用 vm.Script 可以实现 Node.js 中的 code cache

vm.Script 可以实现 Node.js 中的 code cache,但是这还不够。

code cache 是 V8 中的一个特性,简单说就是 JavaScript 代码在执行前,取消进行解析和编译,才能正确执行,解析编译过程是耗时的,V8 暴露了一个方法,可以将编译产物序列化存储下来,下次再执行相同一段代码时,就可以用之前缓存的内容,节省了解析编译的时间。

code cache 在浏览器中可以在页面多次打开,或者多个 tab 中执行相同的代码时,节省 JavaScript 代码的编译时间。在 Node.js 中,也可以利用这一特性做一些优化,例如多个实例间共享code cache节省启动时间。又或者在启动时预先编译代码,节省第一次请求时的代码编译耗时。

Node.js 中有一个很好用的库可以直接使用code cachev8-compile-cache,如果我们查看他的代码,会发现他是通过new vm.Script来获取到编译产出的结果的。

1
2
3
4
5
6
7
var script = new vm.Script(wrapper, {
filename: filename,
lineOffset: 0,
displayErrors: true,
cachedData: buffer,
produceCachedData: true,
});

vm.Script 的实现原理

new vm.Script() 可以用来预编译代码,v8-compile-cache 里面就是用的这个。

vm.Script 定义在 Node.js 源码的 lib/vm.js 文件中。

这个类的构造函数本身没做什么内容,但是它继承了 ContextifyScript 这个类。vm.Script 的构造函数取了一下各种参数,然后就调用了 super。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Calling `ReThrow()` on a native TryCatch does not generate a new
// abort-on-uncaught-exception check. A dummy try/catch in JS land
// protects against that.
try { // eslint-disable-line no-useless-catch
super(code,
filename,
lineOffset,
columnOffset,
cachedData,
produceCachedData,
parsingContext);
} catch (e) {
throw e; /* node-do-not-add-exception-line */
}

所以主要工作都是在 ContextifyScript 这个类中做的,ContextifyScript 这个类是 C++ 实现的,文件位置:

src/node_contextify.cc

直接看 ContextifyScript::New 的定义,这个函数前面主要处理了各种参数,之后调用了:

v8::ScriptCompiler::CompileUnboundScript

1
2
3
4
MaybeLocal<UnboundScript> v8_script = ScriptCompiler::CompileUnboundScript(
isolate,
&source,
compile_options);

之后又调用了:v8::ScriptCompiler::CreateCodeCache

1
ScriptCompiler::CreateCodeCache(v8_script.ToLocalChecked())

所以就是使用了 V8 的这两个 API。

v8::ScriptCompiler::CompileUnboundScript

https://v8.github.io/api/head/classv8_1_1ScriptCompiler.html#a2f7bbea025f3b6bc7c3a3a5c2f7421dc

查阅文档,说这个方法可以在不绑定 Context 的情况下编译出一个 Compiled script object。

同时编译产物中会有 Cached data,这个 Cached data 可以作为缓存,替代代码编译过程。

这就完了?

令人沮丧的是,使用code cache只能使得文件加载变快,但 Node.js 中更大的问题在于:相同函数前几次执行会比较慢,后面才会变快,例如:

01.png

执行这个 render 函数 100 次,第一次耗时在 10ms,第二次 5ms,第三次 2ms,后面就一直维持在 1ms了。

这会造成什么问题呢?线上每次 Node.js 服务重启后,前几次请求的耗时会非常长,很有可能造成超时拒绝。

实例数小的时候还好,实例数多了以后,每次重启都是大量的请求超时。

目前还没找到很好的解决办法。

别的语言是什么情况呢?

好奇试了一下 Go 执行起来会是什么样:

02.png

可以看到 Go 没有这个问题,但 Go 每次执行波动很大。Node.js 虽然有前几次请求慢的情况,但是多次执行以后就非常稳定了,每次都是一样耗时。