跳到主要内容

注意

在 Node 11 版本中,Node 的 Event Loop 已经与 浏览器趋于相同。

背景

Event Loop 也是 js 老生常谈的一个话题了。2 月底看了阮一峰老师的《Node 定时器详解》一文后,发现无法完全对标之前看过的 js 事件循环执行机制,又查阅了一些其他资料,记为笔记,感觉不妥,总结成文。

浏览器中与 node 中事件循环与执行机制不同,不可混为一谈。 浏览器的Event loop是在 HTML5 中定义的规范,而 node 中则由libuv库实现。同时阅读《深入浅出 nodeJs》一书时发现比较当时 node 机制已有不同,所以本文 node 部分针对为此文发布时版本。强烈推荐读下参考链接中的前三篇。

浏览器环境

js 执行为单线程(不考虑 web worker),所有代码皆在主线程调用栈完成执行。当主线程任务清空后才会去轮询取任务队列中任务。

任务队列

异步任务分为 task(宏任务,也可称为 macroTask)和 microtask(微任务)两类。 当满足执行条件时,task 和 microtask 会被放入各自的队列中等待放入主线程执行,我们把这两个队列称为 Task Queue(也叫 Macrotask Queue)和 Microtask Queue。

  • task:script 中代码、setTimeout、setInterval、I/O、UI render。
  • microtask: promise、Object.observe、MutationObserver。

具体过程

  1. 执行完主执行线程中的任务。
  2. 取出 Microtask Queue 中任务执行直到清空。
  3. 取出 Macrotask Queue 中一个任务执行。
  4. 取出 Microtask Queue 中任务执行直到清空。
  5. 重复 3 和 4。

即为同步完成,一个宏任务,所有微任务,一个宏任务,所有微任务......

注意

  • 在浏览器页面中可以认为初始执行线程中没有代码,每一个 script 标签中的代码是一个独立的 task,即会执行完前面的 script 中创建的 microtask 再执行后面的 script 中的同步代码。
  • 如果 microtask 一直被添加,则会继续执行 microtask,“卡死”macrotask。
  • 部分版本浏览器有执行顺序与上述不符的情况,可能是不符合标准或 js 与 html 部分标准冲突。可阅读参考文章中第一篇。
  • new Promise((resolve, reject) =>{console.log(‘同步’);resolve()}).then(() => {console.log('异步')}),即promisethencatch才是 microtask,本身的内部代码不是。
  • 个别浏览器独有 API 未列出。

伪代码

while (true) {
宏任务队列.shift()
微任务队列全部任务()
}

node 环境

js 执行为单线程,所有代码皆在主线程调用栈完成执行。当主线程任务清空后才会去轮询取任务队列中任务。

循环阶段

在 node 中事件每一轮循环按照顺序分为 6 个阶段,来自 libuv 的实现:

  1. timers:执行满足条件的 setTimeout、setInterval 回调。
  2. I/O callbacks:是否有已完成的 I/O 操作的回调函数,来自上一轮的 poll 残留。
  3. idle,prepare:可忽略
  4. poll:等待还没完成的 I/O 事件,会因 timers 和超时时间等结束等待。
  5. check:执行 setImmediate 的回调。
  6. close callbacks:关闭所有的 closing handles,一些 onclose 事件。

执行机制

几个队列

除上述循环阶段中的任务类型,我们还剩下浏览器和 node 共有的 microtask 和 node 独有的process.nextTick,我们称之为 Microtask Queue 和 NextTick Queue。

我们把循环中的几个阶段的执行队列也分别称为 Timers Queue、I/O Queue、Check Queue、Close Queue。

循环之前

在进入第一次循环之前,会先进行如下操作:

  • 同步任务
  • 发出异步请求
  • 规划定时器生效的时间
  • 执行process.nextTick()

开始循环

按照我们的循环的 6 个阶段依次执行,每次拿出当前阶段中的全部任务执行,清空 NextTick Queue,清空 Microtask Queue。再执行下一阶段,全部 6 个阶段执行完毕后,进入下轮循环。即:

  • 清空当前循环内的 Timers Queue,清空 NextTick Queue,清空 Microtask Queue。
  • 清空当前循环内的 I/O Queue,清空 NextTick Queue,清空 Microtask Queue。
  • 清空当前循环内的 Check Queu,清空 NextTick Queue,清空 Microtask Queue。
  • 清空当前循环内的 Close Queu,清空 NextTick Queue,清空 Microtask Queue。
  • 进入下轮循环。

