事件环使得Node.js
能够执行非阻塞I/O
操做,只要有可能就将操做卸载到系统内核,尽管JavaScript是单线程的。node
因为大多数现代(终端)内核都是多线程的,他们能够处理在后台执行的多个操做。 当其中一个操做完成时,内核会通知Node.js
,以即可以将适当的回调添加到轮询队列poll queue
中以最终执行。 咱们将在本主题后面进一步详细解释这一点。npm
当Node.js
开始运行,它初始化事件环、处理提供的输入脚本(或放入REPL
,本文档未涉及),这可能会使异步API
调用,计划定时器或调用process.nextTick()
,而后开始处理事件循环。api
下图显示了事件循环的操做顺序的简化概述。浏览器
┌───────────────────────┐ ┌─>│ timers(计时器) │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ I/O callbacks │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ idle, prepare 内部 │ │ └──────────┬────────────┘ ┌───────────────┐ │ ┌──────────┴────────────┐ │ incoming: │ │ │ poll(轮询) │<─────┤ connections, │ │ └──────────┬────────────┘ │ data, etc. │ │ ┌──────────┴────────────┐ └───────────────┘ │ │ check │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ └──┤ close callbacks │ └───────────────────────┘
注意:每一个方框将被称为事件循环的“阶段”。
每一个阶段都有一个执行回调的FIFO
(First In First Out,先进先出)队列。 虽然每一个阶段都有其特定的方式,但一般状况下,当事件循环进入给定阶段时,它将执行特定于该阶段的任何操做,而后在该阶段的队列中执行回调,直到队列耗尽或回调的最大数量已执行。 当队列耗尽或达到回调限制时,事件循环将移至下一个阶段,依此类推。多线程
因为这些操做中的任何一个均可以调度更多的操做,而且在轮询阶段处理的新事件由内核排队,因此轮询事件能够在轮询事件正在处理的同时排队。 所以,长时间运行的回调可使轮询阶段的运行时间远远超过计时器的阈值。有关更多详细信息,请参阅定时器和轮询部分。异步
注意:Windows和Unix / Linux实现之间略有差别,但这对此演示并不重要。 最重要的部分在这里。 实际上有七八个步骤,但咱们关心的那些 - Node.js实际使用的那些 - 就是上述那些。
timers
):此阶段执行由setTimeout()
和setInterval()
调度的回调。I / O
回调函数:执行几乎全部的回调函数,除了关闭回调函数,定时器计划的回调函数和setImmediate()
。idle, prepare
):只在Node
内部使用。poll
):检索新的I / O
事件; 适当时节点将在此处阻断进程。check
):setImmediate()
回调在这里被调用。close callbacks
):例如 socket.on('close',...)
。在事件循环的每次运行之间,Node.js
检查它是否正在等待任何异步I / O
或定时器,并在没有任何异步I / O
或定时器时清除关闭。socket
定时器async
计时器指定阈值,以后能够执行提供的回调,而不是人们但愿执行的确切时间。 定时器回调将在指定的时间事后,按照预约的时间运行; 可是,操做系统调度或其余回调的运行可能会延迟它们。函数
注意:从技术上讲,轮询阶段控制什么时候执行定时器。
例如,假设您计划在100 ms阈值后执行超时,那么您的脚本将异步开始读取须要95 ms的文件:oop
const fs = require('fs'); function someAsyncOperation(callback) { // 假设这个读取将用耗时95ms 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); // 执行一些异步操做将耗时 95ms someAsyncOperation(() => { const startCallback = Date.now(); // 执行一些可能耗时10ms的操做 while (Date.now() - startCallback < 10) { // do nothing } });
当事件循环进入轮询阶段时,它有一个空队列(fs.readFile()
还没有完成),所以它将等待剩余的毫秒数,直到达到最快计时器的阈值。 当它等待95ms
传递时,fs.readFile()
完成读取文件,而且须要10ms
完成的回调被添加到轮询队列并执行。 当回调完成时,队列中没有更多的回调,因此事件循环会看到已经达到最快计时器的阈值,而后回到计时器阶段以执行计时器的回调。 在这个例子中,你会看到被调度的定时器和它正在执行的回调之间的总延迟将是105ms。
注意:为防止轮询阶段进入恶性事件循环,在中止轮询以前,
libuv
(实现Node.js事件循环的C库以及平台的全部异步行为)也有一个硬性最大值(取决于系统)来中止轮询更多的事件。
I / O回调
此阶段为某些系统操做(如TCP错误类型)执行回调。 例如,若是尝试链接时TCP
套接字收到ECONNREFUSED
,则某些* nix系统要等待报告错误。 这将排队在I / O
回调阶段执行。
轮询
此阶段有两个主要的功能:
当事件循环进入轮询阶段而且没有计时器时,会发生如下两件事之一:
1)若是脚本已经过setImmediate()
进行调度,则事件循环将结束轮询阶段并继续执行检查阶段以执行这些预约脚本。
2)若是脚本没有经过setImmediate()
进行调度,则事件循环将等待回调被添加到队列中,而后当即执行它们。
一旦轮询队列为空,事件循环将检查已达到时间阈值的定时器。 若是一个或多个定时器准备就绪,则事件循环将回退到定时器阶段以执行这些定时器的回调。
检查check
此阶段容许在轮询阶段结束后当即执行回调。 若是轮询阶段变得空闲而且脚本已经使用setImmediate()
排队,则事件循环可能会继续检查阶段而不是等待。
setImmediate()
其实是一个特殊的定时器,它在事件循环的一个单独的阶段中运行。 它使用libuv API
来调度回调,以在轮询阶段完成后执行。
一般,随着代码的执行,事件循环将最终进入轮询阶段,在那里它将等待传入的链接,请求等。可是,若是使用setImmediate()
计划了回调而且轮询阶段变为空闲, 将结束并继续进行检查阶段,而不是等待轮询事件。
关闭回调
若是套接字或句柄忽然关闭(例如socket.destroy()
),则在此阶段将发出'close'事件。 不然它将经过process.nextTick()
发出。
setImmediate()
vssetTimeout()
setImmediate()
和setTimeout()
是类似的,但取决于它们什么时候被调用,其行为方式不一样。
setImmediate()
用于在当前轮询阶段完成后执行脚本。setTimeout()
计划脚本在通过最小阈值(以毫秒为单位)后运行。定时器执行的顺序取决于它们被调用的上下文。 若是二者都是在主模块内调用的,那么时序将受到进程性能的限制(可能会受到计算机上运行的其余应用程序的影响)。
例如,若是咱们运行如下不在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()
的的主要优势是,若是在I / O
周期内进行调度,将始终在任何计时器以前执行setImmediate()
,而无论有多少个计时器。
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()
实际上并不会异步执行任何操做。 所以,回调会尝试引用栏,即便它在范围中可能没有该变量,由于该脚本没法运行到完成状态。
经过将回调放置在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()
触发如下迭代或事件循环的“打勾”实质上,名称应该交换。 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); // 一旦处理程序被分配,使用nextTick来发出事件 process.nextTick(() => { this.emit('event'); }); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', () => { console.log('an event occurred!'); });