多图预警,手把手教你 Node.js 事件循环机制(二)

52 阅读5分钟

本文翻译于 Visualizing nextTick and Promise Queues in Node.js Event Loop

这是解析 Node.js 事件循环系列文章的第二篇。在第一篇文章中我们了解到事件循环是 Node.js 的重要组成部分,协助 Node.js 执行同步代码和异步代码。

事件循环拥有六个不同的队列,一个 nextTick 队列和一个 promise 队列(其中这两个队列被称为微任务队列 microtask queues),一个 timer 队列,一个 I/O 队列,一个 check 队列和一个 close 队列。在这个循环中,回调函数会在适当的时候出队,并在堆栈上执行。在本文中,让我们执行几个事例来检验我们的可视化事件循环是正确的。

我们第这部分的事例主要关注 nextTick 队列和 promise 队列。在执行事例之前,我们先来了解一下是如何把回调函数放入这些队列的。

把回调函数加入 nextTick 队列,我们是通过调用内置的 process.nextTick() 方法。语法很简单:process.nextTick(callbackFn)。当我们在执行此方法时,回调函数将加入 nextTick 队列。

把回调函数加入 Promise 队列,我们是通过调用 Promise.resolve().then(callbackFn)。当 Promise 解析时,传递给 then() 中的回调函数将在 promise 队列中排队。

现在我们了解了如何向两个队列添加回调函数,让我们开始第一个事例。

事例1

// index.js
console.log("console.log 1");
process.nextTick(() => console.log("this is process.nextTick 1"));
console.log("console.log 2");

我们写了一个代码片段,用于打印三个不同的语句。第二个语句使用 process.nextTick() 方法将回调函数放入nextTick队列中。

ezgif-1-8f9e21b74b.gif

  • 第 1 行 console.log() 语句被推送到调用堆栈来执行。它在控制台中打印相应的消息,然后从堆栈中弹出。
  • 接下来,process.nextTick() 在调用堆栈上执行。这会将回调函数放入到 nextTick 队列中并弹出。由于仍有用户编写的代码要执行,因此回调函数必须等待直到下次轮到它。
  • 执行继续,最后一行 console.log() 语句被推送到堆栈上。消息被打印到控制台,函数从堆栈中弹出。现在,没有更多用户编写的同步代码要执行,因此控制权进入事件循环。
  • 来自 nextTick 队列的回调函数被推送到堆栈,console.log()被推送到堆栈上执行,并且相应的消息被打印到控制台。

结论

所有用户编写的同步 JavaScript 代码都优先于异步代码执行

事例2

// index.js
Promise.resolve().then(() => console.log("this is Promise.resolve 1"));
process.nextTick(() => console.log("this is process.nextTick 1"));

我们先调用 Promise.resolve().then(),再调用process.nextTick()

ezgif-4-f1cfeb7ace.gif

  • 当调用堆栈执行第 1 行时,它将回调函数加入 promise 队列。
  • 当调用堆栈执行第 2 行时,它将回调函数加入 nextTick 队列。
  • 第 2 行之后不再执行用户编写的代码,控制进入事件循环,其中nextTick队列优先于 promise 队列(这是 Node.js 运行时的工作方式)。
  • 事件循环先执行nextTick 队列回调函数,然后执行 promise 队列回调函数。
  • 控制台先显示 this is process.nextTick 1,再显示 this is Promise.resolve 1

结论

nextTick 队列中的所有回调函数都会在 promise 队列中的回调函数之前执行

事例3

// index.js
process.nextTick(() => console.log("this is process.nextTick 1"));
process.nextTick(() => {
  console.log("this is process.nextTick 2");
  process.nextTick(() =>
    console.log("this is the inner next tick inside next tick")
  );
});
process.nextTick(() => console.log("this is process.nextTick 3"));

Promise.resolve().then(() => console.log("this is Promise.resolve 1"));
Promise.resolve().then(() => {
  console.log("this is Promise.resolve 2");
  process.nextTick(() =>
    console.log("this is the inner next tick inside Promise then block")
  );
});
Promise.resolve().then(() => console.log("this is Promise.resolve 3"));

代码包含三个调用 process.nextTick() 和三个调用 Promise.resolve() 的语句。每个回调函数都会打印相应的消息。

但是,第二个 process.nextTick() 和第二个 Promise.resolve() 的回调函数中多执行了一个process.nextTick() 语句。

ezgif-4-a97cc3ddc4.gif 为了加快这段代码的解释速度,我将省略调用堆栈。当调用堆栈执行完所有六个语句时,nextTick 队列中有三个回调,promise 队列中有三个回调。由于没有其他要执行的内容,因此控制权进入事件循环。

  • 通过上面的事例我们知道 nextTick 队列具有优先权。第一个回调被执行,并且相应的消息被打印到控制台。

  • 接下来,执行第二个回调函数,该函数打印第二条日志语句。回调函数又调用了 process.nextTick(),此时会把新调用的回调函数排在 nextTick 队列末尾。

  • 然后执行第三个 nextTick 回调,将相应消息打印到控制台。最初只有三个回调,但第二个回调将另一个回调添加到队列中,现在轮到它了。

  • 事件循环执行最后一个 nextTick 回调,打印 this is the inner next tick inside next tick 到控制台。

  • nextTick 队列为空,控制权将转到 promise 队列。promise 队列的执行过程与 nextTick 队列类似。

  • 首先,打印了 Promise.resolve 1,然后打印 Promise.resolve 2。这时调用了process.nextTick(),将一个函数被添加到nextTick队列中。但是控制权仍然在 promise 队列,会继续执行 promise 队列中的其他回调函数。然后打印 Promise.resolve 3,此时,promise 队列为空。

  • Node.js 将再次检查微任务队列中是否有新的回调。这时发现 nextTick 队列中还有一个回调函数,它会执行该回调函数,打印最后一条日志语句。

这是一个稍微复杂一点的事例,但结论与上面的事例是一样的。

结论

nextTick 队列中的所有回调函数都会在 promise 队列中的回调函数之前执行

总结

注意:要谨慎使用 process.nextTick()。过度使用此方法可能会导致事件循环陷入饥饿状态,从而阻止队列的其余部分运行。官方文档建议使用process.nextTick() 的场景主要有两个:

  • Allow users to handle errors, cleanup any then unneeded resources, or perhaps try the request again before the event loop continues.
  • At times it's necessary to allow a callback to run after the call stack has unwound but before the event loop continues.

这三个事例主要是说明了两件事:所有用户编写的同步 JavaScript 代码都优先于异步代码执行nextTick 队列中的所有回调函数都会在 promise 队列中的回调函数之前执行

原文

Visualizing nextTick and Promise Queues in Node.js Event Loop