可以看出,nextTick优先级比promise等 microtask 高。setTimeoutsetInterval优先级比setImmediate高。

注意

  • 如果在 timers 阶段执行时创建了setImmediate则会在此轮循环的 check 阶段执行,如果在 timers 阶段创建了setTimeout,由于 timers 已取出完毕,则会进入下轮循环,check 阶段创建 timers 任务同理。
  • setTimeout优先级比setImmediate高,但是由于setTimeout(fn,0)的真正延迟不可能完全为 0 秒,可能出现先创建的setTimeout(fn,0)而比setImmediate的回调后执行的情况。

伪代码

while (true) {
loop.forEach((阶段) => {
阶段全部任务()
nextTick全部任务()
microTask全部任务()
})
loop = loop.next
}

测试代码

function sleep(time) {
let startTime = new Date();
while (new Date() - startTime < time) {}
console.log("1s over");
}
setTimeout(() => {
console.log("setTimeout - 1");
setTimeout(() => {
console.log("setTimeout - 1 - 1");
sleep(1000);
});
new Promise((resolve) => resolve()).then(() => {
console.log("setTimeout - 1 - then");
new Promise((resolve) => resolve()).then(() => {
console.log("setTimeout - 1 - then - then");
});
});
sleep(1000);
});

setTimeout(() => {
console.log("setTimeout - 2");
setTimeout(() => {
console.log("setTimeout - 2 - 1");
sleep(1000);
});
new Promise((resolve) => resolve()).then(() => {
console.log("setTimeout - 2 - then");
new Promise((resolve) => resolve()).then(() => {
console.log("setTimeout - 2 - then - then");
});
});
sleep(1000);
});
  • 浏览器输出:

    setTimeout - 1 //1为单个task
    1s over
    setTimeout - 1 - then
    setTimeout - 1 - then - then
    setTimeout - 2 //2为单个task
    1s over
    setTimeout - 2 - then
    setTimeout - 2 - then - then
    setTimeout - 1 - 1
    1s over
    setTimeout - 2 - 1
    1s over

  • node 输出:

    setTimeout - 1
    1s over
    setTimeout - 2 //1、2为单阶段task
    1s over
    setTimeout - 1 - then
    setTimeout - 2 - then
    setTimeout - 1 - then - then
    setTimeout - 2 - then - then
    setTimeout - 1 - 1
    1s over
    setTimeout - 2 - 1
    1s over

由此也可看出事件循环在浏览器和 node 中的不同。

由于新版 node 执行情况与浏览器相同,所以浏览器环境为例,以 console 输出值代指值所在函数,执行过程如下

<!--执行完主执行线程中的任务。-->
<!--取出Microtask Queue中任务执行直到清空。-->
<!--取出Macrotask Queue中一个任务执行。-->
<!--取出Microtask Queue中任务执行直到清空。-->
<!--重复3和4。-->
以 IQ 代指微任务队列,AQ 代指宏任务队列
1. 执行完主线程中任务:主执行线程执行完毕,setTimeout-1、setTimeout-2 进入等待
2. 清空 IQ:此时 IQ 中无任务
2. 执行 AQ 中一个任务: setTimeout-1 到时间后进入 AQ 中,被执行,执行过程中 setTimeout-1-1 进入等待状态,setTimeout-1-then 直接进入 IQ 队列,由于 setTimeout-1 中有 1s 等待,此时 setTimeout-2 肯定已经进入 AQ,setTimeout-1-1 也随后进入 AQ,此时结束状态为 IQ: [setTimeout-1-then],AQ: [setTimeout-2, setTimeout-1-1]
3. 清空 IQ: 此时 IQ 中有 setTimeout-1-then,执行 setTimeout-1-then,执行过程中,setTimout-1-then-then 直接被加入 IQ,所以 IQ 没清空,所以继续执行 setTimout-1-then-then,IQ 被清空,此时结束状态为 IQ: [], AQ: [setTimeout-2, setTimeout-1-1]
4. 执行 AQ 中一个任务:即执行 setTimeout-2
5. 清空 IQ: 这一步与 3 相似,所以输出 setTimeout-2-then、setTimeout-2-then-then,IQ 清空,此时结束状态为 IQ: [], AQ: [setTimeout-1-1, setTimeout-2-1]
6. 执行 AQ 中一个任务:即 setTimeout-1-1
7. 清空 IQ: 本身就为空
8. 执行 AQ 中一个任务:即 setTimeout-2-1

参考文章

信息

作者:toBeTheLight 链接:https://juejin.cn/post/6844903574560833550 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。