「译」JavaScript 是如何计算 1+1 的 - Part 2 缓存字节码

629 阅读10分钟

来源 medium.com/compilers/c…

我是一个编译器爱好者,一直在学习 V8 JavaScript 引擎的工作原理。当然,学习东西最好的方式就是写出来,所以这也是我在这里分享经验的原因。我希望这也能让其他人感兴趣。

image.png

这是多篇系列的第二部分,介绍 V8 JavaScript 引擎如何计算 1 + 1。这是一个答案非常明显的简单的表达式,但它仍然需要完整的机制来扫描和解析输入的字符串,生成和执行字节码,然后显示结果,同时在 JavaScript 堆上维护数据 ​

译者注:字节码(Bytecode)通常指的是已经经过编译,但与特定机器代码无关,需要解释器转译后才能成为机器代码中间代码

字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。字节码的实现方式是通过编译器虚拟机。编译器将源码编译成字节码,特定平台上的虚拟机将字节码转译为可以直接执行的指令

如果你没有阅读过前文,那么你必须从第一部分开始,同时也建议你带着对编译器技术的热情来学习

回顾……

在上一篇文章,我们看了一个简单的的客户端程序示例,包括它如何在标准 V8 库调用 C++ 方法。我们的程序在 JavaScript 堆中存储了 1 + 1 的字符串字面量(作为 SeqOneByteString 对象),然后将表达式编译成字节码,执行该字节码,并在控制台上显示结果

// Create a string containing the JavaScript source code.
Local<String> source = String::NewFromUtf8Literal(isolate, "1 + 1");

// Compile the source code.
Local<Script> script = 
    Script::Compile(context, source).ToLocalChecked();

// Run the script to get the result.
Local<Value> result = script->Run(context).ToLocalChecked();

// Convert the result to Number and print it.
Local<Number> number = Local<Number>::Cast(result);
printf("%f\n", number->Value());

第一篇文章中,我们追踪了完整的 String::NewFromUtf8Literal() 方法,所以这次我们将介绍紧接着的Script::Compile() 方法

Local<Script> script = 
    Script::Compile(context, source).ToLocalChecked();

Script::Compile() 负责大量的动作:

  1. 检查_编译缓存_,查看同一脚本是否已经被编译过。这使我们免于为常用脚本重复生成字节码

  2. 将输入的字符串扫描到 Tokens 中。正如我们将看到的那样,1 + 1 被转换为一系列的 token 值。Token::SMI (小整数),Token::ADD,然后是第二个 Token::SMI

译者注: token 是指,句子经过分词或者 subword 模型得到的序列,该序列的元素就是 token,如果分词算法将得到的结果是 ['在', '纽', '约', '生', '活'],那么 '纽' 就是 token,如果得到的是 ['在', '纽约', '生活'],那么 '纽约' 就是一个 token ​

Wikipedia

  1. 将 tokens 解析成抽象语法树(AST),提供程序的内存视图

  2. 生成相应的 V8 字节码,同时进行一定的优化

Script::Compile() 的返回值是一个 Local<Script> 句柄,指向的是 V8 虚拟机要执行的字节码。不过现在,我们只关注上面的第一步。也就是检查编译后的代码是否已经在缓存中可用

通常情况下,几乎所有的 JavaScript 代码都会被多次下载,无论是在同一个浏览器会话中,还是在一段时间内的不同会话中。为了避免重新编译没有变化的源代码,V8 提供了两种特制的缓存机制。第一种是 per-Isolate 缓存,将编译后的字节码直接存储在 V8 的本地内存中。第二种方法允许嵌入式应用程序(如 Chromium 或NodeJS)保存自己的编译字节码副本,很可能是基于磁盘的格式。让我们看看每种方法

方法 1 - Per-Isolate 缓存

