浅谈 JS Event Loop 机制

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 代码可以分为 同步代码异步代码

常见的 异步代码 生产者有:

  • seTimeout

  • setInterval

  • Dom事件

  • ajax/fetch请求

  • process.nextTickNodejs特有)等

处理 异步代码 的方式有:

  • callBack
  • promise
  • async/wait
  • 发布/订阅 (观察者模式)

为了异步处理一些耗时的操作,JS引擎又是基于 事件循环(Event Loop)机制(单独的事件触发线程处理),实现 非阻塞I/O的。那么,Event Loop 机制如何工作呢:

  • JS 将执行环境分为 执行栈任务队列

  • 首先,当前代码块所有代码被放到 执行栈 中 自上而下 执行;

  • 当遇到异步操作,将异步API中定义的 回调函数 作为 任务,添加到 任务队列 中;

  • 执行栈 中的 同步代码 全部执行完,处于空闲态后,会去循环处理 任务队列 里的任务;

  • 任务队列 里的 任务 按先来后到的顺序依次放到 执行栈 中执行。

  • 任务 代码中遇到异步代码,再次放入 任务队列;

  • 如此往复,称为 事件循环(Event Loop);

注意:

  • 熟悉 Promise 原理就会很清楚:Promise 构造函数代码是同步代码。异步是体现在 thencatch 块中。

  • 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。以下 异步任务产生的回调会被放入 宏任务 队列:

  • setTimeout
  • setInterval
  • I/O 操作
  • UI rendering (浏览器独有)
  • requestAnimationFrame (浏览器独有)
  • setImmediate (Node独有)

微任务

微任务(microtask),也叫jobs。以下 异步任务 会被放入 微任务队列:

  • Promise
  • async/await
  • MutationObserver
  • window.queueMicrotask()
  • process.nextTick (Node独有)

MutationObserver: 一个用来监视 DOM 变动的 APIDOM 的任何变动,比如节点的增减、属性的变动、文本内容的变动,这个 API 都可以得到通知。了解更多

queueMicrotask(): 为了允许第三方库、框架、polyfills 能使用微任务,Window 暴露了 queueMicrotask() 方法。

下面,我们来看看 分 宏任务队列微任务队列Event Loop 执行顺序:

  • 所有代码被放到 执行栈 中 自上而下 执行;

  • 遇到 异步操作 API,根据 API 的类型, 将 回调函数 添加到 任务队列宏任务队列 或者 微任务队列

  • 执行栈 中的 同步代码 全部执行完,处于空闲态后,先去循环 微任务队列 里的函数;

  • 依次将 微任务队列 里的函数 放到 执行栈 中执行,如果过程中产生新的微任务,也会放入微任务队列的末尾,并且在此次循环中执行完成。

  • 微任务队列 里的函数全部执行完成,才会将 宏任务队列 里的函数按顺序放到 执行栈 中执行。

  • 当执行完当前的 宏任务 时,只有当 微任务队列 为空的时候,才会继续执行下一个 宏任务。也就是说,在执行 宏任务 的时候产生了新的 微任务,那么在这个 宏任务 执行完成以后,依然优先处理 微任务

  • 如此往复。直到所有 任务队列 为空。

重要:每一个宏任务执行完毕,会检查渲染任务列表,如果有渲染任务,GUI线程会接管渲染,渲染完成后,JS线程继续接管。

Nodejs 有所不同


  • NodeJS 的 异步操作 也分 宏任务微任务
  • 宏任务分为 6 个阶段,4个队列。
  • 微任务 分为 2 个队列。

宏任务

6个阶段:

  • timers阶段:这个阶段执行 setTimeoutsetInterval 设置的 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个队列:

  • iTimerssetTimeoutsetInterval
  • iIO 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 个阶段开始执行。每一个阶段的 宏任务 全部 执行完成后,回去执行所有 微任务(同上),再执行 下个阶段 的全部 宏任务
  • 这就是 NodeJsEvent 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

参考文档


前端干货 JS执行顺序
浏览器是如何工作的:Chrome V8让你更懂JavaScript