zhangxiang958/Blog

[译]定时器,Immediates 和 process.nextTick——NodeJS 事件循环 Part 2

zhangxiang958 opened this issue · 4 comments

原文链接:https://jsblog.insiderattack.net/timers-immediates-and-process-nexttick-nodejs-event-loop-part-2-2c53fd511bb3
欢迎回到事件循环系列文章!在第一篇文章中,我讲述了 NodeJS 事件循环的整体概述。在本篇文章中,我将会深入使用样例代码讲述的三个重要的事件队列中的细节。它们是 timers 定时器,immediates,和 process.nextTick 回调函数。

文章系列目录

  • 事件循环总览
  • 定时器,Immediates 和 process.nextTick(本篇文章)
  • Resolved Promises 和 process.nextTick
  • I/O 处理
  • 事件循环的最佳实践

Next Tick 队列

我们来回顾一下上一篇文章看到的关于事件循环的图。

Next Tick 队列分别在其他四个主要的队列执行间隙中执行,因为它并不是 libuv 原生提供的,而是由 Node 实现的。

在事件循环每个阶段之前(处理定时器队列,IO 事件队列,immediates 队列,close 事件处理队列是四个主要的事件处理阶段),也就是在移动到下一个阶段前,Node 会检查 nextTick 队列是否有待处理的函数。如果有,那么 Node 将会立即开始处理队列中的函数,直到队列为空,在每次准备移动到下一个事件循环阶段都会进行这样的处理。

这样带来了一个新的问题。如果我们不断地递归地使用 process.nextTick 将回调函数添加到 nextTick 队列中,将会引起 I/O 和其他队列永远不会被处理执行。我们可以通过下面这个简单的脚本来模拟这样的情况:

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 的回调被不断调用打印出信息,但是 setTimeout,setImmediate,和 fs.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 函数设置一个有限的值,然后你将会看到 setTimeout,setImmediate,和 fs.readFile 回调函数在 process.nextTick 添加的所有回调函数执行完后被执行。

在 Node v0.12 之前,有一个叫做 process.maxTickDepth 的参数可以用于限制 process.nextTick 队列的长度。它可以被开发者手动设置,这样 Node 的 next tick 队列的长度将不会超过设置的阀值。但是因为某些原因这个参数在 Node v0.12 之后被移除了, 因此,对于新版本的 Node, 不断地添加事件函数到 next tick 队列中是不被提倡的。

Timers 队列

当你使用 setTimeout 设置定时器或者使用 setInterval 设置一个不断调用的定时器的时候,Node 将会把这些添加的定时器函数添加到 libuv 中的一个称为定时器堆(timer heap)的数据结构中。在 Node 的事件循环定时器阶段中,Node 将会检查定时器堆(timer heap)中是否有到期的定时器或 interval,并分别执行它们的回调函数。如果该时刻有不止一个定时器需要被执行,那么它们将会按照被添加到队列的顺序一一被执行。

当定时器或者 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
...

原生的定时器 setTimeout 和 setImmediate 一起使用的话,它的执行时机是不可预测的,造成不可预知的结果。我将会在后面章节讲解。

Immediates 队列

尽管 immediate 队列在表现行为上与定时器队列很相似,但是它还是有一些不同的地方。不像定时器即使设置的过期时间为 0 也不能保证其执行的时机,immediate 队列可以保证在事件循环中在 I/O 阶段之后一定会被立即执行。可以使用 setImmediate 这个函数来将回调添加到队列中:

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

setTimeout vs setImmediate ?

现在,当我们看一下文章开始的关于事件循环的图,你可以发现程序的执行时机,Node 是从定时器开始处理,然后处理 I/O,然后再处理 immediate 队列。通过上面那幅图,我们可以轻易地推断出下面程序的结果:

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

你可能会猜想,这个程序运行后会先打印 setTimeout 然后再打印 setImmediate 因为到期的定时器会比 immediate 队列先执行。但是这个程序的结果是不可预料的!如果你多次运行这个程序,你会得到不一样的结果。

