5

教女朋友学前端之深入理解JS引擎

 3 years ago
source link: https://segmentfault.com/a/1190000040519211
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

美味值:🌟🌟🌟🌟🌟

口味:番茄肥牛

食堂老板娘:老板,Chrome V8 引擎工作原理面试会问吗?

食堂老板:这块的知识不仅面试可能会问,学会了 JS 引擎的工作原理,可以更好的理解 JavaScript、更好的理解前端生态中 Babel 的词法分析和语法分析,ESLint 的语法检查原理以及 React、Vue 等前端框架的实现原理。总之,学习引擎原理可谓是一举多得。

食堂老板娘:好好好,别罗嗦了,快开始吧~

宏观视角看 V8

V8 是我们前端届的网红,它用 C++ 编写,是谷歌开源的高性能 JavaScript 和 WebAssembly 引擎,主要用在 Chrome、Node.js、Electron...中。

在开始讲我们的主角 V8 引擎之前,先来从宏观视角展开谈谈 V8 所处的位置,建立一个世界观。

在信息科技高速发展的今天,这个疯狂的大世界充斥着各种电子设备,我们每天都用的手机、电脑、电子手表、智能音箱以及现在马路上跑的越来越多的电动汽车。

作为软件工程师,我们可以将它们统一理解为“电脑”,它们都是由中央处理器(CPU)、存储以及输入、输出设备构成。CPU 就像厨师,负责按照菜谱执行命令烧菜。存储如同冰箱,负责保存数据以及要执行的命令(食材)。

当电脑接通电源,CPU 便开始从存储的某个位置读取指令,按照指令一条一条的执行命令,开始工作。电脑还可以接入各种外部设备,比如:鼠标、键盘、屏幕、发动机等等。CPU 不需要全部搞清楚这些设备的能力,它只负责和这些设备的端口进行数据交换就好。设备厂商也会在提供设备时,附带与硬件匹配的软件,来配合 CPU 一起工作。说到这里,我们便得到了最基础的计算机,也是计算机之父冯·诺伊曼在 1945 年提出的体系结构。

不过由于机器指令人类读起来非常不友好,难以阅读和记忆,所以人们发明了编程语言和编译器。编译器可以把人类更容易理解的语言转换为机器指令。除此之外,我们还需要操作系统,来帮我们解决软件治理的问题。我们知道操作系统有很多,如 Windows、Mac、Linux、Android、iOS、鸿蒙等,使用这些操作系统的设备更是数不胜数。为了消除客户端的多样性,实现跨平台并提供统一的编程接口,浏览器便诞生了。

所以,我们可以将浏览器看作操作系统之上的操作系统,而对于我们前端工程师最熟悉的 JavaScript 代码来说,浏览器引擎(如:V8)就是它的整个世界。

星球最强 JavaScript 引擎

毫无疑问,V8 是最流行、最强的 JavaScript 引擎,V8 这个名字的灵感来源于 50 年代经典的“肌肉车”的引擎。

Programming Languages Software Award

V8 也曾获得了学术界的肯定,拿到了 ACM SIGPLAN 的 Programming Languages Software Award

主流 JS 引擎

JavaScript 的主流引擎如下所示:

V8 发布周期

V8 团队使用 4 种 Chrome 发布渠道向用户推送新版本。

  • Canary releases 金丝雀版 (每天)
  • Dev releases 开发版 (每周)
  • Beta releases 测试版 (每 6 周)
  • Stable releases 稳定版 (每 6 周)

想要了解更多,请戳 V8 引擎版本发布流程

V8 架构演进史

2008 年 9 月 2 日,V8 与 Chrome 在同一天开源,最初的代码提交日期可追溯到 2008 年 6 月 30 日,你可以通过下面的链接查看 V8 代码库的可视化演化进程。

当时的 V8 架构简单粗暴,只有一个 Codegen 编译器。

