JS引擎(V8为例)
`JavaScirpt 引擎主要用来将 JS 代码编译为不同 CPU(Intel, ARM 以及 MIPS 等)能识别的对应的汇编代码。同时,JavaScript 引擎的工作也不只是编译代码,它还要负责执行代码、分配内存以及垃圾回收。
最出名的JS引擎当属 Google V8。
V8 引擎是用 C ++ 编写的开源高性能 JavaScript 和 WebAssembly 引擎,它已被用于 Chrome 和 Node.js 等。 V8 是一个可以独立运行的模块,完全可以嵌入到任何 C ++应用程序中,比如 Node。
V8 是一个非常复杂的项目,有超过 100 万行 C++代码。它由许多子模块构成,其中最重要的 4 个模块是:
- Parser:负责将 JavaScript 源码转换为 Abstract Syntax Tree (AST)
- Ignition:interpreter,解释器,负责将 AST 转换为 Bytecode,解释执行 Bytecode;同时收集 TurboFan 优化编译所需的信息,比如函数参数的类型。
- TurboFan:compiler,即编译器,利用 Ignitio 所收集的类型信息,将 Bytecode 转换为优化的汇编代码;
- Orinoco:garbage collector,垃圾回收模块,负责将程序不再需要的内存空间回收。
总结下来就是:Parser 将 JS 源码转换为 AST,然后 Ignition 将 AST 转换为 Bytecode,最后 TurboFan 将 Bytecode 转换为经过优化的 Machine Code(实际上是汇编代码)。
在 V8 出现之前,所有的 JavaScript 虚拟机所采用的都是解释执行的方式,这是 JavaScript 执行速度过慢的一个主要原因。而 V8 率先引入了即时编译(JIT)的双轮驱动的设计(混合使用编译器和解释器的技术),这是一种权衡策略,混合编译执行和解释执行这两种手段,给 JavaScript 的执行速度带来了极大的提升。
即时编译(Just-in-time compilation),简称为 JIT。指可以直接执行源码(比如:node test.js),但是在运行的时候先编译再执行,这种方式被称为JIT。V8 也属于 JIT 编译器。
解释执行和编译执行都有各自的优缺点,解释执行启动速度快,但是执行时速度慢,而编译执行启动速度慢,但是执行速度快。为了充分地利用解释执行和编译执行的优点,规避其缺点,V8 采用了一种权衡策略,在启动过程中采用了解释执行的策略,但是如果某段代码的执行频率超过一个值,那么 V8 就会采用优化编译器将其编译成执行效率更加高效的机器代码。
V8 执行一段 JavaScript 代码所经历的主要流程可总结为:
- 初始化基础环境;
- 解析源码生成 AST 和作用域;
- 依据 AST 和作用域生成字节码;
- 解释执行字节码;
- 监听热点代码;
- 优化热点代码为二进制的机器代码;
- 反优化生成的二进制机器代码。
V8 有对应的 D8工具。它是一个非常有用的调试工具,你可以把它看成是 debug for V8 的缩写。我们可以使用 d8 来查看 V8 在执行 JavaScript 过程中的各种中间数据,比如作用域、AST、字节码、优化的二进制代码、垃圾回收的状态,还可以使用 d8 提供的私有 API 查看一些内部信息。
此外,V8引擎内部还做了一系列优化措施:
- 惰性解析基础上,增加预解析器来解决了闭包所带来的外部变量无法释放的问题。
- 引入快属性,慢属性机制,提升对象属性的访问速度。
- 通过内联缓存来提升函数执行效率。
- 引入字节码,相对二进制码,降低了时间和空间成本。
具体优化细节可参考下面文献:浏览器是如何工作的:Chrome V8让你更懂JavaScript
下面,我们主要探究一下JS的异步代码处理机制。
JS异步
- 我们都知道,
JS引擎 是单线程设计。它的创造者就是单纯为了keep it simple。 - 我们也知道,
JS代码可以分为同步代码和异步代码。
常见的 异步代码 生产者有:
seTimeoutsetIntervalDom事件ajax/fetch请求process.nextTick(Nodejs特有)等
处理 异步代码 的方式有:
callBackpromiseasync/wait- 发布/订阅 (观察者模式)
为了异步处理一些耗时的操作,JS引擎又是基于 事件循环(Event Loop)机制(单独的事件触发线程处理),实现 非阻塞I/O的。那么,Event Loop 机制如何工作呢:
JS将执行环境分为执行栈和任务队列。首先,当前代码块所有代码被放到
执行栈中 自上而下 执行;当遇到异步操作,将异步
API中定义的 回调函数 作为任务,添加到任务队列中;当
执行栈中的同步代码全部执行完,处于空闲态后,会去循环处理任务队列里的任务;将
任务队列里的任务按先来后到的顺序依次放到执行栈中执行。当
任务代码中遇到异步代码,再次放入任务队列;如此往复,称为
事件循环(Event Loop);
注意:
熟悉
Promise原理就会很清楚:Promise构造函数代码是同步代码。异步是体现在then和catch块中。在
async/await中,await出现之前的代码也是立即执行的同步代码。之后的代码是放入任务队列的异步代码。
// async/await 本身就是promise+generator的语法糖。
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
//等价于
function async1() {
console.log('async1 start');
Promise.resolve(async2()).then(() => {
console.log('async1 end');
})
}
另外,任务队列 中的 异步任务 又分为 宏任务 和微任务
宏任务
宏任务(macrotask),也叫tasks。以下 异步任务产生的回调会被放入 宏任务 队列:
setTimeoutsetIntervalI/O操作UI rendering(浏览器独有)requestAnimationFrame(浏览器独有)setImmediate(Node独有)
微任务
微任务(microtask),也叫jobs。以下 异步任务 会被放入 微任务队列:
Promiseasync/awaitMutationObserverwindow.queueMicrotask()process.nextTick(Node独有)
MutationObserver: 一个用来监视DOM变动的API。DOM的任何变动,比如节点的增减、属性的变动、文本内容的变动,这个API都可以得到通知。了解更多)
queueMicrotask(): 为了允许第三方库、框架、polyfills 能使用微任务,Window 暴露了 queueMicrotask() 方法。
下面,我们来看看 分 宏任务队列 和 微任务队列 的Event Loop 执行顺序:
所有代码被放到
执行栈中 自上而下 执行;遇到 异步操作
API,根据API的类型, 将 回调函数 添加到任务队列中宏任务队列或者微任务队列;当
执行栈中的 同步代码 全部执行完,处于空闲态后,先去循环微任务队列里的函数;依次将
微任务队列里的函数 放到执行栈中执行,如果过程中产生新的微任务,也会放入微任务队列的末尾,并且在此次循环中执行完成。当
微任务队列里的函数全部执行完成,才会将宏任务队列里的函数按顺序放到执行栈中执行。当执行完当前的
宏任务时,只有当微任务队列为空的时候,才会继续执行下一个宏任务。也就是说,在执行宏任务的时候产生了新的微任务,那么在这个宏任务执行完成以后,依然优先处理微任务。如此往复。直到所有
任务队列为空。
重要:每一个宏任务执行完毕,会检查渲染任务列表,如果有渲染任务,
GUI线程会接管渲染,渲染完成后,JS线程继续接管。
Nodejs 有所不同
NodeJS的 异步操作 也分宏任务和微任务。宏任务分为 6 个阶段,4个队列。微任务分为 2 个队列。
宏任务
6个阶段:
timers阶段:这个阶段执行setTimeout和setInterval设置的callback。I/O callback阶段:执行[close事件、timers、setImmediate()] 设定的callbacks之外的其他callbacks。idle, prepare阶段:仅node内部使用。poll阶段:获取新的I/O事件,适当的条件下node将阻塞在这里。check阶段:执行setImmediate()设定的callbacks。close阶段:执行socket.on('close', ....)这些callbacks。
4个队列:
iTimers:setTimeout,setIntervaliIO Callbacks:other……iCheck:setImmediate()iClose:socket.on('close', ....)
微任务
2个队列
Next Tick:是放置process.nextTick(callback)的回调任务.Other Micro:放置其他微任务,比如Promise等。
Node.js 中的 EventLoop 过程
NodeJS 11 之前:
- 执行全局
Script的同步代码。 - 执行
微任务,先执行所有Next Tick Queue中的所有任务,再执行Other Microtask Queue中的所有任务。 - 开始执行
宏任务,共6个阶段,从第1个阶段开始执行。每一个阶段的宏任务全部 执行完成后,回去执行所有微任务(同上),再执行 下个阶段 的全部宏任务。 - 这就是
NodeJs的Event Loop。
Node 11 +的变化:
宏任务还是分阶段依次执行,但是每一个阶段的每一个
宏任务执行完,都回去执行所有微任务,再继续执行下一个宏任务。而不是等每个阶段宏任务全部执行完才回去执行微任务。和浏览器更加趋同.
执行顺序自测
浏览器端输出顺序:
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0)
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
/*
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
*/
NodeJS中输出顺序:
console.log(1);
setTimeout(() => {
console.log(2);
process.nextTick(() => {
console.log(3);
});
new Promise((resolve) => {
console.log(4);
resolve();
}).then(() => {
console.log(5);
});
});
new Promise((resolve) => {
console.log(7);
resolve();
}).then(() => {
console.log(8);
});
process.nextTick(() => {
console.log(6);
});
setTimeout(() => {
console.log(9);
process.nextTick(() => {
console.log(10);
});
new Promise((resolve) => {
console.log(11);
resolve();
}).then(() => {
console.log(12);
});
});
//node <11: 1 7 6 8 2 4 9 11 3 10 5 12
// node>=11: 1 7 6 8 2 4 3 5 9 11 10 12