最近在调用WASM,但是在开始聊WASM前,想要先复习一下V8引擎基础的运行原理。
1. 我们的计算机是怎么读懂我们的代码的
最底层的语言 - 机器码
一般来说,所有的计算机能读懂的只有一种语言 - 机器码 。并且不同的计算机因为技术架构(x86,ARM等)不同,能读懂的机器码 也不同。
机器码是二进制的代码,简单来说就是通过0/1表示关/开。因为所有的计算机硬件其实就是电路系统,电流的通断可以使用0和1来表示。通过电路系统中的开和关,就能控制硬件的行为。这就是计算机帝国唯一通行语言 - 机器码 。
1.1 程序员会的语言 - 编程语言
一般来说,我们到了一个新的国家,只要学习他们的语言就能深入交流了。但是问题在于这个国家的语言非常晦涩难懂。可以参考以下例子:
cpp
// 这是编译出来的机器码(转换成为十六进制)
B8 04 00 00 02
BB 01 00 00 00
B9 23 01 00 00
BA 0D 00 00 00
CD 80
B8 01 00 00 00
CD 80
从上面的示例可以看出机器码的开发成本和可读性十分差。就算有大神能学会了机器码编写程序,但是维护这套代码成本实在太高,开发并维护一套大型系统的机器码已经超出人力的极限了。因此诞生出了我们经常听到或用到的更高级的语言,如:C,C++,JAVA,Python,JS等。
cpp
// 这是一个上面机器码编译前的C++代码
#include <iostream>
using namespace std;
int main() {
cout << "Hello, World!";
return 0;
}
从上方示例代码,就算没有学习过C++,但是只要学习过英文,可能也能大概猜出来具体表达的是什么,代码的可读性大大提高了。
注意上文说的"高级"只是在架构上层级更高,并不是代表编程语言的优秀程度。
1.2. 翻译官 - 编译器
那么这时问题来了,我们用编程语言编写的程序,计算机他不可能读懂,他们只认机器码,这时怎么办呢?
这时程序员想要游历计算机帝国,就得带上"翻译官"了。翻译官通常有两种工作方式:第一种就是在程序员写完代码的时候,他们就会把写好的代码翻译成为各种架构的机器码,然后在计算机中就能执行了。第二种就是,在计算机里面运行一个虚拟机/容器,对应的代码只在虚拟机/容器里面执行,由虚拟机/容器在具体运行的时候再转换成机器码来和计算机进行对接。这个转换的过程就叫"编译" 。
第一种很好理解,就是程序员把编写好的指令,提前通过翻译官翻译好,直接丢给计算机执行就好了。第二种就相当于程序员带上了个同声传译耳机,一边读命令,这个耳机一边转换成机器码并告诉计算机这时需要做什么。
两种方式各有利弊:
- 方式一:
- 优点:机器直接执行机器码,效率更高。
- 缺点:需要预先知道代码运行的平台,并且为那个平台翻译代码。
- 代表语言:C++
- 方式二:
- 优点:更好的平台兼容性,只需要对应平台支持了编程语言的虚拟机,那么就能使用你的程序。
- 缺点:由于是运行过程中经过了一层转换,因此执行效率相对较低。
- 代表语言:JAVA,JavaScript
而我们JS使用的就是第二种方式,对应的虚拟机/容器就是我们准备要介绍的V8引擎。
1.3. 虚拟机/容器语言 - 字节码 / IR
在介绍V8引擎工作原理之前,这里还需要补充一下。一般来说我们的编程源码,也不能直接在对应的虚拟机中执行,这些源码会在程序员编写完成程序后,编译生成特殊的二进制中间代码 。这些中间代码一般是字节码格式,也称为IR(Intermediate Representation)。
字节码的优势:
- 效率更高:更低级,更贴近机器码,能在虚拟机中快速解析执行。并且在编译时已经被优化过。
- 跨平台:更好的兼容多个平台。
- 安全性:字节码运行在虚拟机中,不直接对接硬件,所有的行为由虚拟机管控。
- 易于送交Just-In-Time(JIT)编译:一些虚拟机,例如Java虚拟机(JVM)和 JS的引擎(V8),使用JIT编译技术实时地将字节码转化为本地机器代码,以提高代码的执行速度。
我们熟悉的wasm也是一种字节码。
最后我们通过一张图来总结一下:
上述方式二没有以JS为例子,是因为V8其实接收的直接是JS,所以有一点区别。
2. V8是怎么工作的
V8的工作流程我们可以直接用图来表示:
2.1 parser
parser主要是做的是通过词法分析 和语法分析 将源码转换成AST(虚拟语法树)。
2.1.1 词法分析与语法分析
词法分析
词法分析会将js源码转换成一个一个tokens。这些token可以理解为最小的js词汇,比如变量、操作符、括号等。以以下简单的代码为例:
javascript
if (x > 10) {
y = 20;
} else {
y = 30;
}
经过词法分析后会得到:
javascript
"if", "(", "x", ">", "10", ")", "{", "y", "=", "20", ";", "}", "else", "{", "y", "=", "30", ";", "}"
语法分析
语法分析会将这些 tokens 组合成一个树状的数据结构,这便是抽象语法树AST,在这个结构中,每个节点都代表源代码中的一个语法结构。经过语法分析后生成最终的AST。
javascript
IfStatement {
test: BinaryExpression {
operator: '>',
left: Identifier {
name: 'x',
},
right: Literal {
value: 10,
},
},
consequent: BlockStatement {
body: [
ExpressionStatement {
expression: AssignmentExpression {
operator: '=',
left: Identifier {
name: 'y',
},
right: Literal {
value: 20,
},
},
},
],
},
alternate: BlockStatement {
body: [
ExpressionStatement {
expression: AssignmentExpression {
operator: '=',
left: Identifier {
name: 'y',
},
right: Literal {
value: 30,
},
},
},
],
},
}
可以看到,这个AST 清晰地反映了源代码的语法结构,甚至包括了条件判断、表达式计算、赋值操作等多种语法元素。
2.2 Ignition - 解释器
- Igition主要的作用就是遍历 上方生成的AST转化为字节码的过程。
- 生成字节码后,Ignition会立即开始解释执行这些字节码,无需将其转化为机器码。
当然Ignition其实就像一个虚拟机,它底层在运行的时候,依然还是需要把相关的指令转换成机器码与机器交互的,只不过这个过程是在这个虚拟机运行时执行的。
以下为字节码示例:
:::info Ignition除了负责执行字节码外,在运行过程中还会收集运行时信息(包括代码一共运行了多少次、如何运行的等信息),Ignition会把多次执行的代码标记为热点代码,这些代码有什么用,请往下看。 :::
2.3 TurboFan
经过上面的描述,可能大家有点疑惑,都已经可以在虚拟机直接执行字节码了,那为什么还会有一个TurboFan呢?它的作用是什么?
2.3.1 虚拟机的利与弊
在V8引擎出来之前,JS引擎执行代码基本上都是到Ignition这一步就结束了。但是随着技术的发展,人们发现了一个问题:解释器/虚拟机启动速度确实很快,但是由于是边解释边执行的,那么对到频繁使用的代码,或者对到循环代码会存在多次解析的情况,从而导致运行速度变慢,降低了JS的执行效率 。
这里可以以上述翻译的例子,我们想象一个翻译器,它能做到同声传译(用户一边讲它一边翻译)。由于用户讲什么是实时的,它无法预先知道用户讲什么,那么所以对到相同的话语它只能一次又一次的重新翻译,避免出现翻译错误的情况。
2.3.2 V8优化 - JIT
为了解决以上的问题,V8引擎引入了编译器 ,也就是我们这一节讲的TurboFan。 :::info 这个编译器会把上面解释器(Ignition)标识的热点代码进行编译,编译为机器码,并且对这部分机器码进行一定的代码优化,等下次V8引擎再执行相同的代码时,会直接跳过解释器(Ignition),采用这段编译后的机器码直接执行,从而提升效率。 ::: 以上的这个过程,就是V8引擎的JIT(Just In Time)优化。当然JIT优化还有更多的原理细节,这里就不一一解释,有兴趣的同学可以查询详细的资料。
2.3.3 编译器的利与弊
小孩子才做选择,V8引擎全都要(解释器,编译器)。对到不常用的代码直接可以使用解释器执行即可,对到常用的热点代码,就转换为机器码,随时拿出执行,这样能一定程度上提升了JS的执行效率。
当然,记录这些代码的信息,其实是需要有一定的开销的,这也是使用编译器的弊端。