2010 年,V8 中加入了 Crankshaft 优化编译器,大大提升了运行时性能。Crankshaft 生成的机器代码比之前的 Codegen 编译器快两倍,而体积减少了 30%。

2015 年,为了进一步提升性能,V8 引入了 TurboFan 优化编译器。

接下来到了分水岭,在此之前,V8 都是选择将源码直接编译为机器码的架构。不过随着 Chrome 在移动设备的普及,V8 团队发现了这种架构下存在的致命问题:编译时间过长、机器码的内存占用很大。

所以,V8 团队对引擎架构进行了重构,在 2016 年引入了 Ignition 解释器和字节码

2017 年,V8 默认开启全新的编译 pipeline(Ignition + TurboFan),并移除了 Full-codegen 和 Crankshaft

高性能的 JS 引擎不仅需要 TurboFan 这样高度优化的编译器,在编译器有机会开始工作之前的性能,也存在着大量的优化空间。

于是在 2021 年,V8 引入新的编译管道 Sparkplug

对于 Sparkplug 想要了解更多,请戳Sparkplug

食堂老板娘:原来 V8 架构经历了这么多的变化

食堂老板:是的,V8 团队为了不断的优化引擎的性能,做了很多努力。

V8 工作机制

敲黑板,进入本文的重点。

食堂老板娘:拿出小本本记好

V8 执行 JavaScript 代码的核心流程分为以下两个阶段:

编译阶段指 V8 将 JavaScript 转换为字节码或者二进制机器码,执行阶段指解释器解释执行字节码,或者 CPU 直接执行二进制机器码。

为了对 V8 整体的工作机制有更好的理解,我们先来搞懂下面几个概念。

机器语言、汇编语言、高级语言

CPU 的指令集就是机器语言,CPU 只能识别二进制的指令。但是对人类来说,二进制难以阅读和记忆,所以人们将二进制转换为可以识别、记忆的语言,也就是汇编语言,通过汇编编译器可以将汇编指令转换为机器指令。

不同的 CPU 有不同的指令集,使用汇编语言编程需要兼容不同的 CPU 架构,如 ARM、MIPS 等,学习成本比较高。汇编语言这层抽象还远远不够,所以高级语言应运而生,高级语言屏蔽了计算机架构的细节,兼容多种不同的 CPU 架构。

CPU 同样不认识高级语言,一般有两种方式执行高级语言的代码,也就是:

解释执行、编译执行

解释执行会先将输入的源码通过解析器编译成中间代码,再直接使用解释器解释执行中间代码,输出结果。

编译执行也会将源码转换为中间代码,然后编译器会将中间代码编译成机器码,通常编译成的机器码以二进制文件形式存储,执行二进制文件输出结果。编译后的机器码还可以保存在内存中,可以直接执行内存中的二进制代码。

JIT (Just In Time)

解释执行启动速度快,执行速度慢,而编译执行启动速度慢,执行速度快。

V8 权衡利弊后同时采用了解释执行和编译执行这两种方式,这种混合使用的方式称为 JIT (即时编译)

V8 在执行 JavaScript 源码时,首先解析器会将源码解析为 AST 抽象语法树,解释器 (Ignition) 会将 AST 转换为字节码,一边解释一边执行。

解释器同时会记录某一代码片段的执行次数,如果执行次数超过了某个阈值,这段代码便会被标记为热代码(Hot Code),同时将运行信息反馈给优化编译器 TurboFan,TurboFan 根据反馈信息,会优化并编译字节码,最后生成优化的机器码。

食堂老板娘:也就是说,当这段代码再次执行时,解释器就可以直接运行优化后的机器码,不需要再次解释,这样会提升很多性能吧?

食堂老板:对的!

V8 的解释器和编译器的名字寓意非常有趣,解释器 Ignition 代表点火器,编译器 TurboFan 代表涡轮增压,代码启动时通过点火器发动,TurboFan 一旦介入,执行效率会越来越高。

