一、什么是事件循环
事件循环允许Node.js执行非阻塞I/O操作(尽管JavaScript是单线程的 )这是因为Node.js通过尽可能将操作卸载到系统内核。
由于大多数现代内核都是多线程的,因此它们可以处理在后台执行的多个操作。当其中一个操作完成时,内核会告诉Node.js,以便可以将相应的回调添加到轮询队列中以最终执行。我们将在本主题后面进一步详细解释。
二、解释
当Node.js启动时,内核就会初始化事件循环,处理提供的输入脚本(或放入REPL,此处不谈论),这可能会进行异步API调用,调度计时器或调用process.nextTick()
, 然后开始处理事件循环。下图显示了事件循环操作顺序的简要概述。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
每个阶段都有一个执行的回调FIFO消息队列。虽然每个阶段都以其自己的特殊方式单独运行,但通常情况下,当事件循环进入给定阶段时,它将执行特定于该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列未空或达到最大回调数量为止。当队列耗尽或达到回调限制时,事件循环将移至下一阶段,依此类推。
由于这些操作中的任何一个都可以调度更多操作并且在轮询阶段中处理的新事件由内核排队,因此轮询事件可以在处理轮询事件时排队。因此,长时间运行的回调可以允许轮询阶段的运行时间远远超过计时器的阈值。有关详细信息,请参阅 timers
和 poll
部分。
三、阶段概述
- timers:此阶段执行
setTimeout()
和setInterval()
调度的回调。 - pending callbacks:执行延迟到下一个循环迭代的I/O回调。
- idle, prepare:仅在内部使用。
- poll:检索新的I/O事件; 执行与I/O相关的回调(几乎所有回调都是关闭回调,定时器和
setImmediate()
调度的回调); 节点将在适当时阻止此处。 - check:
setImmediate()
在这里调用回调。 - close callbacks:关闭一些回调,例如 socket.on(‘close’,…)。
在事件循环的每次运行之间,Node.js检查它是否在等待任何异步I / O或定时器,如果没有,则检查是否干净。
四、细节
1.timers
计时器指定阈值,在该阈值之后可以执行提供的回调而不是人们想要执行它的确切时间。定时器回调将在指定的时间过去后尽早安排; 但是,操作系统调度或其他回调的运行可能会延迟它们。
注意:从技术上讲,轮询阶段可以控制何时执行计时器。
例如,假设您计划在100毫秒后执行超时,同时您的脚本将异步读取一个需要95毫秒的文件:
const fs = require('fs');
function someAsyncOperation(callback) {
// Assume this takes 95ms to complete
fs.readFile('/path/to/file', callback);
}
const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms have passed since I was scheduled`);
}, 100);
// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
const startCallback = Date.now();
// do something that will take 10ms...
while (Date.now() - startCallback < 10) {
// do nothing
}
});
当事件循环进入轮询阶段时,它有一个空队列(fs.readFile()
尚未完成),因此它将等待剩余的ms数,直到达到最近的计时器阈值。当它等待95毫秒传递时,fs.readFile()
完成读取文件,并且其完成需要10毫秒的回调被添加到轮询队列并执行。当回调结束时,队列中不再有回调,因此事件循环将看到已达到最近定时器的阈值,然后回绕到定时器阶段以执行定时器的回调。在此示例中,可以看到正在调度的计时器与正在执行的回调之间的总延迟将为105毫秒。
注意:为了防止轮询阶段使事件循环堵塞,libuv
(实现Node.js事件循环的C库和平台的所有异步行为)在停止轮询之前也默认有最大值(取决于系统)。
2.pending callbacks
此阶段执行某些系统操作(例如TCP错误类型)的回调。例如,如果TCP套接字在尝试连接时收到ECONNREFUSED,则某些* nix系统希望等待报告错误。这将排队等待在挂起的回调阶段执行。
3.poll
轮询阶段有两个主要功能:
- 计算它应该阻塞和轮询I/O的时间,然后
- 处理轮询队列中的事件。
当事件循环进入轮询阶段并且没有设定定时器时,将发生以下两种情况之一:
-
如果轮询队列不为空,则事件循环将遍历同步执行它们的回调队列,直到队列未空,或者达到系统相关的最大值限制。
-
如果轮询队列为空,则会发生以下两种情况之一:
-
如果
setImmediate()
已调度脚本,则事件循环将结束轮询阶段并继续执行检查阶段以执行这些调度脚本。 -
如果
setImmediate()
尚未调度脚本,则事件循环将等待把回调添加到队列,然后立即执行它们。
-
轮询队列为空后,事件循环将检查已达到时间阈值的计时器。如果一个或多个定时器准备就绪,事件循环将回绕到定时器阶段以执行那些定时器的回调。
4.check
此阶段允许人员在轮询阶段完成后立即执行回调。如果轮询阶段变为空闲并且脚本已使用setImmediate()
排队,则事件循环可以继续到检查阶段而不是等待。
setImmediate()
实际上是一个特殊的计时器,它在事件循环的一个单独阶段运行。它使用libuv API来调度在轮询阶段完成后执行的回调。
通常,在执行代码时,事件循环最终会到达轮询阶段,它将等待传入连接、请求等。但是,如果已使用setImmediate()
调度回调并且轮询阶段变为空闲,则将结束并继续检查阶段,而不是等待轮询事件。
5.close cllbacks
如果套接字或句柄突然关闭(例如socket.destroy()
),则在此阶段将发出'close'
事件。否则它将通过process.nextTick()
发出。
5.1 setImmediate() vs setTimeout()
setImmediate()
和setTimeout()
类似,但根据它们的调用时间使用不同的方式运行。
setImmediate()
用于在当前轮询阶段完成后执行脚本。
setTimeout()
计划在经过最小阈值(以ms为单位)后运行的脚本。
执行计时器的顺序将根据调用它们的上下文而有所不同。如果从主模块中调用两者,那么时间将受到进程性能的限制(可能受到计算机上运行的其他应用程序的影响)。
例如,如果我们不在I/O周期内运行以下脚本(即主模块),则执行两个定时器的顺序是不确定的,因为它受进程性能的约束:
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
node timeout_vs_immediate.js
timeout
immediate
node timeout_vs_immediate.js
immediate
timeout
但是,如果在I/O周期内移动两个调用,则始终首先执行立即回调:
// timeout_vs_immediate.js
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
使用setImmediate()
优于setTimeout()
的主要优点是setImmediate()
将始终在任何定时器之前执行(如果在I/O周期内调度),与存在多少定时器无关。
5.2 process.nextTick()
您可能已经注意到,即使process.nextTick()
是异步API的一部分,也没有显示在图中。这是因为process.nextTick()
在技术上不是事件循环的一部分。相反,nextTickQueue
将在当前操作完成后处理,而不管事件循环的当前阶段如何。这里,操作被定义为从底层C/C ++处理程序的转换,以及处理需要执行的JavaScript
。
回顾一下我们的图表,每当你在给定阶段调用process.nextTick()
时,传递给process.nextTick()
的所有回调都将在事件循环继续之前得到解决。这可能会产生一些不良情况,因为它允许您通过进行递归process.nextTick()
调用来"一直阻塞"您的I/O,这会阻止事件循环到达轮询阶段。
为什么会被允许???
为什么这样的东西会被包含在Node.js中? 其中一部分是一种设计理念,即API本身应该始终是异步的,即使它不是必须的。以此代码段为例:
function apiCall(arg, callback) {
if (typeof arg !== 'string')
return process.nextTick(callback,
new TypeError('argument should be string'));
}
函数进行参数检查,如果不正确,它会将错误传递给回调。最近更新的API允许将参数传递给process.nextTick()
,允许它将回调后传递的任何参数作为参数传播到回调,因此您不必嵌套函数。
我们正在做的是将错误传回给用户,但只有在我们允许其余的用户代码执行之后。通过使用process.nextTick()
,我们保证apiCall()
始终在用户代码的其余代码之后和允许事件循环继续之前执行其回调。为了实现这一点,允许JS展开调用堆栈然后立即执行提供的回调,这允许一个人对process.nextTick()
进行递归调用而不会达到RangeError
(超出v8的最大调用堆栈大小)。
这种理念可能会导致一些潜在的问题。以此片段为例:
let bar;
// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) { callback(); }
// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
// since someAsyncApiCall has completed, bar hasn't been assigned any value
console.log('bar', bar); // undefined
});
bar = 1;
用户看起来将someAsyncApiCall()
定义为具有异步性,但它实际上是同步操作的。调用它时,在事件循环的同一阶段提供给someAsyncApiCall()
的回调,因为someAsyncApiCall()
实际上不会执行任何异步操作。因此,回调尝试引用bar,即使它在范围内可能没有该变量,因为脚本无法运行完成。
通过将回调放在process.nextTick()
中,脚本仍然能够运行完成,允许在调用回调之前初始化所有变量,函数等。它还具有不允许事件循环继续的优点。在允许事件循环继续之前,可以先向用户警告错误。以下是使用process.nextTick()
的上一个示例:
let bar;
function someAsyncApiCall(callback) {
process.nextTick(callback);
}
someAsyncApiCall(() => {
console.log('bar', bar); // 1
});
bar = 1;
再来一个:
const server = net.createServer(() => {}).listen(8080);
server.on('listening', () => {});
仅传递端口时,端口立即绑定。因此,可以立即调用'listen'
回调。问题是那时候不会设置.on('listen'
)回调。
为了解决这个问题,'listening'
事件在nextTick()
中排队,以允许脚本运行完成。这允许用户设置他们想要的任何事件处理程序。
5.3 process.nextTick() vs setImmediate()
就用户而言,我们有两个类似的呼叫,但它们的名称令人困惑。
process.nextTick()
立即在同一阶段触发setImmediate()
触发事件循环的后续迭代或'tick'
实质上,应该交换名称。process.nextTick()
比setImmediate()
更快地触发,但这是过去的工件,不太可能改变。进行此切换会破坏npm上的大部分包。每天都会添加更多新模块,这意味着我们每天都在等待更多的潜在破损发生。虽然它们令人困惑,但名称本身不会改变。
我们建议开发人员在所有情况下都使用setImmediate()
,因为它更容易推理(并且它导致代码与更广泛的环境兼容,如浏览器JS。)
5.4 为什么用process.nextTick()
主要有两个原因:
- 允许用户处理错误,清除任何不需要的资源,或者在事件循环继续之前再次尝试请求。
- 有时需要允许回调在调用堆栈展开后但在事件循环继续之前运行。
例子:
const server = net.createServer();
server.on('connection', (conn) => { });
server.listen(8080);
server.on('listening', () => { });
假设listen()
在事件循环开始时运行,但是监听回调放在setImmediate()
中。除非传递主机名,否则将立即绑定到端口。要使事件循环继续,它必须达到轮询阶段,这意味着可能已经接收到连接的非零概率允许在侦听事件之前触发连接事件。
另一个例子是运行一个函数构造函数,比如继承自EventEmitter
,它想在构造函数中调用一个事件:
const EventEmitter = require('events');
const util = require('util');
function MyEmitter() {
EventEmitter.call(this);
this.emit('event');
}
util.inherits(MyEmitter, EventEmitter);
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});
您无法立即从构造函数中发出事件,因为脚本将不会处理到用户为该事件分配回调的位置。因此,在构造函数本身中,您可以使用process.nextTick()
来设置回调以在构造函数完成后发出事件,从而提供预期的结果:
const EventEmitter = require('events');
const util = require('util');
function MyEmitter() {
EventEmitter.call(this);
// use nextTick to emit the event once a handler is assigned
process.nextTick(() => {
this.emit('event');
});
}
util.inherits(MyEmitter, EventEmitter);
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});