Node.js 事件循环、定时器和process.nextTic
本文为译文,英文原文
什么是事件循环
尽管JavaScript是单线程的,但通过尽可能将操作放到系统内核执行,事件循环允许Node.js执行非阻塞I/O操作。
由于现代大多数内核都是多线程的,因此它们可以处理在后台执行的多个操作。 当其中一个操作完成时,内核会告诉Node.js,以便可以将相应的回调添加到 轮询队列 中以最终执行。 我们将在本主题后面进一步详细解释。
事件循环解释
当Node.js启动时,它初始化事件循环,处理提供的输入脚本(或放入REPL,本文档未涉及),这可能会进行异步API调用,调度计时器或调用process.nextTick()
, 然后开始处理事件循环。
下图显示了事件循环操作顺序的简要概述。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
注意:每个框都将被称为事件循环的“阶段”。
每个阶段都要执行一个FIFO的回调队列。 虽然每个阶段都有其特殊的方式,但通常,当事件循环进入给定阶段时,它将执行特定于该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列耗尽或最大回调数量为止 。 当队列耗尽或达到回调限制时,事件循环将移至下一阶段,依此类推。
由于这些操作中的任何一个可以调度更多操作并且在轮询阶段中处理的新事件由内核排队,因此轮询事件可以在处理轮询事件时排队。 因此,长时间运行的回调可以允许轮询阶段运行的时间比计时器的阈值长得多。 有关详细信息,请参阅计时器和轮询部分。
注意:Windows和Unix / Linux实现之间存在轻微差异,但这对于此演示并不重要。 最重要的部分在这里。 实际上有七到八个步骤,但我们关心的是 - Node.js实际使用的那些 - 是上面那些。
阶段概述
-
timer : 此阶段执行
setTimeout()
和setInterval()
调度的回调 - pending callbacks : 执行延迟到下一个循环迭代的I/O回调
- idle, prepare : 只用于内部
-
poll : 检索新的I/O事件; 执行与I/O相关的回调(几乎所有回调都是带有异常的
close callbacks
,timers
和setImmediate()
调度的回调); node将在适当的时候阻塞在这里 -
check : 这里调用
setImmediate()
回调函数 - close callbacks : 一些 close callbacks, 例如. socket.on('close', ...)
在事件循环的每次运行之间,Node.js检查它是否在等待任何异步I / O或定时器,如果没有,则关闭。
阶段细节
定时器(timer)
计时器在一个回调执行完之后指定阈值,而不是人们希望的确切时间去执行。 定时器回调将在指定的时间过去后尽早安排; 但是,操作系统调度或其他回调的运行可能会延迟它们。
注意:从技术上讲,轮询阶段控制何时执行定时器。
例如,假设您计划在100毫秒后执行timeout
,然后您的脚本将异步读取一个耗时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库和平台的所有异步行为)在停止轮询之前也为事件提供了固定的最大值(取决于系统)。
等待回调(pending callbacks)
此阶段执行某些系统操作(例如TCP错误类型)的回调。 例如,如果TCP套接字在尝试连接时收到 ECONNREFUSED
,则某些*nix系统希望等待报告错误。 这将排队等待在等待回调阶段执行。
轮询(poll)
轮询阶段有两个主要功能:
- 计算它阻塞和轮询I / O的时间,然后
- 处理轮询队列中的事件。
当事件循环进入轮询阶段并且没有定时器调度时,将发生以下两种情况之一:
-
如果轮询队列不为空,则事件循环将遍历回调队列并且同步执行,直到队列已执行完或者达到系统相关的固定限制。
-
如果轮询队列为空,则会发生以下两种情况之一:
-
如果
setImmediate()
已调度脚本,则事件循环将结束轮询阶段并继续执行检查阶段以执行这些调度脚本。 -
如果
setImmediate()
尚未调度脚本,则事件循环将等待将回调添加到队列,然后立即执行它们。
-
检查(check)
此阶段允许在轮询阶段完成后立即执行回调。 如果轮询阶段变为空闲并且脚本已使用setImmediate()
排队,则事件循环可以继续到检查阶段而不是等待。
setImmediate()
实际上是一个特殊的计时器,它在事件循环的一个单独阶段运行。 它使用libuv API来调度在轮询阶段完成后执行的回调。
通常,在执行代码时,事件循环最终会到达轮询阶段,它将等待传入连接,请求等。但是,如果已使用setImmediate()
调度回调并且轮询阶段变为空闲,则 将结束并继续检查阶段,而不是等待轮询事件。
关闭回调(close callbacks)
如果套接字或句柄突然关闭(例如socket.destroy()
),则在此阶段将发出'close'
事件。 否则它将通过process.nextTick()
发出。
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周期内调度),与存在多少定时器无关。
process.nextTick()
理解process.nextTick()
您可能已经注意到process.nextTick()
没有显示在图中,即使它是异步API的一部分。 这是因为process.nextTick()
在技术上不是事件循环的一部分。 相反,nextTickQueue
将在当前操作完成后处理,而不管事件循环的当前阶段如何。
回顾一下我们的图表,无论何时在给定阶段调用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', () => {});
仅传递端口时,端口立即绑定。 因此,可以立即调用'listening'
回调。 问题是那时候不会设置.on('listening')
回调。
为了解决这个问题,'listening'
事件在nextTick()
中排队,以允许脚本运行完成。 这允许用户设置他们想要的任何事件处理程序。
process.nextTick()
vs setImmediate()
就用户而言,我们有两个类似的调用,但它们的名称令人困惑。
-
process.nextTick()
在同一阶段立即触发 -
setImmediate()
在事件循环的后续迭代或'tick'中触发
实质上,应该交换名称。 process.nextTick()
比setImmediate()
更快地触发,但这是过去创造的,不太可能改变。 进行此切换会破坏npm上的大部分包。 每天都会添加更多新模块,这意味着我们每天都在等待,更多的潜在破损发生。 虽然它们令人困惑,但自身的叫法不会改变。
我们建议开发人员在所有情况下都使用setImmediate()
,因为它更容易推理(并且它导致代码与更广泛的环境兼容,如浏览器JS。)
为什么要使用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!');
});