这是因为为一个定时器的过期时间设置为 0 无法保证这个定时器的回调一定会在 0 秒后被执行。因为这个原因,当事件循环开始的时候,它可能没有检测到这个到期的定时器,然后事件循环就移动到了下一个阶段即 I/O 阶段 然后到了 immediate 阶段。然后事件循环发现 immediate 队列中有待处理的程序然后就执行它。

但是我们看一下下面的程序,我们可以保证 immediate 的回调函数一定会比定时器的回调先执行:

const fs = require('fs');

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

来一起看一下这个程序的执行流程。

  • 在开始的时候,这个程序会异步地使用 fs.readFile 函数来读取文件内容,并且提供一个回调函数当文件内容读取到了之后会执行。
  • 然后事件循环开始。
  • 当文件内容读取到之后,它将会把事件(指定的回调函数)添加到 I/O 队列中。
  • 因为此时没有其他事件需要被处理,Node 将会等待 I/O 操作完成,并执行 I/O 队列中的事件。
  • 在回调函数的执行过程中,一个定时器被添加到了定时器堆(timers heap)中,一个 immediate 被添加到了 immediate 队列中。
  • 现在我们执行事件循环处于 I/O 处理阶段,因为没有其他的 I/O 事件需要被处理,事件循环将会移动到下一个阶段去处理 immediate 队列中的事件,它会检测到刚刚被添加到 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 immediates
  • 5 timer callbacks
  • 5 next tick callbacks

来一起看一下执行的过程:

  1. 当事件循环开始的时候,它将会注意到 next tick 队列不为空,并且开始处理 next tick 队列的函数。在执行第二个 next tick 回调的时候,一个新的 next tick 回调函数被添加到 next tick 队列的最后,它将会在 next tick 队列中最后被执行。
  2. 然后事件循环进入 timmer 阶段,开始执行到期定时器添加到队列中的回调,在第二个定时器的回调中,一个 next tick 回调函数被添加到 next tick 队列中。
  3. 当定时器队列的所有回调都被执行之后,事件循环发现 next tick 队列有一个待处理的回调即刚刚定时器回调中添加的那个 next tick 回调函数,然后事件循环就会执行这个函数。
  4. 因为此时没有 I/O 事件需要被处理,事件循环移动到 immediate 阶段,执行 immediate 队列的函数。

很好!如果你执行过上面的代码,你将会得到以下的输出:

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

在下一篇文章中,我将会讲述 next tick 回调与 resolved Promise。

如果对本篇文章有任何意见或建议,请踊跃评论。

References:

image
是为空吧

@caohuilin 感谢指正!文章已修改

MrZJD commented

哈喽!请问一下,结论中的例子,为什么immediate和timer执行顺序是固定的?我把nextTick给注释以后,就出现了immediate和timer输出顺序不一样。加上了nextTick顺序就是稳定的。感觉有点儿奇怪。(env: node: v8.11.3 platform: win7)

@MrZJD 如果去掉了 process.nextTick 那么,代码就类似于:

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

这样的例子代码在文章中也有体现,setTimeout 与 setImmediate 的执行顺序是不能保证的,原因在于 setTimeout 的执行时机与 CPU 和进程的性能有关。setTimeout 的事件会存放在一个 heap 中,在一次事件循环中,可能在当前循环节点上定时器并没有到期,所以就先执行了 setImmediate。

但是如果添加了 process.nextTick 之后,process.nextTick 属于微任务队列中的事件,此时微任务中的队列会先执行直到微任务队列中的所有任务都清空为止,那么此时添加的定时器已经到期了,那么就执行定时器事件,然后执行完定时器事件之后,由于没有 I/O 事件,事件循环会执行 setImmediate 的相关事件,所以最后一个例子中,timmer 的输出顺序比 setImmediate 要早。