什么是事件循环?
让nodejs执行非阻塞I/O操作的就是事件循环–尽管事实上JavaScript是单线程–它无论何时都尽可能把操作丢给系统内核(相当于一个管家把任务都丢给手下)。
因为大多数现代内核都是多线程,在这样的背景下内核就能处理多线程操作的执行。当这些操作中的一个完成后,内核会告诉nodejs以便适当的回调函数会被添加到轮询队列然后被执行。接下来我们将解释更多的细节在这篇文章中。
事件循环解释
当nodejs开始运行后,他将初始化event loop来处理输入进来的脚本(或者进入交互式解释器)这可能调用异步API、定时器、或者process.nextTick()
,然后开始处理enevt loop。
下面展示了一个简单的命令循环操作顺序描述
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
这其中的每一个方框都意味着将作为event loop的一个阶段
每个时期都有一个回调函数执行的FIFO(先进先出)队列。因为每个阶段都有自己特殊的方式,通常来说,当enevt loop进入一个阶段时,他将执行这个特定阶段的任何操作,然后执行此阶段队列中的回调函数直到队列被耗尽或者所有的回调函数都被执行完,当队列被耗尽或者回调函数达到上限时,event loop将会进入下一个阶段,以此循环。
由于这些操作中的任何一个都可能调度更多的操作,在轮询阶段处理的新事件将由内核排队,在处理轮询事件时,可以将轮询事件排队。因此,长时间运行的回调函数允许轮询阶段运行的时间比计时器的阈值长得多。有关详细信息,请参见计时器和轮询部分。
注意:在Windows和Unix/Linux实现之间有细微的差异,但这对于本演示并不重要。最重要的部分在这里。实际上有7到8个步骤,但是我们关心的是——那个nodejs实际使用的-也就是上面的那些。
各阶段描述
- timers: 这个阶段执行
setTimeout()
和setInterval
的回调函数 - pending callbacks: 执行被推迟的I/O回调函数,即上一轮loop中剩下的
- idle,prepare: 仅内部使用
- poll: 获取新的I/O事件,执行I/O相关的回调函数(几乎所有的回调函数,除了关闭回调函数、定时器相关的回调函数和
setImmediate()
)适当的时候nodejs将在此阻塞 - check:
setImmediate()
的回调函数在此执行 - close callbacks: 一些关闭回调函数的操作,例如
socket.on('close', ...)
在每次运行事件循环之间,nodejs检查它是否在等待异步I/O或计时器,如果没有,则直接关闭。
各阶段详情
一个定时器指定了一个回调函数执行的阈值,而不是执行该回调函数的确切时间。计时器回调将尽可能早地在经过指定的时间后执行;但是,操作系统调度或其他回调的运行可能会延迟它们。
注意: 从技术上来说,poll阶段控制什么时候定时器被执行
举个栗子,假设你计划一个定时器在100ms的阈值后执行,然后你的脚本开始异步读取一个文件只用了95ms:
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
}
});
当enevt loop进入poll阶段时,他有一个空队列(因为fs.readFile()
还没完成),所以他将等待剩下的毫秒数直到最快的定时器阈值到来。当他等待95ms后,fs.readFile()
完成读取这个文件,并且他的回调函数(将花费10ms去完成)被添加到poll队列并执行。
当回调函数结束后,队列中没有回调函数,所以event loop将看到最快计时器的阈值已达到,然后将其回滚到计时器阶段以执行计时器的回调函数。在本例中,您将看到调度的计时器和执行的回调之间的总延迟为105ms。
注意: 为了防止轮询进入死循环,libuv(一个实现nodejs事件循环和所有异步行为平台的C库)在停止轮询事件之前也有一个最大值(依赖于系统)
这个阶段执行一些系统操作的回调函数比如TCP错误的类型。例如,如果TCP套接字在尝试连接时接收到拒绝错误链接的错误,一些unix/linux系统希望等待报告错误。这将在pending callbacks阶段排队执行。
poll阶段有两个主要功能:
- 计算它应该阻塞和轮询I/O多长时间
- 处理poll队列中的事件
当event loop进入poll阶段并且没有定时器的时候,将会发生以下两个事件中的一个
- 如果poll队列非空,enevt loop将通过他的回调函数队列执行他们的异步函数直到队列为空或者达到依赖于系统的限制
- 如果poll队列是空的,以下两件事将至少发生一件:
- 如果将执行的代码中有
setImmediate()
,event loop将结束poll阶段并且继续下一个阶段也就是check阶段。 - 如果代码中没有
setImmediate()
,event loop将等待回调函数被添加到队列中,然后立即执行
- 如果将执行的代码中有
一旦poll队列为空,event loop将检查定时器的时间阈值是否快要达到。如果一个或者更多的定时器将要到达,event loop将回滚到timers阶段去执行这些定时器的回调函数。
这个阶段将执行在poll阶段被完成的立即执行的回调函数。如果轮询阶段变为空闲,并且脚本已经使用setImmediation()
排队,那么event loop可能会继续到check阶段,而不是等待。
setImmediation()
实际上是一个特殊的定时器,这个定时器运行在event loop中的一个单独的阶段,它使用了一个libuv API,这个API作用为安排回调函数在poll阶段完成后执行。
通常来说,随着代码的执行,event loop最终将停在poll阶段,在poll阶段中等待传入请求和链接等等。但是如果有setImmediation()
的回调函数存在,并且poll阶段是空闲状态,poll阶段就会结束然后继续到下一个阶段-poll阶段而不是等待轮询事件。
如果一个socket或者handle突然被关闭(例如socket.destroy()
),close
事件将会在这个阶段发出(emitte),否则,它将通过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');
});
});
与setTimeout()相比,使用setimmediation()的主要优势是,如果在I/O周期中调度setimmediation(),那么它总是在任何计时器之前执行,而不受存在多少计时器的影响。
process.nextTick()
理解process.nextTick()
你可能注意到了process.nextTick()
没有在上述图表中演示,尽管他是异步API的一部分。这是因为在技术上来说process.nextTick()
不是event loop循环的一部分。相反,不论事件循环的当前阶段如何,nextTick队列都将在当前操作完成后被处理。
回头看看上述图表,在给定的每个阶段中任何时间都能调用process.nextTick()
,所有被传递给process.nextTick()
的回调函数都将在事件循环继续工作前被处理。这会造成一些糟糕的情况,因为他允许你通过产生循环调用process.nextTick()
来“饿死”你的I/O,也就是阻止了你的事件循环到达poll阶段
为什么还会被允许?
为什么一些类似的东西还会在nodejs中存在?这也是他设计理念的一部分,即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: Maximum call stack size exceeded from 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()
,这段脚本仍然会完整的运行,允许所有的变量、函数等等在调用回调函数之前被初始化完成。它还具有不允许事件循环继续的优点。这可能对用户在事件循环可以继续进行下去之前去警示一个error非常有用。下面是在之前的栗子中使用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()
在事件循环的开始阶段运行,但是listening的回调函数被放在了setImmediate()
里面。除非传递主机名,否则将立即绑定到端口。对于事件循环的处理来说,他必须放在poll阶段,这意味着有可能连接事件有可能发生在监听事件之前。
另一个例子是运行一个构造函数,它继承自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!');
});
文章翻译自
The Node.js Event Loop, Timers, and process.nextTick()