微任务、宏任务、Event-Loop(nodejs篇)
前言
JavaScript是一个单线程的脚本语言。
思考一个问题,为什么不设计成多线程呢?
这与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
再思考一个问题,因为是单线程,所有的代码都会同步执行,那假如代码中有一个同步操作很花费时间,后续的代码在这个操作未执行完成前都会卡住不动,这该怎么办,程序在这傻等着显然不合乎常理。
所以,为了解决同步的问题,JavaScript引入了异步的概念。除了主线程上的同步操作外,Javascript还有个任务队列(task queue),所有大开销的任务都可以先通过异步函数包裹一下,通过回调函数的方式去触发它。我们只需要先把这个大开销的操作仍到任务队列中不管它,等主线程上同步任务都执行完毕后,再去轮询任务队列中的事件,看看哪个满足执行条件(比如setTimeout(fn, 100),已经等待了100ms,就判定为满足条件),满足的就放到主线程上执行。这样就解决了上面的问题,设计的非常巧妙。
在上面的分析中,我们知道,异步的任务都在任务队列中,而队列遵循先进先出原则,那么问题来了,假如队列里有很多个满足条件的异步操作,都可以挨个去主线程上去执行(遵循它们入队的顺序,先进先出),那假如有一个异步任务很重要,但它排在队尾,暂时还轮不到它,我们要让它插队怎么办???
这就引出了本篇文章的主题,微任务和宏任务。JavaScript会将异步任务划分为微任务和宏任务,微任务会在宏任务之前执行(因为每次从主线程切换到任务队列时,都会优先遍历微任务队列,后遍历宏任务队列)。
微任务和宏任务
我们先来通过一个小例子,初时一下微任务、宏任务。
一个小例子
// 1
setTimeout(() => { // 宏任务
console.log(4)
}, 0)
// 2
new Promise(resolve => {
resolve()
console.log(1)
// 3
}).then(data => { // 微任务
console.log(3)
})
// 4
console.log(2) // 同步任务
// 执行结果: 1 2 3 4
来看一下JavaScript引擎是如何执行这段代码的:
- 程序首先遇到了
setTimeout
,setTimeout
属于异步函数,并且属于宏任务,将其塞到宏任务队列中,先不执行。 - 再往下走,
new Promise(resolve)
,根据Promise
的特性,当使用new
关键字声明一个promise
对象时,会立即执行传入的resolve
方法,也就是说,resolve
函数里执行的代码属于同步代码,所以主线程直接执行,输出1
。 - 而之后的
.then()
方法属于异步方法,且属于微任务,所以将其塞到微任务队列中,先不执行。 - 再往下走,是同步代码
console.log
,所以主线程直接执行,输出2
。 - 这时,所有同步的代码已经执行完毕,主线程的执行队列为空,然后就切换到任务队列里,任务队列优先遍历微任务队列。
- 发现微任务队列中有步骤 3 中的
then
方法等待执行,且已满足执行条件,所以将then
方法移至主线程队列中执行,输入3
。执行完毕后,主线程队列再次为空,再次切换到任务队列。任务队列优先遍历微任务队列,此时发现微任务队列为空,开始遍历宏任务队列。(只有微任务队列为空时,才会开始遍历宏任务队列,并且每次只要从主线程切入任务队列,都会优先遍历一遍微任务队列) - 发现宏任务队列中有
1
步骤中的setTimeout
函数等待执行,且已满足执行条件,所以将setTimeout
中的回调函数移至主线程中执行,输出4
。执行完毕后,主线程队列为空,再次切换到任务队列,遍历微任务队列,为空。再遍历宏任务队列,也为空。发现所有代码都已执行完毕,程序终止。所以最终的执行结果为:1 2 3 4
再简单画个图理解一下:
通过这个小例子,我们推断:
- JavaScript执行代码时,同步任务顺序执行,遇到异步任务会先仍到任务队列中等待执行。
- 任务队列分为宏任务队列和微任务队列。
- 只有当主线程中的任务执行完毕后,才会去轮询执行任务队列中的异步任务,且每次优先执行微任务队列。
- 所有任务队列中的任务,最终都会放置在主线程完成。
- 从主线程切换到任务队列时,每次都会优先再轮询一遍微任务队列,只有微任务队列为空时,才会去轮询宏任务队列。
setTimeout
和promise.then
,都属于异步任务,且setTimeout
属于宏任务,promise.then
属于微任务。- 当主线程和任务队列都为空时,程序执行完毕。
接下来,让我们看看平时都遇到过哪些宏任务和微任务。
tip:上面的结论只是通过例子和平时的经验推断得到的,实际上中间还有很多细节需要注意,js有着自己独特的事件轮询机制,继续往下看吧。
宏任务MacroTask
图片来源:https://juejin.cn/post/6844903657264136200#comment
I/O (input/output)
一直听过文件的独写操作,I/O操作,那么什么是I/O操作呢?维基百科这么定义的。
大意就是,在计算机科学中,计算机之间或人与计算机之间的信息交换,都是I/O操作。比如两台计算机通过网卡进行信息交互,比如向硬盘写入数据或读取硬盘数据,比如人敲击鼠标键盘等,比如鼠标滑动,等等等都属于I/O。在浏览器端和node端,所有的I/O操作都属于宏任务。
setTimeout
设置一个定时器,在定时器到期后执行一个函数或指定的一段代码。在浏览器端和node端都属于宏任务。
setInterval
方法重复调用一个函数或执行一个代码段,在每次调用之间具有固定的时间延迟。在浏览器端和node端都属于宏任务。
setImmediate
node中特有的异步操作,只支持node端,属于宏任务。
requestAnimationFrame
是浏览器端特有方法,告诉浏览器,希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
注意:若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用
window.requestAnimationFrame()
参考:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame
该方法只支持浏览器端,异步操作,且属于宏任务。
微任务MicroTask
图片来源:https://juejin.cn/post/6844903657264136200#comment
Promise.then catch finally
浏览器和node都支持,异步任务,微任务队列。
process.nextTick
node中特有的异步方法,属于微任务,但是在微任务中,它的执行时机是最早的,比Promise.then还早。
MutationObserver
监听DOM树的更改,浏览器端特有功能。异步任务,属于微任务。
再来一个例子
// 1
setTimeout(() => {
console.log(5);
}, 0)
// 2
setImmediate(() => {
console.log(6)
})
// 3
new Promise((resolve, reject) => {
resolve()
console.log(1)
}).then(data => { // 4
console.log(4)
})
// 5
process.nextTick(() => {
console.log(3);
})
// 6
let a = 2
console.log(a);
// 输出结果:1 2 3 4 5 6
这个例子最终输入的结果是 1 2 3 4 5 6 ,我们来分析一下:
- 程序首先遇到
setTimeout
,异步任务,宏任务,仍到宏任务队列先不管 - 往下走,遇到
setImmediate
,异步任务,宏任务,扔到宏任务队列先不管 - 遇到
new Promise
,new关键字声明的promise
会自动执行resolve
的回调方法,输出 1 - 继续往下看,遇到
.then
方法,异步任务,微任务,扔到微任务队列先不管 - 继续往下,遇到
process.nextTick
,异步任务,微任务,扔到微任务队列先不管(此处就和上个例子有区别了,process.nextTick
的执行时机优先于其它微任务,换个角度看,我们可以将微任务队列分为两类,process.nextTick微任务
和除了process.nextTick的微任务
)。 - 遇到同步代码
console.log
,输出 2。此时主线程任务执行完毕,开始轮询任务队列,优先执行微任务队列,微任务队列中,优先执行process.nextTick
队列,发现有第5步中的process.nextTick
任务未执行,将任务置于主线程执行,输出 3。 - 主线程执行完毕,继续轮询任务队列,优先轮询微任务队列里的
nextTick队列
,发现为空,开始轮询除了nextTick的微任务队列
,发现步骤 4 中的then
方法等待执行,将其置于主线程中执行,输出 4. - 主线程执行完毕,继续轮询,发现微任务队列已空,开始轮询宏任务队列,发现步骤1中的
setTimeout
已经可以执行,移至主线程中执行,输出 5 - 主线程执行完毕,继续轮询,发现微任务队列已空,开始轮询宏任务队列,发现步骤2中的
setImmediate
已经可以执行,移至主线程中执行,输出 6 - 主线程执行完毕,任务队列为空,程序结束。
再画个图理解一下:
在这个例子里,我们只能够确定,在一次轮询中,process.nextTick
微任务总会优先执行,但是宏任务中的setTimeout
和setImmediate
的执行顺序仍不能确定(往下看就会明白了)。要想知道实际的执行时机,得知道任务队列到底是怎么轮询的,也就是我们常说的,事件轮询Event-Loop
机制。
事件轮询 Event-Loop
事件循环实际上有六个阶段:timer->Pending I/O callbacks->Idle, prepare->Poll->Check->Close callbacks。
事件轮询是一直循环往复的,只有当任务队列为空时,才会停止循环,且在每一趟循环中,每一个环节都会有对应的操作。
接下来我们看看轮询的每个阶段都在干什么。
事件循环的六个阶段
timer 阶段
定时器阶段,处理setTimeout()
和setInterval()
的回调函数。进入这个阶段后,主线程会检查一下当前时间,是否满足定时器的条件。如果满足就执行回调函数,否则就离开这个阶段。
Pending I/O callbacks 阶段
除了以下操作的回调函数,其他的回调函数都在这个阶段执行。
setTimeout()
和setInterval()
的回调函数,(因为它在timer阶段执行)setImmediate()
的回调函数,(因为它在Check阶段执行)- 用于关闭请求的回调函数,比如
socket.on('close', ...)
,(因为它在Close callbacks阶段执行)
Idle, prepare 阶段
该阶段只供 libuv 内部调用,可以忽略。
Poll 阶段
这个阶段是轮询时间,用于等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等等。这个阶段的时间会比较长。如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果。
Check 阶段
该阶段执行setImmediate()
的回调函数。
Close callbacks 阶段
该阶段执行关闭请求的回调函数,比如socket.on('close', ...)
。
例子
我们再来个例子理解一下:
const fs = require('fs');
const startTime = Date.now();
// 宏任务1:setTimeout,100ms 后执行的定时器
setTimeout(() => {
const delay = Date.now() - startTime;
console.log(`${delay}ms`);
}, 100);
// 宏任务2:I/O操作,文件读取后,有一个 500ms 的回调函数
fs.readFile('1.js', () => {
const startCallback = Date.now();
console.log(`${startCallback - startTime} ms`, 'read file callback');
while (Date.now() - startCallback < 500) {
// 什么也不做
}
});
new Promise((resolve, reject) => {
resolve()
console.log(1);
}).then(data => { // 微任务1:.then
console.log(2);
})
// 宏任务3:setImmediate
setImmediate(() => {
console.log(3);
})
// 微任务2:process.nextTick
process.nextTick(() => {
console.log(4);
})
// 宏任务4:setTimeout
setTimeout(() => {
console.log(6);
}, 0)
console.log(5);
// 输出 1 5 4 2 6 3 【9 ms read file callback】509ms
上面的例子有几点需要注意:
- 所有的函数执行都是在主线程中,从主线程中切会任务队列时,总会优先遍历一遍微任务队列。
- 微任务队列优先执行process.nextTick
- 从主线程中切回宏任务队列继续事件轮询时,会承接上阶段继续轮询,比如上一阶段是timer,那么继续I/O阶段轮询,依此类推。
- 轮询总共六个阶段,timer、I/O callbacks、Idle prepare、Poll、check、close callbacks,只要任务队列没有被清空,事件循环就一直循环往复。
setTimeout和setImmediate
由于setTimeout
在 timers 阶段执行,而setImmediate
在 check 阶段执行。所以,setTimeout
会早于setImmediate
完成。
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
上面代码应该先输出1
,再输出2
,但是实际执行的时候,结果却是不确定的,有时还会先输出2
,再输出1
。这是因为setTimeout
的第二个参数默认为0
。但是实际上,Node 做不到0毫秒,最少也需要1毫秒。也就是说,setTimeout(f, 0)
等同于setTimeout(f, 1)
。实际执行的时候,进入事件循环以后,有可能到了1毫秒,也可能还没到1毫秒,取决于系统当时的状况。如果没到1毫秒,那么 timers 阶段就会跳过,进入 check 阶段,先执行setImmediate
的回调函数。
但是,下面的代码一定是先输出2,再输出1。
const fs = require('fs');
fs.readFile('1.js', () => {
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
});
上面代码会先进入 I/O callbacks 阶段,然后是 check 阶段,最后才是 timers 阶段。因此,setImmediate
才会早于setTimeout
执行。
参考
Tasks, microtasks, queues and schedules