浅谈执行 JavaScript 的 V8 引擎做了什么?

1,098 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第23天,点击查看活动详情

浅谈执行 JavaScript 的 V8 引擎做了什么?

Hi!大家好,对于开发前端的小伙伴大家应该都听过JavaScript吧,也在浏览器或 Node.js 上执行过 JavaScript,但你们有想过 JavaScript 是如何执行的吗?这背后的功臣就是 JavaScript 引擎,而标题提到的 V8 引擎 也是其中之一哟!

V8 引擎是由 Google 用 C++ 开源的 JavaScript 与 WebAssembly 引擎,目前像是 Chrome 和 Node.js 都是使用 V8 在执行 JavaScript。除了 V8 以外还有 SpiderMonkey(最早的 JavaScript 引擎,目前是 Firefox 浏览器在使用)与 JavaScriptCore(Safari 浏览器使用)等其他 JavaScript 引擎。

好的,那麽 V8 引擎到底是如何执行 JavaScript 的呢?

V8 引擎执行流程

Scanner

V8 引擎取得 JavaScript 源代码后的第一步,就是让 Parser 使用 Scanner 提供的 Tokens(Tokens 裡有 JavaScript 内的语法关键字,像是 function、async、if 等),将 JavaScript 的原始码解析成** abstract syntax tree**,就是大家常在相关文章中看到的 AST(抽象语法树)。

如果好奇 AST 长什麽样子的话,可以使用 acron 这个 JavaScript Parser,或是 这个网站 生成 AST 参考看看。以下是使用 acron 的代码:

const { Parser } = require('acorn')
 
const javascriptCode = `
  let name;
  name = 'Clark';
`;
 
const ast = Parser.parse(javascriptCode, { ecmaVersion2020 });
console.log(JSON.stringify(ast));

下方是解析 let name; name = 'Clark'; 所得到的 AST:

{
  "type""Program",
  "start"0,
  "end"31,
  "body": [
    {
      "type""VariableDeclaration",
      "start"3,
      "end"12,
      "declarations": [
        {
          "type""VariableDeclarator",
          "start"7,
          "end"11,
          "id": {
            "type""Identifier",
            "start"7,
            "end"11,
            "name""name"
          },
          "init"null
        }
      ],
      "kind""let"
    },
    {
      "type""ExpressionStatement",
      "start"15,
      "end"30,
      "expression": {
        "type""AssignmentExpression",
        "start"15,
        "end"29,
        "operator""=",
        "left": {
          "type""Identifier",
          "start"15,
          "end"19,
          "name""name"
        },
        "right": {
          "type""Literal",
          "start"22,
          "end"29,
          "value""Clark",
          "raw""'Clark'"
        }
      }
    }
  ],
  "sourceType""script"
}

如果再进一步,将上方的 AST 转化成图表,会长这样:

v8引擎.png

AST 可以从上到下,由左而右去理解它在执行的步骤:

  1. 走 VariableDeclaration 建立名字为 name 的变量
  2. ExpressionStatement 到表达式
  3. AssignmentExpression 遇到 =,且左边为 name,右边为字串 Clark

产生 AST 后,就完成了 V8 引擎的第一个步骤。

JIT(Just-In-Time)

JIT 的中文名称是即时编译,这也是 V8 引擎所採用在执行时编译 JavaScript 的方式。

将代码转变为可执行的语言有几种方法,第一种是编译语言,像是 C/C++ 在写完代码的时候,会先经过编译器将代码变成机器码才能执行。第二种就像 JavaScript,会在执行的时候将代码解释成机器懂的语言,一边解释边执行的这种,称作直译语言。

编译语言的好处是可以在执行前的编译阶段,审视所有的代码,将可以做的优化都完成,但直译语言就无法做到这一点,因为执行时才开始解释的关係,执行上就相对较慢,也没办法在一开始做优化,为了处理这个状况,JIT 出现了。

JIT 结合解释和编译两者,让执行 JavaScript 的时候,能够分析代码执行过程的情报,并在取得足够情报时,将相关的代码再编译成效能更快的机器码。

听起来 JIT 超讚,而在 V8 引擎裡负责处理 JIT 的左右手分别为 IgnitionTurboFan

Ignition & TurboFan

成功解析出 AST 后,Ignition 会将 AST 解释为 ByteCode,成为可执行的语言,但是 V8 引擎还未在这裡结束,Ignition 用 ByteCode 执行的时候,会搜集代码在执行时的类型信息。举个例子,如果我们有个 sum 函式,并且始终确定呼叫的参数类型都是 number,那麽 Ignition 会将它记录起来。

此时,在另一方面的 TurboFan 就会去查看这些信息,当它确认到“只有 number 类型的参数会被送进 sum 这个函式执行”这个情报的时候,就会进行 Optimization,把 sum 从 ByteCode 再编译为更快的机器码执行。

如此一来,就能够保留 JavaScript 直译语言的特性,又能够在执行的时候优化性能。

但毕竟是 JavaScript,谁也不敢保证第一百万零一次送进来的参数仍然是 number,因此当 sum 接收到的参数与之前 Optimization 的策略不同时,就会进行 Deoptimization 的动作。

TurboFan 的 Optimization 并不是将原有的 ByteCode 直接变成机器码,而是在产生机器码的同时,增加一个 Checkpoint 到 ByteCode 和机器码之间,在执行机器码之前,会先用 Checkpoint 检查是否与先前 Optimization 的类型符合。这样的话,当 sum 以与 Optimization 不同的类型被呼叫的时候,就会在 Checkpoint 这关被挡下来,并进行 Deoptimization。

最后如果 TurboFan 重複执行了 5 次 Optimization 和 Deoptimization 的过程,就会直接放弃治疗,不会再帮这个函式做 Optimization。

那到底该怎麽知道 TurboFan 有没有真的做 Optimization 咧?我们可以用下方的代码来做个实验:

const loopCount = 10000000;
const sum = (a, b) => a + b;
 
performance.mark('first_start');
 
for (let i = 0; i < loopCount; i += 1) {
    sum(1, i);
}
 
performance.mark('first_end');
 
 
performance.mark('second_start');
 
for (let i = 0; i < loopCount; i += 1) {
    sum(1, i);
}
 
performance.mark('second_end');
 
performance.measure('first_measure''first_start''first_end');
const first_measures = performance.getEntriesByName('first_measure');
console.log(first_measures[0]);
 
performance.measure('second_measure''second_start''second_end');
const second_measures = performance.getEntriesByName('second_measure');
console.log(second_measures[0]);

上方利用 Node.js v18.1 的 perf_hooks 做执行速度的测量,执行结果如下:

v8引擎1.png

执行后会发现第一次执行的时间花了 8 秒,第二次的执行时间只花了 6 秒,大家可以再把 loopCount 的数字改大一点,差距会越来越明显。

但是这麽做仍然没办法确认是 TurboFan 动了手脚,因此接下来执行的时候,加上 --trace-opt 的 flag,看看 Optimization 是否有发生:

v8引擎2.png 执行后的信息显示了 TurboFan 做的几次 Optimization,也有把每次 Optimization 的原因写下来,像第一二行分别显示了原因为 hot and stable 和 small function,这些都是 TurboFan 背后做的 Optimization 策略。

那 Deoptimization 的部分呢?要测试也很简单,只要把第二个迴圈的参数型别改成 String 送给 sum 函式执行,那 TurboFan 就会进行 Deoptimization,为了查看 Deoptimization 的讯息,下方执行的时候再加上 --trace-deopt

v8引擎3.png 在 highlight 的那一段,就是因为送入 sum 的参数型别不同,所以执行了 Deoptimization,但是接下来又因为一直送 String 进 sum 执行,所以 TurboFan 又会再替 sum 重新做 Optimization。

总结

整理 V8 引擎执行 JavaScript 的过程后,能够得出下方的流程图:

v8引擎4.png

这里就是我对v8引擎处理机制的一个简单理解,如果有不对的地方欢迎大家留言讨论,一起学习进步呀~