了解了 V8 的大体工作机制,接下来我们继续深入,看一下 V8 核心模块的工作原理。

V8 核心模块工作原理

V8 的核心模块包括:

  • Parser:解析器负责将 JavaScript 代码转换成 AST 抽象语法树。
  • Ignition:解释器负责将 AST 转换为字节码,并收集 TurboFan 需要的优化编译信息。
  • TurboFan:利用解释器收集到的信息,将字节码转换为优化的机器码。

V8 需要等编译完成后才可以运行代码,所以解析和编译过程中的性能十分重要。

解析器 Parser

解析器的解析过程分为两个阶段:

  • 词法分析 (Scanner 词法分析器)
  • 语法分析 (Pre-Parser、Parser 语法分析器)

Scanner 负责接收 Unicode Stream 字符流,将其解析为 tokens,提供给解析器 Parser

比如下面这段代码:

let myName = '童欧巴'

会被解析成 letmyName='童欧巴',它们分别是关键字、标识符、赋值运算符以及字符串。

接下来,语法分析会将上一步生成的 tokens,根据语法规则转换为 AST,如果源码存在语法错误,在这一阶段就会终止并抛出语法错误。

可以通过这个网站查看 AST 的结构:https://astexplorer.net/

也可以通过这个链接https://resources.jointjs.com/demos/javascript-ast直接生成图片,如下所示:

得到了 AST,V8 便会生成该段代码的执行上下文

主流的 JavaScript 引擎都采用了惰性解析(Lazy Parsing),因为源码在执行前如果全部完全解析的话,不仅会造成执行时间过长,而且会消耗更多的内存以及磁盘空间。

惰性解析就是指如果遇到并不是立即执行的函数,只会对其进行预解析(Pre-Parser),当函数被调用时,才会对其完全解析。

预解析时,只会验证函数的语法是否有效、解析函数声明以及确定函数作用域,并不会生成 AST,这项工作由 Pre-Parser 预解析器完成。

解释器 Ignition

得到了 AST 和执行上下文,接下来解释器会将 AST 转换为字节码并执行。

食堂老板娘:为什么要引入字节码呢?

引入字节码是一种工程上的权衡,从图中可以看出,仅仅是一个几 KB 的文件,生成的机器码就已经占用了大量的内存空间。

相比机器码,字节码不仅占用内存少,而且生成字节码的时间很快,提升了启动速度。虽然字节码没有机器码执行速度快,但是牺牲了一点执行效率,换来的收益还是很值得的。

况且,字节码与特定类型的机器码无关,通过解释器将字节码转换为机器码后才可以执行,这样也使得 V8 更加方便的移植到不同的 CPU 架构。

你可以通过如下命令,查看 JavaScript 代码生成的字节码。

node --print-bytecode index.js

也可以通过如下链接进行查看:

我们来看一段代码:

// index.js
function add(a, b) {
    return a + b
}

add(2, 4)

上面的代码在执行命令后,会生成如下的字节码:

[generated bytecode for function: add (0x1d3fb97c7da1 <SharedFunctionInfo add>)]
Parameter count 3
Register count 0
Frame size 0
   25 S> 0x1d3fb97c8686 @    0 : 25 02             Ldar a1
   34 E> 0x1d3fb97c8688 @    2 : 34 03 00          Add a0, [0]
   37 S> 0x1d3fb97c868b @    5 : aa                Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 8)
0x1d3fb97c8691 <ByteArray[8]>

其中,Parameter count 3 表示三个参数,包括传入的 a,b 以及 this。字节码的详细信息如下:

Ldar a1 // 表示将寄存器中的值加载到累加器中
Add a0, [0] // 从 a0 寄存器加载值并且将其与累加器中的值相加,然后将结果再次放入累加器
Return // 结束当前函数的执行,并把控制权传给调用方,将累加器中的值作为返回值

每行字节码都对应着特定的功能,一行行字节码就如同搭乐高积木一样,组装到一起就构成了完整的程序。

