定时器,Immediates 和 Process.nextTick —— Node 事件循环 Part 2

492 阅读6分钟

译者:焉逢

原文链接

欢迎回到⎣事件循环⎤文章系列!在第一篇文章中,我整体介绍了 Node 的事件循环。在这篇文章中,我准备用一些代码片段作为例子,来详细探讨我们前文提及的三个重要的事件队列 —— 定时器队列immediates 队列以及 nextTick 队列

本系列文章指引

  • 事件循环总体概览

  • 定时器, Immediates 和 Process.nextTick (本文)

  • Resolved Promises 和 Process.nextTick — 未完成

  • I/O 的处理 — 未完成

  • 处理事件循环的最佳实践 — 未完成

  • 编写异步组件(async Add-ons) — 未完成

nextTick 队列

先让我们再次看看前文出现过的图示。

图中,nextTick 队列被区分开来,因为它是由 Node 实现而非 libuv 原生提供

对于事件循环的每个阶段(定时器队列,I/O 事件队列,immediates 队列,close 处理队列是 4 个主要阶段),事件循环在移动到某一阶段之前,Node 会检查 nextTick 队列中是否有可处理的事件。如果有,在前往下一个主要阶段之前,事件循环会先直接处理这个队列直至它为空(当然还有其他 microtask 队列)。

这带来了一个新的问题。递归、无限的调用 process.nextTicknextTick 队列添加事件将导致 I/O 和其他队列永远处于饥饿(starve)状态。我们可以用下面的代码模拟这种情况。

const fs = require('fs');

function addNextTickRecurs(count) {
    let self = this;
    if (self.id === undefined) {
        self.id = 0;
    }

    if (self.id === count) return;

    process.nextTick(() => {
        console.log(`process.nextTick call ${++self.id}`);
        addNextTickRecurs.call(self, count);
    });
}

addNextTickRecurs(Infinity);
setTimeout(console.log.bind(console, 'omg! setTimeout was called'), 10);
setImmediate(console.log.bind(console, 'omg! setImmediate also was called'));
fs.readFile(__filename, () => {
    console.log('omg! file read complete callback was called!');
});

console.log('started');

你可以看到输出是一个无限循环的 nextTick 回调调用,而 setTimeoutsetImmediatefs.readFile 回调从未被调用,因为任何 ""omg!..."" 都没有打印到控制台中。

started
process.nextTick call 1
process.nextTick call 2
process.nextTick call 3
process.nextTick call 4
process.nextTick call 5
process.nextTick call 6
process.nextTick call 7
process.nextTick call 8
process.nextTick call 9
process.nextTick call 10
process.nextTick call 11
process.nextTick call 12
....

你可以尝试设置一个有限的值作为 addNextTickRecurs 调用的参数,你将看到 setTimeoutsetImmediatefs.readFile 回调会在一系列 process.nextTick 回调后面被调用。

在 Node v0.12 之前,有一个参数 process.maxTickDepth 可以设置 nextTick 队列的上限。可以由开发者手动设置,以便 Node 处理不超过上限的 nextTick 队列。但由于某些原因,这个特性已经自 v0.12 起被删除了。因此,对于较新版本的 Node,重复地往 nextTick 队列中添加事件仅仅是不被鼓励的做法。

定时器队列

每当你使用 setTimeoutsetInterval 添加定时器回调时,Node 将添加定时器到定时器堆(timer heap)中,这是一个由 libuv 访问的数据结构。在事件循环的定时器阶段,Node 会检查定时器堆中已过期的 timer/interval ,并调用它们各自的回调。如果有多个已过期的定时器(比如设置了相同的过期时间),那它们会按照它们被设置的顺序执行。

当一个 timer/interval 设置了明确的到期时间时,并不能确保在到期后能准时地执行回调。定时器回调的调用,依赖于系统的性能(Node 在执行回调前需要检查时间,显然这需要一些 CPU 时间)以及当前事件循环中的执行情况。事实上,到期时间确保的是,定时器回调至少在该时间之前,不会被调用。我们可以模拟一下:

const start = process.hrtime();

setTimeout(() => {
    const end = process.hrtime(start);
    console.log(`timeout callback executed after ${end[0]}s and ${end[1]/Math.pow(10,9)}ms`);
}, 1000);

上面的代码设置了一个过期时间为 1000ms 的定时器,它会打印出执行回调时消耗的时间。如果你运行该代码多次,你会注意到它每次的结果都不尽相同,并且永远不会出现 timeout callback executed after 1s and 0ms 这样的结果。你看到的结果类似这样:

timeout callback executed after 1s and 0.006058353ms
timeout callback executed after 1s and 0.004489878ms
timeout callback executed after 1s and 0.004307132ms
...

setTimeoutsetImmediate 一起使用时,定时器的这种特性可能会导致意外和不可预测的结果,接下来你就会看到了。

Immediates 队列