per-Isolate 缓存是 V8 内置的,默认情况下是启用的。在 V8 术语中,一个 Isolate 是一个 JavaScript 虚拟机的实例,拥有自己的堆内存。当 V8 被嵌入到浏览器等应用程序中时,通常使用不同的 V8 Isolates 作为分隔(隔离)一个 JavaScript 运行时环境的手段。也许最好的例子就是浏览器标签页,在一个标签页中运行的代码不能影响其他标签页中的代码

当一个脚本被提交给 Isolate 进行编译时,源代码字符串(如 1 + 1 )被用作内存中哈希表的键。如果之前已经编译过相同的源代码,则会从缓存中读取一个包含代码字节码的 SharedFunctionInfo 对象,并返回给调用者。然而,如果没有相关缓存,代码必须从头开始编译,并将生成的字节码插入缓存,以便下次使用

per-Isolate 缓存(在CompilationCache 类中,见 src/codegen/compilation-cache.h)并不只是一个简单的哈希表,而是有许多功能来满足不同类型的脚本。例如,LookupScript()PutScript() 方法可以缓存「普通」的 JavaScript 源代码,将它们的工作委托给 CompilationCacheScript 类。相反,LookupEval()PutEval() 方法管理传递到 eval() 函数中的 JavaScript 字符串的缓存,将它们的工作委托给 CompilationCacheEval 类。同样,还有正则表达式(regexes)和其他代码对象的子缓存。

此外,per-Isolate 缓存中的每个子缓存都有多个_世代_,允许随着时间的推移,逐渐_淘汰_最近没有被使用的老缓存。显然,这个内存缓存系统的设计花了很多心思并且做了大量的优化

下面以编译缓存在内存中的布局为例,展示层次化的哈希表

image.png

要查看 per-Isolate 缓存的运行情况,请在 d8 解释器中多次输入 1 + 1

$ ./out/x64.debug/d8 --print-bytecode
V8 version 8.8.0 (candidate)
d8> 1 + 1
... lots of output given, including byte codes ...
2
d8> 1 + 1
2
d8> 1 + 1
2

正如预期的那样,第一次会产生大量的编译输出(感谢 --print-byteecode 标志),但第二次(或第三次)不会产生字节码。相反,如果你指定 --no-compilation-cache 命令行标志,你会看到代码每次都被重新编译

方法 2 - 在嵌入式应用程序中缓存字节码

上面提到的 per-Isolate 缓存有几个限制。特别是,当应用程序重新启动时(如关闭浏览器),内存中的缓存将无法存活。此外,缓存在 V8 的不同实例之间是不共享的,这意味着在一个浏览器标签页中加载的网页不会与其他浏览器标签页共享缓存

为了解决这些问题,可以使用第二种类型的缓存。正如在代码缓存中所讨论的那样,应用程序可以要求 V8 提供编译代码的序列化版本,并将其保存在应用程序自己的缓存中(如 Chromium 浏览器缓存)。这个序列化的数据使用 Source 对象的 GetCacheData() 方法(见 include/v8.h)传回给应用程序,然后保存在应用程序自己的缓存中

image.png

当应用程序试图再次编译同一个脚本时,比如网页多次下载同一个 .js 文件时,浏览器会将 CachedData 传回 V8,以避免重新生成字节码