解释器通常有两种类型,基于栈基于寄存器的解释器,早期的 V8 解释器也是基于栈的,现在的 V8 解释器采用了基于寄存器的设计,支持寄存器的指令操作,使用寄存器来保存参数和中间计算结果。

Ignition 解释器在执行字节码时,主要使用了通用寄存器累加寄存器,相关的函数参数和局部变量会保存在通用寄存器中,累加寄存器会保存中间结果。

在执行指令的过程中,CPU 需要对数据进行读写,如果直接在内存中读写的话,会严重影响程序的执行性能。所以 CPU 就引入了寄存器,将一些中间数据存放到寄存器中,提升 CPU 的执行速度。

编译器 TurboFan

在编译方面,V8 团队同样做了很多优化,我们来看下内联和逃逸分析。

内联 inlining

关于内联,我们先来看一段代码:

function add(a, b) {
  return a + b
}
function foo() {
  return add(2, 4)
}

如上代码所示,我们在 foo 函数中调用了函数 add,add 函数接收 a,b 两个参数,返回他们的和。如果不经过编译器优化,则会分别生成这两个函数所对应的机器码。

为了提升性能,TurboFan 优化编译器会将上面两个函数进行内联,然后再进行编译。内联后的函数如下所示:

function fooAddInlined() {
  var a = 2
  var b = 4
  var addReturnValue = a + b
  return addReturnValue
}

// 因为 fooAddInlined 中 a 和 b 的值都是确定的,所以可以进一步优化
function fooAddInlined() {
  return 6
}

内联优化后,编译生成的机器码会精简很多,执行效率也有很大的提升。

逃逸分析 Escape Analysis

逃逸分析也不难理解,它的意思就是分析对象的生命周期是否仅限于当前函数,我们来看一段代码:

function add(a, b){
  const obj = { x: a, y: b }
  return obj.x + obj.y
}

如果对象只在函数内部定义,并且对象只作用于函数内部的话,就会被认为是“未逃逸”的,我们可以将上面代码进行优化:

function add(a, b){
  const obj_x = a
  const obj_y = b
  return obj_x + obj_y
}

优化后,无需再有对象定义,而且我们可以直接将变量加载到寄存器上,不再需要从内存中访问对象属性。不仅减少了内存消耗,而且提升了执行效率。

关于逃逸分析,Chrome 曾经也爆出过安全漏洞,使整个互联网变慢,感兴趣请戳V8 团队的一个错误,使得整个互联网变慢

除了上述提到的各种优化方案和模块,V8 还有很多优化手段和核心模块,如:使用隐藏类快速获取对象属性、使用内联缓存提升函数执行效率、Orinoco 垃圾回收器、Liftoff WebAssembly 编译器等等,本文不再过多介绍,大家感兴趣可以自行学习。

本文从宏观视角看 V8、V8 架构演进史、V8 的工作机制以及 V8 核心模块的工作原理几个方面进行了介绍和总结,我们可以发现,无论是 Chrome 还是 Node.js,它们只是一个桥梁,负责把我们前端工程师编写的 JavaScript 代码运输到最终的目的地,转换成对应机器的机器码并执行。在这段旅程中,V8 团队做了很大的努力,给他们最大的 respect。

虽然 CPU 的指令集是有限的,但是我们软件工程师编写的程序不是固定的,正是这些程序最终被 CPU 执行,才有了改变世界的可能。

你们是最棒的,改变世界的程序们!

食堂老板娘:童童,你是最胖的!^_^

站在巨人的肩膀上

  • V8是如何执行 JavaScript 代码的?-老蒋
  • 图解 Google V8 -李兵
  • 许式伟的架构课
  • https://v8.dev/blog
  • ❤️爱心三连击

1.如果你觉得食堂酒菜还合胃口,就点个赞支持下吧,你的是我最大的动力。

2.关注公众号前端食堂吃好每一顿饭!

3.点赞、评论、转发 === 催更!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK