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.执行 cb1cb1 里有 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.nextTickObject.observeMutationObserver就是微任务(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.thencatchfinally 是属于 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

参考资料