跳到主要内容

极快的解析,第2部分:惰性解析

· 阅读需 16 分钟
Toon Verwaest ([@tverwaes](https://twitter.com/tverwaes)) 和 Marja Hölttä ([@marjakh](https://twitter.com/marjakh)),精简解析器

这是我们系列文章的第二部分,解释了 V8 如何以尽可能快的速度解析 JavaScript。第一部分解释了我们如何让 V8 的扫描器变得快速。

解析是将源代码转换为中间表示以供编译器(在 V8 中是字节码编译器 Ignition)使用的步骤。解析和编译发生在网页启动的关键路径上,而并非所有传递给浏览器的函数都会在启动过程中立即需要。尽管开发人员可以通过异步和延迟脚本推迟这类代码,但这并不总是可行的。此外,许多网页会传递仅用于某些特性的代码,而这些特性在单次运行页面时用户可能根本不会访问。

不必要地急切编译代码会产生实际的资源成本:

  • CPU 周期用于生成代码,从而延迟了启动时实际需要的代码的可用性。
  • 代码对象会占用内存,直到字节码清理决定当前不需要该代码并允许其被垃圾回收为止。
  • 在顶层脚本执行结束时编译的代码将被缓存到磁盘,占用磁盘空间。

出于这些原因,所有主流浏览器都实现了_惰性解析_。解析器可以选择“预解析”所遇到的函数,而不是为每个函数生成抽象语法树 (AST) 并将其编译为字节码。它通过切换到预解析器来实现,预解析器是解析器的一个副本,执行解析函数所需的最低限度工作,以便跳过该函数。预解析器验证它跳过的函数语法是否有效,并生成外部函数正确编译所需的所有信息。当预解析的函数稍后被调用时,将按需对其进行完全解析和编译。

变量分配

使预解析复杂化的主要原因是变量分配。

出于性能原因,函数激活通过机器堆栈管理。例如,如果函数 g 使用参数 12 调用函数 f

function f(a, b) {
const c = a + b;
return c;
}

function g() {
return f(1, 2);
// `f` 的返回指令指针现在指向这里
// (因为 `f` `return` 时会返回到这里)。
}

首先,接收者(即 fthis 值,因为这是松散函数调用,所以是 globalThis)会被推送到堆栈,然后是被调用的函数 f。接着参数 12 被推送到堆栈。在此时调用函数 f。为了执行调用,我们首先将 g 的状态保存在堆栈上:f 的“返回指令指针”(rip; 我们需要返回的代码位置)以及“帧指针”(fp; 返回时堆栈应该的样子)。然后进入 f,它为局部变量 c 分配空间,并分配所需的临时空间。这确保了当函数激活超出作用域时,函数使用的任何数据都会消失:它会从堆栈中被弹出。

调用函数 f 的堆栈布局,参数 a、b 和局部变量 c 都分配在堆栈上。

这种设置的问题在于函数可以引用外部函数中声明的变量。内部函数可以在创建它们的激活结束后继续存在:

function make_f(d) { // ← `d` 的声明
return function inner(a, b) {
const c = a + b + d; // ← 引用了 `d`
return c;
};
}

const f = make_f(10);

function g() {
return f(1, 2);
}

在上面的示例中,innermake_f 中声明的局部变量 d 的引用是在 make_f 返回后计算的。为了实现这一点,具有词法闭包的语言的虚拟机会在堆上分配内部函数引用的变量,并存储在一个称为“上下文”的结构中。

调用 make_f 的堆栈布局,将参数复制到堆上分配的上下文中供稍后捕获 d 的 inner 使用。

这意味着对于函数中声明的每个变量,我们需要知道是否有内部函数引用了该变量,以便决定是将该变量分配到栈上还是分配到堆上的上下文中。当我们计算一个函数字面量时,我们分配一个闭包,这个闭包同时指向函数的代码以及当前上下文:即包含变量值的对象,这些变量可能需要访问。

长话短说,我们确实需要在预解析器中至少追踪变量引用。

然而如果我们仅仅追踪引用的话,会高估哪些变量被引用了。在一个外部函数中声明的变量可能会被一个内部函数中的重新声明所遮蔽,使得内部函数的引用指向内部声明而非外部声明。如果我们无条件地将外部变量分配到上下文中,性能会受到影响。因此,为了使变量分配在预解析时正常工作,我们需要确保预解析的函数不仅能正确追踪变量引用,还能追踪变量声明。

顶级代码是规则的一个例外。脚本的顶级总是分配到堆上,因为变量在脚本之间是可见的。一种接近完美架构的简单方法是运行预解析器,而不进行变量追踪,以快速解析顶级函数;对内部函数使用完整解析器,但跳过对它们的编译。这比预解析成本更高,因为我们不必要地构建了整个AST(抽象语法树),但它使我们能够快速运行。这恰恰是V8在V8 v6.3 / Chrome 63之前所采用的方法。

教预解析器判断变量

在预解析器中追踪变量声明和引用是复杂的,因为在JavaScript中,从一开始并不总能明确部分表达式的意义。例如,假设我们有一个带参数d的函数f,其中有一个内部函数g,其表达式看起来可能引用了d

function f(d) {
function g() {
const a = ({ d }

它确实可能最终引用了d,因为我们看到的这些标记是解构赋值表达式的一部分。

function f(d) {
function g() {
const a = ({ d } = { d: 42 });
return a;
}
return g;
}

它也可能最终是一个带解构参数d的箭头函数,在这种情况下,f中的d并不是被g引用的。

function f(d) {
function g() {
const a = ({ d }) => d;
return a;
}
return [d, g];
}

最初我们的预解析器作为解析器的独立拷贝实现,彼此之间没有太多共享,这导致两者随着时间的推移逐渐分化。通过将解析器和预解析器重写为基于ParserBase的实现,该ParserBase采用了Curiously Recurring Template Pattern(奇怪地递归模板模式),我们设法最大化了共享,同时保持了单独拷贝的性能优势。这大大简化了为预解析器添加完整变量追踪功能,因为实现的大部分可以在解析器和预解析器之间共享。

实际上,即使对于顶级函数,忽略变量声明和引用也是不正确的。ECMAScript规范要求在首次解析脚本时检测各种类型的变量冲突。例如,如果变量在同一作用域中被两次声明为词法变量,那会被认为是早期SyntaxError(语法错误)。由于我们的预解析器简单地忽略了变量声明,它会错误地允许这些代码在预解析时存在。那时我们认为性能的提升值得违反规范。但是现在预解析器能够正确追踪变量,我们已经在没有显著性能成本的情况下完全消除了这一类与变量解析相关的规范违规行为。

跳过内部函数

如前所述,当一个预解析的函数首次被调用时,我们会完整解析它并将生成的AST编译为字节码。

// 这是顶级作用域。
function outer() {
// 预解析
function inner() {
// 预解析
}
}

outer(); // 完全解析并编译`outer`,但不解析`inner`。

函数直接指向包含变量声明值的外部上下文,这些值需要对内部函数可用。为了支持懒编译函数(以及调试器),上下文指向一个名为ScopeInfo的元数据对象。ScopeInfo对象描述了上下文中列出的变量。这意味着在编译内部函数时,我们可以计算变量在上下文链中存储的位置。

为了计算延迟编译的函数本身是否需要上下文,我们需要再次进行作用域解析:我们需要知道嵌套在延迟编译函数中的函数是否引用了由延迟函数声明的变量。我们可以通过重新预解析这些函数来得出结论。这正是 V8 在 V8 v6.3 / Chrome 63 之前所做的。然而,这在性能方面并不理想,因为这使得源代码大小与解析成本之间的关系变得非线性:我们会多次预解析嵌套的函数。除了动态程序的自然嵌套外,JavaScript 打包器通常将代码包装在“立即调用的函数表达式”(IIFEs)中,使得大多数 JavaScript 程序具有多个嵌套层。

每次重新解析至少增加了解析功能的成本。

为了避免非线性的性能开销,我们甚至在预解析期间执行完整的作用域解析。我们存储足够的元数据,以便稍后可以简单地跳过内部函数,而不必重新预解析它们。一种方法是存储内部函数引用的变量名。这种方法存储起来成本较高,并且仍然需要重复工作:我们在预解析期间已经执行了变量解析。

相反,我们将变量分配的地方序列化为每个变量的密集标志数组。当我们延迟解析一个函数时,变量会按照预解析器看到的顺序重新创建,我们可以直接将元数据应用于这些变量。现在函数已编译,变量分配的元数据不再需要,可以被垃圾回收。由于我们只需要这些元数据来处理实际包含内部函数的函数,所以大量的函数根本不需要这些元数据,从而显著减少了内存开销。

通过为预解析的函数跟踪元数据,我们可以完全跳过内部函数。

跳过内部函数的性能影响,与重新预解析内部函数的开销类似,是非线性的。有些网站将所有函数提升到顶层作用域。由于它们的嵌套等级始终为 0,开销也始终为 0。然而,许多现代网站确实会深度嵌套函数。在这些网站上,当此功能在 V8 v6.3 / Chrome 63 中推出时,我们看到了显著的改进。主要优势在于,现在无论代码嵌套得多深都无关紧要:任何函数最多只进行一次预解析,然后进行一次完整解析1

主线程和脱离主线程的解析时间,在启动“跳过内部函数”优化前后对比。

可能调用的函数表达式

如前所述,打包器通常通过将模块代码包装在一个闭包中并立即调用来将多个模块合并到一个文件中。这为模块提供了隔离,使它们能够像脚本中唯一的代码一样运行。这些函数本质上是嵌套脚本;在脚本执行时,这些函数会立即被调用。打包器通常将_立即调用的函数表达式_(IIFEs;发音为“iffies”)作为括号包裹的函数提供:(function(){…})()

由于这些函数在脚本执行期间立即需要,所以预解析这样的函数并不理想。在脚本的顶层执行期间,我们会立即需要编译函数,并对该函数进行完整的解析和编译。这意味着我们之前为了加速启动而进行的快速解析显然只是给启动增加了额外的成本。

你可能会问,为什么不简单地编译调用的函数呢?虽然对开发人员来说通常很容易注意到什么时候调用了一个函数,但对于解析器来说却不是这样。解析器需要在开始解析函数之前决定是急切编译函数还是推迟编译。语法中的歧义使得简单地快速扫描到函数末尾变得困难,其成本很快就类似于常规预解析的成本。

出于这个原因,V8 识别了两个简单的模式作为_可能调用的函数表达式_(PIFEs;发音为“piffies”),以决定是否急切解析并编译函数:

  • 如果一个函数是括号包裹的函数表达式,例如:(function(){…}),我们假设它会被调用。当我们看到这种模式的开头,即(function时,我们就会做出这个假设。
  • 自 V8 v5.7 / Chrome 57 起,我们还检测到了由 UglifyJS 生成的 !function(){…}(),function(){…}(),function(){…}() 模式。检测会在我们看到 !function 或紧接着 PIFE 的 ,function 后立即启动。

由于 V8 急切编译了 PIFE,它们可以用作 基于配置文件的反馈2,告知浏览器启动需要哪些函数。

在 V8 仍然会重新解析内部函数的时期,一些开发者注意到 JavaScript 解析对启动速度的影响非常大。包 optimize-js 基于静态启发式方法将函数转换为 PIFE。当该包被创建时,这对 V8 的加载性能影响非常大。通过运行 optimize-js 提供的基准测试,并仅查看经过最小化的脚本,我们在 V8 v6.1 上复制了这些结果。

主动解析和编译 PIFE 会带来稍快的冷启动和温启动(第一次和第二次页面加载,测量总的解析+编译+执行时间)。不过,与 V8 v6.1 相比,在 V8 v7.5 上这种好处要小得多,这归功于解析器的重大改进。

然而,现在我们不再重新解析内部函数,并且因为解析器已经变得更快,通过 optimize-js 获得的性能提升已经大幅减少。事实上,v7.5 的默认配置已经比运行在 v6.1 上的优化版本快得多。即使在 v7.5 上,对于启动过程中需要的代码,有限地使用 PIFE 仍然是有意义的:我们避免了预解析,因为我们很早就知道函数将会被需要。

optimize-js 的基准测试结果并不完全反映实际情况。脚本是同步加载的,整个解析+编译时间都被计入加载时间。在实际场景中,你可能会使用 <script> 标签加载脚本。这使得 Chrome 的预加载器可以在脚本被评估之前发现它,并且可以下载、解析和编译脚本,而不会阻塞主线程。我们决定主动编译的一切内容都会自动在主线程之外编译,并且对启动时间的影响应该是最小的。使用主线程外的脚本编译运行会放大使用 PIFE 的影响。

尽管如此,仍然会有成本,特别是内存成本,所以主动编译所有内容并不是个好主意:

主动编译所有 JavaScript 会带来显著的内存成本。

虽然在启动时为需要的函数添加括号是个好主意(例如,基于启动性能剖析),但是使用像 optimize-js 这样基于简单静态启发式方法的包不是个好主意。例如,它假设一个函数是启动过程中会被调用的,只要它是函数调用的参数。然而,如果这样的函数实现了一个很晚才需要的模块,你最终会编译过多内容。过于积极的编译会导致性能下降:没有惰性编译的 V8 会显著延长加载时间。此外,optimize-js 的一些好处源于 UglifyJS 和其他代码压缩工具带来的问题,这些工具从 PIFE(非 IIFE)中移除了括号,从而去除了对例如 通用模块定义-风格模块的有用提示。这可能是代码压缩工具应该解决的问题,以便在主动编译 PIFE 的浏览器上获得最大性能。

结论

惰性解析加快了启动速度并减少了发送超出需要代码的应用程序的内存开销。在预解析器中能够正确跟踪变量声明和引用是能够快速和正确(依据规范)地预解析的必要条件。在预解析器中分配变量还使我们可以序列化变量分配信息以供解析器后续使用,从而能够彻底避免重新预解析内部函数,避免深度嵌套函数的非线性解析行为。

能够被解析器识别的 PIFE 避免了启动过程中立即需要的代码的初始预解析开销。小心地基于剖析引导使用 PIFE,或者通过打包工具使用 PIFE,可以为冷启动提供有用的性能提升。然而,不必要地将函数包装在括号中以触发这一启发式方法应该避免,因为这会导致更多代码被主动编译,从而导致更差的启动性能和更高的内存使用。

Footnotes

  1. 出于内存原因,V8 在一段时间未使用后会刷新字节码。如果之后再次需要代码,我们会重新解析并编译。由于我们允许变量元数据在编译时被移除,这会在延迟重新编译时重新解析内部函数。但在这个阶段我们会为其内部函数重新创建元数据,因此无需再次重新预解析内部函数的内部函数。

  2. PIFE 也可以被视为基于剖析的函数表达式。