欢迎回到事件循环系列文章!在本系列的第一部分中,我描述了 NodeJS 事件循环的整体图。在这篇文章中,我将通过示例代码片段详细讨论我们在第一篇文章中讨论的三个重要队列。它们是timers、immediates和process.nextTick回调。
后期系列路线图
- 事件循环和大图
- 计时器、立即数和下一个滴答声(本文)
- Promises、Next-Ticks 和 Immediates
- 处理输入/输出
- 事件循环最佳实践
- Node v11 中计时器和微任务的新变化
- JavaScript 事件循环与 Node JS 事件循环
下一个滴答队列
让我们看看我们在上一篇文章中看到的事件循环图。
下一个滴答队列与其他四个主要队列分开显示,因为它是 不是由 libuv 本身提供的,但在 Node.js 中实现。
在事件循环的每个阶段(定时器队列、IO 事件队列、立即队列、关闭处理程序队列是四个主要阶段)之前,在进入该阶段之前,Node 会检查nextTick队列中是否有任何排队的事件。如果队列不为空,Node 将立即开始处理队列,直到队列为空,然后进入主事件循环阶段。
自 Node v11 以来,Node v11 中引入了一些更改,显着改变了这种行为。阅读更多:https : //medium.com/@dpjayasekara/new-changes-to-timers-and-microtasks-from-node-v11-0-0-and-above-68d112743eb3
这就引入了一个新问题。nextTick使用process.nextTick函数递归/重复地向队列添加事件会导致 I/O 和其他队列永远饥饿。我们可以使用以下简单脚本模拟这种情况。 ```javascript 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!...'**消息都打印在控制台中。
```html
启动
process.nextTick 调用 1
process.nextTick 调用 2
process.nextTick 调用 3
process.nextTick 调用 4
process.nextTick 调用 5
process.nextTick 调用 6
process.nextTick 调用 7
process.nextTick 调用 8
process.nextTick 调用 9
process.nextTick 调用10
process.nextTick 调用 11
process.nextTick 调用 12
....
您可以尝试设置一个有限值作为参数addNextTickRecurs并查看setTimeout,setImmediate并且fs.readFile会在process.nextTick call * log 消息的末尾调用回调。
在 Node v0.12 之前,有一个称为队列长度process.maxTickDepth的阈值的属性process.nextTick。这可以由开发人员手动设置,以便 NodemaxTickDepth在给定点处理的只是来自下一个滴答队列的回调。但是由于某种原因,自 Node v0.12 以来已将其删除。因此,对于较新的 Node 版本,不鼓励重复向下一个滴答队列添加事件。
定时器队列
当你添加定时器 setTimeout或间隔 setInterval,Node 会将定时器添加到定时器堆中,这是一个通过 libuv 访问的数据结构。在事件循环的计时器阶段,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);
上述程序将在程序启动时启动一个 1000 毫秒的计时器,并记录执行回调所花费的时间。如果你多次运行这个程序,你会注意到它每次都会打印出不同的结果,并且永远不会打印timeout callback executed after 1s and 0ms。你会得到这样的东西,
1s和0.006058353ms
后执行的超时回调1s和0.004489878ms
后执行的超时回调1s和0.004307132ms后执行的超时回调
...
超时的这种性质在setTimeout与setImmediate我将在下一节中解释的一起使用时会导致意外和不可预测的结果。
立即队列
尽管立即队列的行为方式有点类似于超时,但它有一些自己独特的特征。与即使计时器到期时间为零我们也不能保证其回调何时执行的计时器不同,立即队列保证在事件循环的 I/O 阶段之后立即处理。可以使用setImmediate函数将事件(函数)添加到立即队列,如下所示:
setImmediate(() => {
console.log('嗨,这是即时的');
});
setTimeout 与 setImmediate ?
现在,当我们查看本文顶部的事件循环图时,您可以看到当程序开始执行时,Node 开始处理计时器。稍后在处理 I/O 之后,它会进入立即队列。看这个图,我们可以很容易地推断出以下程序的输出。
正如您可能猜到的那样,该程序将始终setTimeout在 before打印,setImmediate因为过期的计时器回调是在立即数之前处理的。但是这个程序的输出永远无法保证!如果多次运行这个程序,你会得到不同的输出。
这是因为 NodeJS 将最小超时限制为一个有趣的事实,1ms以便与Chrome 的计时器上限保持一致。由于这个上限,即使您将计时器设置为0ms延迟,延迟实际上也会被覆盖并设置为1ms。
如果您想更多地了解这种行为在 Node 和不同浏览器中的差异,请查看以下博客文章中描述的小实验。
JavaScript 事件循环与 Node JS 事件循环理解浏览器中事件循环和 Node 中的区别的指南blog.insiderattack.net
在事件循环的新迭代开始时,NodeJS 调用系统调用来获取当前时钟时间。根据 CPU 的繁忙程度,获取当前时钟时间可能会或可能不会在1ms. 如果在 小于 检索时钟时间1ms,NodeJS 将检测到计时器未过期,因为计时器需要1ms过期。但是,如果获取时钟时间超过1ms,则计时器将在检索时钟时间时到期。在 Node 检测到计时器尚未到期的情况下,事件循环将进入 I/O 阶段,然后进入立即数队列。然后它会看到即时队列中有一个事件,它会处理它。因此,setImmediate在setTimeout回调之前。
但是,在下面的程序中,保证在定时器回调之前肯定会调用立即回调。
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 队列中看到文件读取事件并执行它。
- 在回调的执行过程中,一个定时器被添加到定时器堆中,一个立即数被添加到立即数队列中。
- 现在我们知道事件循环处于 I/O 阶段。由于没有任何 I/O 事件要处理,事件循环将移动到即时阶段,在那里它将看到在执行文件读取回调期间添加的即时回调。然后立即回调将被执行。
- 在事件循环的下一轮中,它会看到过期的计时器并执行计时器回调。
结论
所以让我们看看这些不同的阶段/队列在事件循环中是如何一起工作的。请参阅以下示例。 ```javascript 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 立即数
- 5个定时器回调
- 5 个nextTick回调
现在让我们看看执行流程:
1. 当事件循环开始时,它会注意到nextTick队列并开始处理nextTick回调。在第二个nextTick回调执行期间,一个新的nextTick回调被添加到nextTick队列的末尾,并将在nextTick队列的末尾执行。
2. 将执行过期定时器的回调。在第二个定时器回调的执行过程中,一个事件被添加到nextTick队列中。
3. 一旦执行了所有过期定时器的回调,事件循环就会看到nextTick队列中有一个事件(在第二个定时器回调执行期间添加)。然后事件循环将执行它。
4. 由于没有要处理的 I/O 事件,事件循环将移动到立即数阶段并处理立即数队列。
如果您运行上面的代码,您现在将获得以下输出。
```javascript
这是process.nextTick 1
这是process.nextTick 2
这是process.nextTick 3
这是process.nextTick 4
这是下一个tick 内部的下一个tick
这是设置超时1
这是设置超时2
这是设置超时3
这是设置超时 4
这是设置超时 5
这是进程.nextTick 在 setTimeout 中添加
这是立即设置 1
这是立即设置 2
这是立即设置 3
让我们在下一篇文章中更多地讨论下一次滴答回调和已解决的承诺。如果有要添加到此帖子或更改的内容,请随时回复。