尽管在行为上,immediates 队列和定时器队列有点相似,但它也有自己的一些独特特性。不像定时器,即使过期时间为0,我们也不能确保它的回调何时会执行,但 immediates 队列被确保会在事件循环的 I/O 阶段之后被立即执行。可以通过 setImmediate 向此队列添加一个事件(回调):

setImmediate(() => {
   console.log('Hi, this is an immediate');
});

setTimeout vs setImmediate ?

现在,我们回过头来看看文章开头的那个图示,可以看到当事件循环开始执行的时候,Node 首先会处理定时器队列。接着,在处理完 I/O 之后,会来到 immediates 队列。通过图示,我们很容易推断出以下代码的输出结果。

setTimeout(function() {
    console.log('setTimeout')
}, 0);
setImmediate(function() {
    console.log('setImmediate')
});

你可能会猜,以上代码永远会在 setImmediate 之前打印 setTimeout,因为定时器队列的处理优先于 immediates 队列。然而事实是,以上代码的运行结果是永远不确定的!如果你试着运行多次,你会得到不一样的输出结果。

这是因为设定一个 0 秒过期的定时器,永远不能保证在 0 秒后回调会被准时调用。正因如此,当事件循环启动时,它可能还没有看到已过期的定时器。这时候事件循环将前往 I/O 阶段,接着来到 immediates 队列,发现这队列中有一个待处理的事件,于是执行它。

但对于下面这段代码,我们可以确保 immediate 回调永远会比定时器回调先执行。

const fs = require('fs');

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout')
    }, 0);
    setImmediate(() => {
        console.log('immediate')
    })
});

我们来看看它的执行流程。

  • 一开始,程序通过 fs.readFile 异步读取当前文件,并提供了一个回调

  • 事件循环启动

  • 一旦文件读取完成,一个事件(待执行的回调)会被添加进事件循环的 I/O 队列中

  • 由于没有其他待执行的事件,Node 开始等待后续的 I/O 事件。当它看到文件读取完成的事件时,执行它

  • 在这个回调的执行过程中,一个定时器被添加到定时器堆中,一个 immediate 事件被添加到 immediates 队列中

  • 我们知道当前事件循环处于 I/O 阶段,并且此时没有其他 I/O 事件可处理,事件循环于是来到 immediates 阶段,它发现了在执行文件读取回调的过程中被添加的 immediate 回调,于是便直接执行它

  • 在事件循环的下一轮中,它发现了已过期的定时器,于是执行相应的回调

总结

让我们一起看看这些不同的阶段、队列,在整个事件循环中是如何工作的。以下是代码示例。

setImmediate(() => console.log('this is set immediate 1'));
setImmediate(() => console.log('this is set immediate 2'));
setImmediate(() => console.log('this is set immediate 3'));

setTimeout(() => console.log('this is set timeout 1'), 0);
setTimeout(() => {
    console.log('this is set timeout 2');
    process.nextTick(() => console.log('this is process.nextTick added inside setTimeout'));
}, 0);
setTimeout(() => console.log('this is set timeout 3'), 0);
setTimeout(() => console.log('this is set timeout 4'), 0);
setTimeout(() => console.log('this is set timeout 5'), 0);

process.nextTick(() => console.log('this is process.nextTick 1'));
process.nextTick(() => {
    process.nextTick(console.log.bind(console, 'this is the inner next tick inside next tick'));
});
process.nextTick(() => console.log('this is process.nextTick 2'));
process.nextTick(() => console.log('this is process.nextTick 3'));
process.nextTick(() => console.log('this is process.nextTick 4'));

以上代码执行完之后,以下这些事件会被添加到相应的事件队列中。

  • 3 个 immediate 事件
  • 5 个定时器事件
  • 5 个 nextTick 事件

我们看看执行流程:

  1. 事件循环启动时,它注意到 nextTick 队列非空,于是开始处理这个队列。在执行第二个 nextTick 回调的过程中,一个新的 nextTick 被添加到该队列的末尾,它将在当前 nextTick 队列结束后执行

  2. 开始执行定时器回调。在执行第二个定时器回调的过程中,一个事件被添加至 nextTick 队列

  3. 一旦定时器队列处理完,事件循环看到 nextTick 队列中有一个事件(在执行第二个定时器回调的过程中被添加的),于是处理 nextTick 队列

  4. 由于没有任何 I/O 事件可处理,事件循环来到了 immediates 阶段,并处理相应队列

很好,如果你运行以上代码,你将看到下面的输出结果:

this is process.nextTick 1
this is process.nextTick 2
this is process.nextTick 3
this is process.nextTick 4
this is the inner next tick inside next tick
this is set timeout 1
this is set timeout 2
this is set timeout 3
this is set timeout 4
this is set timeout 5
this is process.nextTick added inside setTimeout
this is set immediate 1
this is set immediate 2
this is set immediate 3

下篇文章我们将讨论更多关于 nextTick 回调和 promise 的内容。如果你发现了有什么需要更正或添加的内容,请随时回复。

参考链接: