Skip to content

事件循环诸果之因 ​

在这篇文章中会介绍以下内容:

  • engine runtime 和 call stack 简介(以 V8 引擎为例)
  • Event Loop 运行机制的详解
  • microtasks 和 macrotask 的执行顺序
    • 哪些是 microtasks
    • 哪些是 macrotask
    • promise 的执行顺序
    • async/await 的执行顺序

前言 ​

之前讲 浏览器架构 时,我们说到现代浏览器采用多进程架构,有浏览器主进程、多个渲染进程、多个插件进程、网络进程、GPU进程等等。又在 渲染进程中的线程 中讲到,渲染进程有 JS 引擎线程、GUI 渲染线程、事件触发线程、定时触发线程、异步 HTTP 请求线程等,并且提到 GUI 渲染线程与 JS 引擎线程是相斥的。在 浏览器的渲染原理 一文中,我们知道浏览器的渲染的大致过程

JS 引擎线程 和 GUI 渲染线程是互斥关系,浏览器为了能够使宏任务和 DOM 任务有序的执行,会在一个宏任务执行结束后,在下一个宏任务执行前,GUI 渲染线程开始工作,对页面进行渲染

javascript
// 宏任务 --> 渲染 --> 宏任务 --> 渲染 -->...

engine runtime 和 call stack 简介 ​

在 chrome 浏览器和 nodejs 里都是用 V8 引擎解析和运行 JS 代码,我们先来看下 V8 引擎的简化图:

V8 引擎的简化图

上面的 Heap 时用来做内存分配的, Call Stack 时用来执行 JS 代码,由于 JS 是单线程所以只有一个 Call Stack。实际我们写页面时,除了一些 JS 代码,我们还会大量使用:DOM 事件、AJAX(XMLHttpRequest)、setTimeout 等一些异步事件。从上图可以看出,这些异步事件都没有在 V8 引擎里,事实上这些异步事件不属于 V8 引擎,而是属于浏览器,并且 DOM 事件、AJAX(XMLHttpRequest)、setTimeout 都分别有单独的线程来处理。由于 Call Stack 执行(JS 引擎线程)与 页面渲染线程(GUI 渲染线程)时互斥的,如果所有的事情都由 V8 引擎处理,这样肯定会导致页面卡顿。

而浏览器多线程和 callback 机制完美避免了页面卡顿问题。DOM 事件、AJAX(XMLHttpRequest)、setTimeout 这些异步事件在各自单独的线程处理完以后,每个异步事件都有 callback 回调函数,V8 引擎再把这些回调函数放在 Call Stack 执行。上述整个运行机制可以称为是 runtime,可以简化如下图:

runtime简化图

如上图所示,web 异步事件结束以后,会有 callback,然后 runtime 把这些 callback 事件放到 Callback Queue 里,一旦 Call Stack 所有的方法都执行完以后, Event Loop 会依次把 Callback Queue 里的回调函数放到 Call Stack 里执行

Event Loop 运行机制 ​

Event Loop 实际上就是一个 job,用来检测 Call Stack 和 Callback Queue,一旦 Call Stack 里代码执行完以后,就会把 Callback Queue 里第一个 callback 函数放到 Call Stack 里执行。我们来看这个例子:

javascript
console.log('script start');

setTimeout(function () {
    console.log('setTimeout');
}, 1000);

console.log('script end');

运行结果如下:

javascript
script start
script end
setTimout

具体的图就不画了,以文字的形式讲解以下:

1.代码没有运行之前,Call Stack 、Callback Queue 都是空的

2.把 console.log('script start') 加入 Call Stack 中

3.执行 console.log(),打印script start,执行接触后出栈

4.将 setTimeout 压入 Call Stack

5.执行 setTimeout,用定时器触发线程执行 timeout 时间,Call Stack 中的 setTimeout 执行结束,出栈

6.把 console.log('script end') 加入 Call Stack 中

7.执行console.log('script end'),在 console 里打印出script end

8.console.log('script end')执行结束,把它移出 Call Stack

9.1000 毫秒后,计时接触,定时器触发线程将 callbackcb1 函数放到 Callback Queue 里

10.此时 Call Stack 是空的,Event Loop 把 cb1 拿到 Call Stack 里

11.执行 cb1,cb1 里有 console.log('setTimeout') ,把它放到 Call Stack 里

12.执行console.log('setTimeout'),打印,移出 Call Stack

13.cb1 执行结束,将它移出 Call Stack

放入 Callback Queue 的顺序 ​

放入 Callback Queue 中的顺序与什么有关,要么跟书写位置(像词法作用域那样),要么调用(像调用 this 那样),我们看一下这个例子:

javascript
console.log('script start');
setTimeout(() => {
    console.log('setTimeout1');
}, 1000);
setTimeout(() => {
    console.log('setTimeout2');
}, 500);
console.log('script end');

运行结果如下:

javascript
script start
script end
setTimeout2
setTimeout1

放入 Callback Queue 的顺序与调用有关,一个 500 毫秒后就放入 Callback Queue 中,一个是 1000 毫秒,先进先出

总结来说,JS 是单线程,只有一个调用栈(Call Stack),浏览器的渲染进程中有 DOM 事件(事件触发线程)、setTimeout (定时触发线程)、AJAX(异步 HTTP 请求线程)等,它们是有单独的线程处理。在这些异步事件结束后,JS 引擎会把他们的回调函数(callback)按顺序放到事件队列(Callback Queue)中,并依次放入调用栈(Call Stack)里执行,直到事件队列为空

PS:浏览器多进程,其中渲染进程中又有多个进程

microtasks 和 macrotask 的执行顺序 ​

刚才用 setTimeout 为例,解释了 JS 中 Event Loop 机制是怎么运行的,也提到过 runtime 会把回调函数依次按时间先后顺序放到 Callback Queue 里,然后 Event Loop 再依次把这些回调函数放到 Call Stack 里运行。我们运行以下代码,看下结果:

javascript
console.log('script start');

setTimeout(function () {
    console.log('setTimeout');
}, 0);

Promise.resolve()
    .then(function () {
        console.log('promose1');
    })
    .then(function () {
        console.log('promose2');
    });

console.log('script end');

执行结果如下:

javascript
script start
script end
promose1
promose2
setTimeout

上述代码虽然 setTimeout 延时为 0,其实还是异步的,因为 H5 标准规定 setTimeout 函数的第二个参数不能小于 4 毫秒,不足会自动增加

setTimeout 和 promise 都是异步事件,而且 setTimeout 写在 promise 之前,为什么 setTimeout 的回调要比 promise 后执行呢?那是因为 promise 属于微任务(microtasks)而 setTimeout 属于宏任务(macrotask),微任务(microtasks)的优先级要高于宏任务(macrotask)。

首先我们需要明白以下几个事件:

  • JS 分为同步任务和异步任务
  • 同步任务都在主线程上执行,形成一个执行栈
  • 主线程之外,事件触发线程管理着一个任务队列,只要异步任务有了运行结果(callback),就在任务队列之中放置一个事件
  • 一旦执行栈中的所有同步任务执行完毕(此时 JS 引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到 Call Stack 中,开始执行

根据规范,事件循环是通过任务队列的机制来进行协调的。一个 Event Loop 中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合;每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。 setTimeout/Promise 等 API 便是任务源,而进入任务队列的是他们指定的具体执行任务。

执行任务

Callback Queue(Task Queue)里的回调事件称为宏任务(macrotask),每次异步事件结束后,它们的回调函数会依次按时间顺序放在 Callback Queue 里,等待 Event Loop 依次把它们放到 Call Stack 里执行。比如:setInterval setTimeout script setImmediate I/O UI rendering就是宏任务(macrotask)。

微任务(microtasks)是指异步事件结束后,回调函数不会放到 Callback Queue,而是放到一个微任务队列里(Microtasks Queue),在 Call Stack 为空时,Event Loop 会先查看微任务队列里是否有任务,如果有就会先执行微任务队列里的回调事件;如果没有微任务,才会到 Callback Queue 执行回到事件。比如:promise process.nextTick、 Object.observe 、MutationObserver就是微任务(microtasks)。

在 ES6 规范中,microtasks 称为 jobs,macrotask 称为 task

整个 Event Loop 的执行顺序如下:

  • 执行一个宏任务(栈中没有就从事件队列中获取)
  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
  • 遇到宏任务,就将它添加到宏任务的任务队列中
  • 当前宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
  • 当前宏任务执行完毕,开始检查渲染,然后 GUI 线程接管渲染
  • 渲染完毕后,JS 线程继续接管,开始下一个宏任务(从事件队列中获取,也就是 callback queue)

流程图如下:

js-eventloop17

Promise ​

promise 是真正的男人,它是同步任务,它的 then 是异步的

javascript
new Promise(function (resolve) {
    console.log('promise1');
    resolve();
    console.log('promise2');
}).then(() => console.log('then'));
// promise1 promise2 then

promise 中再给你个 setTimeout 怎么办

javascript
setTimeout(function () {
    console.log('外部setTimeout1');
}, 0);
new Promise(function (resolve) {
    console.log('promise1');
    setTimeout(function () {
        console.log('内部setTimeout');
        resolve();
    }, 0);
}).then(() => console.log('then'));
setTimeout(function () {
    console.log('外部setTimeout2');
}, 0);
// promise1 外部setTimeout1 内部setTimeout then 外部setTimeout2

这个例子告诉我们,宏任务中的执行顺序是队列,先进先出

如上所说,promise.then 、catch 和 finally 是属于 MicroTasks。

async/wait ​

async 函数返回一个 Promise 对象,当函数执行的时候,一旦遇到 await 就会先返回,等到触发的一部操作完成,再接着执行函数体内后面的语句

async 隐式返回 Promise 作为结果的函数, await 后面的函数执行完毕时,await 会产生一个微任务。但是我们要注意这个微任务产生的时机,是执行完 await 之后,直接跳出 async 函数

来一个题目

javascript
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('setTimeout0');
}, 0);
setTimeout(function () {
    console.log('setTimeout3');
}, 3);
setTmmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
async1();
new Promose(function (resolve) {
    console.log('promise1');
    resolve();
    console.log('promise2');
}).then(function () {
    console.log('promise3');
});
console.log('script end');

运行结果

javascript
script start
async1 start
async2
promise1
promise2
script end
nextTick
async1 end
promise3
setTimeout0
setImmediate
setTimeout3

总结 ​

回想起 JavaScript 的执行栈中,我们讲到执行上下文栈,所谓的执行上下文栈,就是 Call Stack。从大的方向看,每一段代码要先压入 Call Stack 。这里涉及到词法作用域与变量提升、函数提升等问题,但最终引出的是闭包。闭包是静态(词法)作用域的延申

因为浏览器的多线程以及 callback 机制完美避免了页面卡顿的问题。任何异步操作在各自单独线程中处理完以后,每个异步事件都有 callback 回调函数,V8 引擎会将这些回调函数放入 Call Stack 执行。上述整个运行机制可以称为 runtime

参考资料 ​