4

前言

本文讨论的核心基本概念是 Event Loop,即事件循环,是 JavaScript 的执行模型,这是实现异步编程的核心。在不同的平台有不同的实现,浏览器基于 HTML5 的规范各自实现,而 NodeJS 基于 libuv 核心。他们虽然都是实现了异步通知的效果,但运行规则还是有些差别。
网络上关于“事件循环”的文章有很多,本文就不再多赘述,请阅读本文 前预备好这些背景知识,重点是 NodeJS 运行的 6 个阶段以及宏任务队列(Macrotask Queue)和微任务队列(Microtask Queue)的基本概念。
本人能力有限,不足之处还请批评指教。

本文要解决的问题

  1. 在 NodeJS 中运行完全局同步代码后是先运行微任务,还是先运行宏任务?
  2. 不同版本的 NodeJS 运行微任务的时机有差异吗?
  3. Next Tick Queue 和 Other Micro Queue 都是 NodeJS 中的微任务队列,他们的运行时机有什么差别?
  4. 微队列是一次清空,还是每一轮循环运行一个任务?

举个例子

console.log("start")

setTimeout(() => {
  console.log(1)
  Promise.resolve().then(() => {
    console.log(2)
  })
  setTimeout(() => {
    console.log(3)
  })
  setImmediate(() => {
    console.log(4)
  })
  process.nextTick(() => {
    console.log(5)
  })
})

setTimeout(() => {
  console.log(6)
  Promise.resolve().then(() => {
    console.log(7)
  })
  setTimeout(() => {
    console.log(8)
  })
  setImmediate(() => {
    console.log(9)
  })
  process.nextTick(() => {
    console.log(10)
  })
})

process.nextTick(() => {
  console.log(11)
})

console.log("end")

说明

上面的例子基本可以解决上述的疑问,每一个回调函数都只会打印出一个数字,这个数字也相当于该函数的编号;全局的“start”和“end”标明了全局同步代码的运行开始与结束。

例子中属于宏任务的 API:

  • setTimeout()
  • setImmediate()

例子中属于微任务的 API:

  • process.nextTick() 会进入 Next Tick Queue
  • Promise 会进入 Other Micro Queue

不同 NodeJS 版本的运行结果

只选取稳定版本进行测试;为了节约篇幅,把原本竖的打印结果打横展示。
setTimeout 和 setImmediate 执行顺序在同模块中是不一定,因此“ 4 9 ”和“ 3 8 ”多次运行结果可能会互调,由于这是属于宏任务的内容,这里不展开讨论。

v6.17.1

start end 11 1 6 5 10 2 7 4 9 3 8

v8.17.0

start end 11 1 6 5 10 2 7 4 9 3 8

v10.23.0

start end 11 1 6 5 10 2 7 4 9 3 8

v12.20.0

start end 11 1 5 2 6 10 7 4 9 3 8

v14.15.3

start end 11 1 5 2 6 10 7 4 9 3 8

结果分析

由上面的结果,解答前面提出的问题。

  1. 由于 11 是紧跟在全局代码执行的,因此可以得知,全局的同步代码运行完即会先开始微任务的运行。
  2. 不同版本的 NodeJS 运行是有差异的,以 v10.23.0 版本的结果为分界线,会有很明显的两个结果(经过进一步验证,这个变化从 v11.0.0 就开始)。至于产生这个不同的原因,将在下面详细分析。
  3. 以 v10.23.0 的结果来看,“ 5 10 ”是在“ 2 7 ”之前执行,因此可知 Next Tick Queue 是优先于 Other Micro Queue 的。
  4. 以 v10.23.0 的结果来看,“ 5 10 ”是在“ 2 7 ”是连续出现的,因此可知微队列是一次清空。由同样连续出现的“ 4 9 ”和“ 3 8 ”,也可知宏队列也有一次清空的性质,但他们不同 API 分属的队列不同。

分析新旧版本的差异

以 v11.0.0 版本为分界线,高于或等于 v11.0.0 称为新版本;低于 v11.0.0 称为旧版本。
这里宏任务队列不是讨论重点进行了简化处理。

