Skip to content

微任务、宏任务、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会将异步任务划分为微任务和宏任务,微任务会在宏任务之前执行(因为每次从主线程切换到任务队列时,都会优先遍历微任务队列,后遍历宏任务队列)。

微任务和宏任务

我们先来通过一个小例子,初时一下微任务、宏任务。

一个小例子

js
// 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引擎是如何执行这段代码的:

  1. 程序首先遇到了setTimeoutsetTimeout属于异步函数,并且属于宏任务,将其塞到宏任务队列中,先不执行。
  2. 再往下走,new Promise(resolve),根据Promise的特性,当使用new关键字声明一个promise对象时,会立即执行传入的resolve方法,也就是说,resolve函数里执行的代码属于同步代码,所以主线程直接执行,输出1
  3. 而之后的.then()方法属于异步方法,且属于微任务,所以将其塞到微任务队列中,先不执行。
  4. 再往下走,是同步代码console.log,所以主线程直接执行,输出2
  5. 这时,所有同步的代码已经执行完毕,主线程的执行队列为空,然后就切换到任务队列里,任务队列优先遍历微任务队列。
  6. 发现微任务队列中有步骤 3 中的then方法等待执行,且已满足执行条件,所以将then方法移至主线程队列中执行,输入3。执行完毕后,主线程队列再次为空,再次切换到任务队列。任务队列优先遍历微任务队列,此时发现微任务队列为空,开始遍历宏任务队列。(只有微任务队列为空时,才会开始遍历宏任务队列,并且每次只要从主线程切入任务队列,都会优先遍历一遍微任务队列)
  7. 发现宏任务队列中有1步骤中的setTimeout函数等待执行,且已满足执行条件,所以将setTimeout中的回调函数移至主线程中执行,输出4。执行完毕后,主线程队列为空,再次切换到任务队列,遍历微任务队列,为空。再遍历宏任务队列,也为空。发现所有代码都已执行完毕,程序终止。所以最终的执行结果为:1 2 3 4

再简单画个图理解一下:

微任务宏任务代码解析

通过这个小例子,我们推断:

  1. JavaScript执行代码时,同步任务顺序执行,遇到异步任务会先仍到任务队列中等待执行。
  2. 任务队列分为宏任务队列和微任务队列。
  3. 只有当主线程中的任务执行完毕后,才会去轮询执行任务队列中的异步任务,且每次优先执行微任务队列。
  4. 所有任务队列中的任务,最终都会放置在主线程完成。
  5. 从主线程切换到任务队列时,每次都会优先再轮询一遍微任务队列,只有微任务队列为空时,才会去轮询宏任务队列。
  6. setTimeoutpromise.then,都属于异步任务,且setTimeout属于宏任务,promise.then属于微任务。
  7. 当主线程和任务队列都为空时,程序执行完毕。

接下来,让我们看看平时都遇到过哪些宏任务和微任务。

tip:上面的结论只是通过例子和平时的经验推断得到的,实际上中间还有很多细节需要注意,js有着自己独特的事件轮询机制,继续往下看吧。

宏任务MacroTask

image-20211103102111067

图片来源:https://juejin.cn/post/6844903657264136200#comment

I/O (input/output)

一直听过文件的独写操作,I/O操作,那么什么是I/O操作呢?维基百科这么定义的。

image-20211103102820008

大意就是,在计算机科学中,计算机之间或人与计算机之间的信息交换,都是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

image-20211103102126271

图片来源:https://juejin.cn/post/6844903657264136200#comment

Promise.then catch finally

浏览器和node都支持,异步任务,微任务队列。

process.nextTick

node中特有的异步方法,属于微任务,但是在微任务中,它的执行时机是最早的,比Promise.then还早。

MutationObserver

监听DOM树的更改,浏览器端特有功能。异步任务,属于微任务

再来一个例子

js
// 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 ,我们来分析一下:

  1. 程序首先遇到setTimeout,异步任务,宏任务,仍到宏任务队列先不管
  2. 往下走,遇到setImmediate,异步任务,宏任务,扔到宏任务队列先不管
  3. 遇到new Promise,new关键字声明的promise会自动执行resolve的回调方法,输出 1
  4. 继续往下看,遇到.then方法,异步任务,微任务,扔到微任务队列先不管
  5. 继续往下,遇到process.nextTick,异步任务,微任务,扔到微任务队列先不管(此处就和上个例子有区别了,process.nextTick的执行时机优先于其它微任务,换个角度看,我们可以将微任务队列分为两类,process.nextTick微任务除了process.nextTick的微任务)。
  6. 遇到同步代码console.log,输出 2。此时主线程任务执行完毕,开始轮询任务队列,优先执行微任务队列,微任务队列中,优先执行process.nextTick队列,发现有第5步中的process.nextTick任务未执行,将任务置于主线程执行,输出 3。
  7. 主线程执行完毕,继续轮询任务队列,优先轮询微任务队列里的nextTick队列,发现为空,开始轮询除了nextTick的微任务队列,发现步骤 4 中的then方法等待执行,将其置于主线程中执行,输出 4.
  8. 主线程执行完毕,继续轮询,发现微任务队列已空,开始轮询宏任务队列,发现步骤1中的setTimeout已经可以执行,移至主线程中执行,输出 5
  9. 主线程执行完毕,继续轮询,发现微任务队列已空,开始轮询宏任务队列,发现步骤2中的setImmediate已经可以执行,移至主线程中执行,输出 6
  10. 主线程执行完毕,任务队列为空,程序结束。

再画个图理解一下:

微任务宏任务代码解析2

在这个例子里,我们只能够确定,在一次轮询中,process.nextTick微任务总会优先执行,但是宏任务中的setTimeoutsetImmediate的执行顺序仍不能确定(往下看就会明白了)。要想知道实际的执行时机,得知道任务队列到底是怎么轮询的,也就是我们常说的,事件轮询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', ...)

例子

我们再来个例子理解一下:

js
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完成。

js
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。

js
const fs = require('fs');

fs.readFile('1.js', () => {
  setTimeout(() => console.log(1));
  setImmediate(() => console.log(2));
});

上面代码会先进入 I/O callbacks 阶段,然后是 check 阶段,最后才是 timers 阶段。因此,setImmediate才会早于setTimeout执行。

参考

微任务、宏任务与Event-Loop

Tasks, microtasks, queues and schedules

JavaScript 运行机制详解:再谈Event Loop

node-event-loop

Event Loop Explained

Promises/A+规范

event-loop-processing-model

EventTarget.dispatchEvent

MutationObserver