uncategorized

事件循环, MacroTask和MicroTask

A short review of eventloop, macrotask and microtask

什么是事件循环

Javascript 是单线程异步的

在浏览器环境里,单线程就是指,除了渲染线程、计时器线程等以外,负责执行我们主代码的就一条线程,它不像Java或者其他多线程语言一样,程序员可以写代码来启动新的线程

而异步是指主代码中,并不是所有的逻辑都一口气在这个单线程中执行完成,某些任务会在单独的线程里执行,执行完后再回调通知主线程(例如网络请求),而某些任务出于调度优化或是为了支持更好地管理耗时任务的原因会被临时存储起来,等待时分复用当前线程(如Promise / set等)

这类回调临时存储起来的任务大致会被分为两个分类:微任务和宏任务

EventLoop.svg

  1. 主线程从同步代码开始执行, 一口气执行完全部同步代码,遇到稍微费事一点的就嫌麻烦扔先队列里,之后再做 (im kidding🤔)
  2. 查看 MircroTask 队列里是否有 task, 如果有就全部取出来按入队先后顺序执行
  3. 查看 MarcroTask 队列里是否有 task, 如果有就取一个最先加进去的任务执行
  4. 重复2, 指导所有 MacroTask / MicroTask 都执行完毕

2~4的不停轮转就叫事件循环, 事件不停地被用户操作 / 网络请求 / 计时器等触发,产生的任务就不停地排到相应的事件队列里,事件循环就负责按调度规则不停地做任务,一直到世界终结 …

微任务(MicroTask)和宏任务(MacroTask)

那么哪些是微任务 / 哪些是宏任务呢

大致可以按如下归类

类型 操作
微任务 Promise 产生的 then / catch / finally
queueMicrotask 插入的方法 (插队函数…🤔)
MutationObserver callback
宏任务 setTimeout, setInterval, setImmediate
MouseEvent
KeyboardEvent
NetworkEvent
HtmlParsing
I/O

值得注意的是,回调并不一定代表异步:

  1. Promise 的构造函数里的回调函数会同步执行, 也就是

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const t = new Promise((res) => {
    console.log(`constructor fn`)
    res()
    })
    t.then(() => {
    console.log('then')
    })
    console.log('sync')
    // 执行顺序
    // constructor fn
    // sync
    // then
  2. Array 里的 forEach / map / filter 等方法的回调当然也会同步执行 … 不要搞混 …

Funny Fact:
浏览器执行timer的最小时间间隔是4ms,也就是说即便 setTimeout(fn, 0), 那也是最快4ms后执行

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
setTimeout(()=>{
console.log('A');
},0);
var obj={
func:function () {
setTimeout(function () {
console.log('B')
},0);
return new Promise(function (resolve) {
console.log('C');
resolve();
})
}
};
obj.func().then(function () {
console.log('D')
});
console.log('E');

输出: C -> E -> D -> A -> B

执行过程解释(从同步代码开始):

  1. setTimeout,将输出A的函数压入MacroTask队列🚀
  2. obj对象赋值
  3. 取func执行【进入func】
  4. setTimeout,将输出B的函数压入MacroTask队列🚀
  5. 用匿名函数构造Promise, 此时输出C🖨,并将Promise状态改为resolve
  6. 返回 Promise 【退出func】
  7. 【func().then】为返回的Promise设置fullfill回调函数,因为该Promise已经状态变为fullfill, 所以输出D的回调函数被压入 MicroTask 队列等待执行🚲
  8. 输出E🖨【同步代码执行完毕】
  9. 检查 MicroTask 队列是否有任务,如果有则全部取出顺序执行 -> 输出D🖨
  10. 检查 MacroTask 队列是否为空,如果有任务,取最先入队的任务执行,输出A🖨
  11. 检查 MicroTask 队列,为空
  12. 检查 MacroTask,输出B🖨

Nodejs 里的事件循环

上述案例说的都是浏览器里的事件循环模型,那Nodejs里的事件循环模型也是这样的吗?

从行为的角度,类似,但不完全一样;

从模型的角度,完全不同;

Nodejs的事件循环模型,类似于一个永远循环的while语句,其中每次循环过程中,处理过程又分为6个阶段

  1. timers 阶段:这个阶段执行timer(setTimeout、setInterval)的回调
  2. I/O callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
  3. idle, prepare 阶段:仅node内部使用
  4. poll 阶段:获取新的I/O事件, 适当的条件下node将阻塞在这里
  5. check 阶段:执行 setImmediate() 的回调
  6. close callbacks 阶段:执行 socket 的 close 事件回调

并且在每个阶段结束时,会执行 MicroTask

从理解程序执行顺序的角度,常见的MacroTask和MicroTask归类可以参考如下:

MacroTask: setTimeout / setInterval / setImmediate / IO

MicroTask: process.nextTick / promise.then

但其中需要注意的是,事实上 process.nextTick 不属于 event-loop, 其有自己的任务队列,并且在处理时会优先于其他MicroTask

案例

1
2
3
4
5
6
7
8
9
10
11
12
console.log('script start')
Promise.resolve().then(() => console.log('promise1'))
setTimeout(() => {
console.log('timeout1')
}, 0);
setImmediate(() => {
console.log('immediate1')
})
process.nextTick(() => {
console.log('nextTick')
})
console.log('script end')

答案是:

script start -> script end -> nextTick -> promise1 -> timeout1 -> immediate1

或:

script start -> script end -> nextTick -> promise1 -> immediate1 -> timeout1

怎么会还有呢?

因为这里setTimeout(fn, 0) 和 setImmediate 的执行先后顺序是看具体运行时而定的,Nodejs规定setTimeout中的timeout为[1,2147483647] ms,也就是 setTimeout(fn, 0) === setTimeout(fn, 1),即最快也是1ms后执行

而如果在具体运行时,进入事件循环的时机,如果还没到1ms,那setTimeout所在的timer阶段就可以起作用,setTimeout就会先于setImmediate执行;如果超过了1ms,那setTimeout就先被跳过,setImmediate会先执行

除此之外还需要注意的就是 process.nextTick 插队性非常强,会优先于其他微任务运行

Share