旧版本运行过程

  1. 运行全局同步代码:

    1. 打印“start”;
    2. 1 入宏任务队列;
    3. 6 入宏任务队列;
    4. 11 入微任务队列的 Next Tick Queue;
    5. 打印“end”。
    宏任务队列:[1, 6]
    微任务队列的 Next Tick Queue:[11]
    微任务队列的 Other Micro Queue:[]
  2. 运行微任务队列,输出 11。

    宏任务队列:[1, 6]
    微任务队列的 Next Tick Queue:[]
    微任务队列的 Other Micro Queue:[]
  3. 从宏任务队列取出 1 运行:

    1. 打印“1”;
    2. 2 入微任务队列的 Other Micro Queue;
    3. 3 入宏任务队列;
    4. 4 入宏任务队列;
    5. 5 入微任务队列的 Next Tick Queue。
    宏任务队列:[6, 3, 4]
    微任务队列的 Next Tick Queue:[5]
    微任务队列的 Other Micro Queue:[2]
  4. 从宏任务队列取出 6 运行:

    1. 打印“6”;
    2. 7 入微任务队列的 Other Micro Queue;
    3. 8 入宏任务队列;
    4. 9 入宏任务队列;
    5. 10 入微任务队列的 Next Tick Queue。
    宏任务队列:[3, 4, 8, 9]
    微任务队列的 Next Tick Queue:[5, 10]
    微任务队列的 Other Micro Queue:[2, 7]
  5. 运行微任务队列:

    1. 清空 Next Tick Queue,打印“5”,“10”;
    2. 清空 Other Micro Queue,打印“2”,“7”。
    宏任务队列:[3, 4, 8, 9]
    微任务队列的 Next Tick Queue:[]
    微任务队列的 Other Micro Queue:[]
  6. 后续运行宏队列,在这里省略。

新版本运行过程

  1. 运行全局同步代码:

    1. 打印“start”;
    2. 1 入宏任务队列;
    3. 6 入宏任务队列;
    4. 11 入微任务队列的 Next Tick Queue;
    5. 打印“end”。
    宏任务队列:[1, 6]
    微任务队列的 Next Tick Queue:[11]
    微任务队列的 Other Micro Queue:[]
  2. 运行微任务队列,输出 11。

    宏任务队列:[1, 6]
    微任务队列的 Next Tick Queue:[]
    微任务队列的 Other Micro Queue:[]
  3. 从宏任务队列取出 1 运行:

    1. 打印“1”;
    2. 2 入微任务队列的 Other Micro Queue;
    3. 3 入宏任务队列;
    4. 4 入宏任务队列;
    5. 5 入微任务队列的 Next Tick Queue。
    宏任务队列:[6, 3, 4]
    微任务队列的 Next Tick Queue:[5]
    微任务队列的 Other Micro Queue:[2]
  4. 运行微队列:

    1. 清空 Next Tick Queue,打印“5”;
    2. 清空 Other Micro Queue,打印“2”。
    宏任务队列:[6, 3, 4]
    微任务队列的 Next Tick Queue:[]
    微任务队列的 Other Micro Queue:[]
  5. 从宏任务队列取出 6 运行:

    1. 打印“6”;
    2. 7 入微任务队列的 Other Micro Queue;
    3. 8 入宏任务队列;
    4. 9 入宏任务队列;
    5. 10 入微任务队列的 Next Tick Queue。
    宏任务队列:[3, 4, 8, 9]
    微任务队列的 Next Tick Queue:[10]
    微任务队列的 Other Micro Queue:[7]
  6. 运行微队列:

    1. 清空 Next Tick Queue,打印“10”;
    2. 清空 Other Micro Queue,打印“7”。
    宏任务队列:[3, 4, 8, 9]
    微任务队列的 Next Tick Queue:[]
    微任务队列的 Other Micro Queue:[]
  7. 后续运行宏队列,在这里省略。

差异总结

差异出现在第四步,旧版本是从宏队列取出任务执行,而新版本是处理微任务。
于是我们可以得到结论:旧版本会清空宏任务队列,再运行微任务;而新版本是每运行完一个宏任务,就去清空微任务队列。

额外补充

网上有流传微任务队列有深度限制的传说,好像说限制是 1000 ,在此验证一下。使用下面这个例子(一次性塞入 10000 个微任务):

console.log('start')

let a = 0
while (a < 10000) {
  process.nextTick(() => {
    console.log(111)
  })

  a += 1
}

setTimeout(()=>{
  console.log(123)
})

console.log('end')

在不同的版本(v6到v12)都能顺利运行,并得到相同结果:

start
end
111 x 10000
123

结论:微队列并不存在深度限制,但过多的微任务会导致系统一直在运行微任务而无法去运行其他任务,比如例子里处于宏任务的 123 将在 10000 个微任务运行完再运行,体验上有很明显的延迟,因此为了性能考量,不应泛滥地使用微任务。


calimanco
1.4k 声望766 粉丝

老朽对真理的追求从北爱尔兰到契丹无人不知无人不晓。