什么是事件循环(EventLoop)?事件循环使Node.js能够执行非阻塞I/O操作,尽可能将操作卸载到系统内核,即使JavaScript是单线程的。由于大多数现代(终端)内核都是多线程的,因此它们可以处理在后台执行的多个操作。当其中一个操作完成时,内核会通知Node.js,以便将适当的回调添加到轮询队列以供最终执行。我们稍后将在本主题中对此进行更详细的解释。EventLoop:说明当Node.js开始运行时,会初始化eventloop,处理提供的输入脚本(或放入REPL,本文档未涉及),可能会进行异步API调用,调度定时器,或调用进程。nextTick(),并开始处理事件循环。下图显示了事件循环操作顺序的简化概述。┌────────────────────────────┐┌──>│timers(定时器)││└─────────────────────────────────────────────────────────────────────────────────────┐┌─>│定时器(timer)││└──────────────┬──────────────┘│┌────────────┴───────────────────┐┐│I/O回调││└──────────────┬────────────────┘│┌──────────────────────────────────────────────┐││空闲,内部准备││└────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐│┌──────────┴──────────────┐│传入:│││poll(轮询)│<──────┤连接数,││└──────────────┬────────────────┘│数据等││┌────────────┴──────────────┐└────────────────────┘││检查││└──────────────────────────────────────────────────────────────────────────┘│┌────────────┴──────────────┐└──┤收盘回调│└───────────────────────────┘注意:每个框将被称为事件循环的“阶段”。每个阶段都有一个执行回调的FIFO(先进先出)队列。虽然每个阶段都有自己的特定方式,但通常,当事件循环进入给定阶段时,它会执行该阶段特定的任何操作,然后对该阶段的队列执行回调,直到队列耗尽或回调的最大数量执行。当队列耗尽或达到回调限制时,事件循环将进入下一阶段,依此类推。由于这些操作中的任何一个都可以调度更多操作,并且在轮询阶段处理的新事件由内核排队,因此轮询事件可以在处理轮询事件的同时排队。因此,长时间运行的回调会导致轮询阶段运行的时间远远超过计时器的阈值。有关详细信息,请参阅定时器和轮询部分。注意:Windows和Unix/Linux实现之间存在细微差别,但这对于本演示并不重要。最重要的部分在这里。实际上有七八个步骤,但我们关心的那些——Node.js实际使用的那些——是上面的那些。阶段概述定时器(timers):这个阶段执行由setTimeout()和setInterval()调度的回调。I/O回调:执行除关机回调、定时器调度回调和setImmediate()之外的几乎所有回调。Idle,prepare(idle,prepare):仅供Node内部使用。poll:检索新的I/O事件;节点会在适当的时候在这里阻塞进程。检查:这里调用了setImmediate()回调。关闭回调:例如socket.on('关闭',...)。在事件循环的每次运行之间,Node.js检查它是否正在等待任何异步I/O或计时器,并在没有时清除关闭。StageDetailsTimer计时器指定所提供的回调可以执行的阈值,而不是人们希望它执行的确切时间。定时器回调将在指定时间过去后在预定时间运行;但是,操作系统调度或其他回调的运行可能会延迟它们。注意:从技术上讲,轮询阶段控制计时器何时执行。例如,假设您计划在100毫秒的阈值后执行超时,那么您的脚本将异步开始读取一个需要95毫秒的文件:constfs=require('fs');functionsomeAsyncOperation(callback){//假设这个读取需要95msfs.readFile('/path/to/file',callback);}consttimeoutScheduled=Date.now();setTimeout(()=>{constdelay=Date.now()-timeoutScheduled;console.log(`${delay}mshaspassedsinceIwasscheduled`);},100);//执行一些异步操作需要95mssomeAsyncOperation(()=>{conststartCallback=Date.now();//做一些可能需要10毫秒的事情while(Date.now()-startCallback<10){//什么都不做}});当事件循环进入轮询阶段时,它有一个空队列(fs.readFile()尚未完成),因此它将等待剩余的毫秒数,直到达到最快计时器的阈值。当它等待95ms过去时,fs.readFile()完成读取文件,需要10ms完成的回调被添加到轮询队列并执行。当回调完成时,队列中不再有回调,所以事件循环看到最快的定时器阈值已经达到,然后回退到定时器阶段执行定时器的回调。在此示例中,您将看到被调度的计时器与其执行的回调之间的总延迟为105毫秒。注意:为防止轮询阶段进入恶性事件循环,libuv(实现Node.js事件循环和平台所有异步行为的C库)也有一个hardmaximum(取决于系统)停止轮询在更多事件之前。I/O回调此阶段执行某些系统操作(如TCP错误类型)的回调。例如,如果TCP套接字在尝试连接时收到ECONNREFUSED,一些*nix系统会等待报告错误。这将在I/O回调阶段排队等待执行。轮询这个阶段有两个主要功能:执行过时的定时器脚本;处理轮询队列中的事件。当事件循环进入轮询阶段并且没有计时器时,会发生以下两种情况之一:如果轮询队列不为空,事件循环遍历其回调队列,同步执行它们直到队列耗尽或系统关联硬限制。如果轮询队列为空,将发生以下两种情况之一:1)如果脚本已通过setImmediate()调度,事件循环将结束轮询阶段并继续检查阶段以执行那些调度的脚本。2)如果脚本没有通过setImmediate()调度,事件循环会等待回调被添加到队列中,然后立即执行它们。一旦轮询队列为空,事件循环就会检查已达到时间阈值的计时器。如果一个或多个计时器就绪,事件循环将回退到计时器阶段以执行这些计时器的回调。检查此阶段允许在轮询阶段结束后立即执行回调。如果轮询阶段变为空闲并且脚本已使用setImmediate()排队,事件循环可能会继续检查该阶段而不是等待。setImmediate()实际上是一个特殊的计时器,它在事件循环的一个单独阶段运行。它使用libuvAPI来安排在轮询阶段完成后执行的回调。通常,当您的代码执行时,事件循环最终会进入轮询阶段,等待传入的连接、请求等。但是,如果使用setImmediate()安排回调并且轮询阶段变为空闲,则检查阶段将结束并继续,而不是等待轮询事件。关闭回调如果套接字或句柄突然关闭(例如socket.destroy()),“关闭”事件将在此阶段发出。否则它将通过process.nextTick()发出。setImmediate()与setTimeout()setImmediate()和setTimeout()相似,但根据调用时间的不同表现不同。setImmediate()用于在当前轮询阶段完成后执行脚本。setTimeout()安排脚本在最小阈值(以毫秒为单位)过去后运行。计时器执行的顺序取决于调用它们的上下文。如果两者都在主模块中调用,则时间将受到进程性能的限制(也可能受到计算机上运行的其他应用程序的限制)。例如,如果我们运行以下不在I/O周期中的脚本(即主模块),则两个计时器的执行顺序是不确定的,因为它受流程执行的约束://timeout_vs_immediate.jssetTimeout(()=>{console.log('timeout');},0);setImmediate(()=>{console.log('immediate');});$nodetimeout_vs_immediate.jstimeoutimmediate$nodetimeout_vs_immediate.jsimmediatetimeout然而,如果在I/O期间移动这两个调用,立即回调总是先执行://timeout_vs_immediate.jsconstfs=require('fs');fs.readFile(__filename,()=>{setTimeout(()=>{console.log('timeout');},0);setImmediate(()=>{console.log('immediate');});});$nodetimeout_vs_immediate.jsimmediatetimeout$nodetimeout_vs_immediate.jsimmediatetimeout使用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应始终异步的设计理念,即使在不需要时也是如此。以这段代码为例:参数检查,如果不正确,则将错误传递给回调函数。最近更新的API允许将参数传递给process.nextTick()以允许它传播回调后传递的任何参数作为回调函数的参数,因此您不必嵌套函数。我们正在做的是将错误传递给用户,但前提是我们允许用户的其余代码执行。通过使用process.nextTick(),我们保证apiCall()始终在用户代码的其余部分之后和允许事件循环继续之前运行其回调。为实现这一点,允许JS调用堆栈展开,然后立即执行提供的回调,允许对process.nextTick()进行递归调用而不会出现RangeError:超出v8的最大调用堆栈大小。这种理念导致了一些潜在的问题。以这个片段为例:letbar;//这有一个异步签名,但是同步调用回调}//在`someAsyncApiCall`完成之前调用回调。someAsyncApiCall(()=>{//因为someAsyncApiCall已经完成,bar还没有被赋值console.log('bar',bar);//undefined});酒吧=1;同步运行。提供给someAsyncApiCall()的回调将在调用时在事件循环的同一阶段被调用,因为someAsyncApiCall()实际上并不异步执行任何操作。因此回调会尝试引用bar,即使它可能在范围内没有该变量,因为脚本无法运行完成。通过将回调放在process.nextTick()中,脚本仍具有运行完成的能力,允许在调用回调之前初始化所有变量、函数等。它还具有不允许事件循环继续的优点。在允许事件循环继续之前通知用户错误可能是有用的。这是前面使用process.nextTick()的示例:);酒吧=1;这是另一个现实的例子:constserver=net.createServer(()=>{}).listen(8080);server.on('listening',()=>{});当只传递一个端口时,立即绑定该端口。因此,可以立即调用“监听”回调。问题是.on('listening')回调在那个时候没有设置。为了解决这个问题,'listening'事件在nextTick()中排队,直到脚本完成运行。这允许用户设置他们想要的任何事件处理程序。process.nextTick()vssetImmediate()就用户而言,我们有两个类似的调用,但它们的名称令人困惑。process.nextTick()在与setImmediate()触发事件循环的后续迭代或“滴答”的同一阶段立即触发本质上,名称应该交换。process.nextTick()比setImmediate()更立即触发,但这是过去的产物,不太可能改变。进行此切换会破坏npm上的大多数包。每天都会添加更多新模块,这意味着我们等待的每一天,都会发生更多潜在的破坏。虽然他们很困惑,但名字本身并没有改变。我们建议开发人员在所有情况下都使用setImmediate(),因为它更容易推理(并导致代码与更广泛的环境兼容,例如浏览器JS)。为什么要使用process.nextTick()?有两个主要原因:允许用户处理错误,清理任何不需要的资源,或者可能在事件循环继续之前再次尝试请求。有时有必要允许回调在调用堆栈展开之后但在事件循环继续之前运行。一个例子是匹配用户期望。简单示例:constserver=net.createServer();server.on('connection',(conn)=>{});server.listen(8080);server.on('listening',()=>{});假设listen()在事件循环开始时运行,但监听回调放在setImmediate()中。除非传递主机名,否则会立即绑定到端口。要继续事件循环,它必须进入轮询阶段,这意味着接收连接的机会非零,允许连接事件在侦听事件之前触发。另一个例子是运行一个继承自EventEmitter的函数构造函数,它想在构造函数中调用一个事件:constEventEmitter=require('events');constutil=require('util');functionMyEmitter(){EventEmitter.call(this);this.emit('event');}util.inherits(MyEmitter,EventEmitter);constmyEmitter=newMyEmitter();myEmitter.on('event',()=>{console.log('事件发生!');});您不能立即从构造函数发出事件,因为脚本不会处理到用户为该事件分配回调的地步。因此,在构造函数本身中,您可以使用process.nextTick()设置回调以在构造函数完成后发出事件,这会给出预期的结果:constEventEmitter=require('events');constutil=require('util');functionMyEmitter(){EventEmitter.call(this);//分配处理程序后,使用nextTick发出事件process.nextTick(()=>{this.emit('event');});}util.inherits(MyEmitter,EventEmitter);constmyEmitter=newMyEmitter();myEmitter.on('event',()=>{console.log('事件发生!');});