image.png 明显的优点是,即使应用程序重新启动,代码也可以被长期缓存。然而,缺点是字节码必须从 V8 的内存格式序列化(参见 [CodeSerializer](https://github.com/v8/v8/blob/8.8.276/src/snapshot/code-serializer.h#L47) 类),变成更适合磁盘存储的字节序列。在以后的时间点上,这个序列化的数据必须再次被反序列化,然后才能被执行。所有这些都需要额外的时间,导致效率低于第一种方法

因为这个额外的开销,V8 只在第二次编译时才会对数据进行序列化,确保它不只是一个永远不会再出现的一次性脚本。同时,V8也将该序列化工作推迟到代码执行后进行,确保序列化不会降低用户的体验

追踪代码 - 做出缓存决定

为了了解这些缓存技术是如何融入到我们计算 1 + 1 表达式流程中的,让我们走完完整的代码路径。如前所述,我们从调用 V8 的 Script::Compile() 方法开始,将 1 + 1 作为输入参数。虽然这个方法启动了整个编译过程,但我们只看缓存机制是如何参与的

Local<Script> script = 
    Script::Compile(context, source).ToLocalChecked();

正如我们在第一篇文章中所看到的,这将调用 V8 的 API 层(见 src/api/api.cc)来验证输入参数,添加一些更重要的值(比如 Isolate 对象的指针),以及在外部 Local 句柄和它们对应的 V8 内部对象之间进行翻译

过不了多久,我们就会到达 Compiler::GetSharedFunctionInfoForScript() 方法,这是做缓存决策的地方(见 src/codegen/compiler.cc)。下面是该方法遵循的基本步骤

  • 第 2647 行 - GetSharedFunctionInfoForScript() 的参数之一是 compile_options,指定如何使用程序的缓存。如果调用者传递 kConsumeCodeCache 作为 compile_option 的值,则要求 V8 考虑使用保存在应用程序缓存中的序列化字节码(在 cached_data 参数中可用)。不过在我们的例子中,这默认为 kNoCompileOptions,表示没有序列化数据可用

  • 第 2655 行 - 为了跟踪的目的,我们记录了这个 isolate 中加载和编译的字节数。1 + 1 中有5个字节。

  • 第 2659 行 - 我们必须考虑到 JavaScript 的语言模式,因为它会影响代码的生成,从而影响到被缓存的字节码。选项是 kSloppykStrict,代表传统的 JavaScript 语法和较新的严格模式

  • 第 2676 行 - 无论应用程序是否提供了 cached_data 参数,我们都会检查源代码是否已经缓存在 per-Isolate 缓存中。这就进入了 CompilationCache::LookupScript() 方法,它委托给「script」子缓存中的 CompilationCacheScript::LookUp() 方法。最终,该代码会在多代哈希表中执行查找。考虑到字节码已经在 V8 的内存中,检查这个缓存(即使我们被应用程序传递了 cached_data)是超级快的。

  • 第 2686 行 - 鉴于我们的 1 + 1 脚本之前没有被编译并缓存在 V8 的内存中,我们现在考虑使用来自应用程序的 cached_data。不过在我们的例子中,我们的应用程序并没有给我们任何 cached_data(我们的简单示例程序),所以这两个缓存都没有提供命中。然而,如果提供了 cached_data,我们需要将其反序列化为内存格式,然后将其插入到 V8 的 per-Isolate 缓存中

译者注:序列化(serialize)就是把对象转换成字节流,便于保存在内存、文件、数据库中;反序列化(deserialize)即逆过程,由字节流还原成对象

  • 第 2727 行 - 考虑到我们的缓存中都不包含 1 + 1 的预编译字节码,我们现在继续编译源代码。这是由CompileScriptOnMainThread() 方法完成的。正如我们将在下一篇博文中看到的,这也是扫描、解析和代码生成等最复杂的一部分

  • 第 2736 行 - 如果编译成功,SharedFunctionInfo 对象(包含生成的字节码)将被插入到 per-Isolate 缓存中,为下次评估 1 + 1 时做好准备

所以,这就是 V8 代码缓存机制的概述。如果你有兴趣,V8 团队有几篇非常棒的文章和演讲来介绍缓存的工作原理,包括非常全面的 Code caching for JavaScript developersBlinkOn 9: Caching (more) JavaScript code in Chrome

下一节……

在本博文系列的第 3 部分,我们将继续进一步追踪到 Script::Compile() 方法。也就是说,我们将进一步了解 V8 的词法扫描器如何读取输入字符序列(在我们的例子中,1 + 1),并将它们形成 tokens,作为解析过程的输入