当前位置: 首页 > 后端技术 > Node.js

Node.js指南(Node.js事件循环、定时器和process.nextTick())

时间:2023-04-04 00:11:53 Node.js

Node.js事件循环、定时器和process.nextTick()什么是事件循环?事件循环允许Node.js执行非阻塞I/O操作——即使JavaScript是单线程的——通过尽可能多地将操作卸载到系统内核。由于大多数现代内核都是多线程的,它们可以处理在后台执行的多个操作,并且当其中一个操作完成时,内核告诉Node.js以便可以将相应的回调添加到轮询队列中以供最终执行,我们将在本主题中进一步详细解释。事件循环解释当Node.js启动时,它会初始化事件循环,处理可能进行异步API调用、调度计时器或调用process.nextTick()的输入脚本(或放入REPL,本文档未涵盖),然后开始处理事件循环。下图简要概述了事件循环的操作顺序。┌────────────────────────────┐┌──>│计时器││└──────────────┬──────────────┘│┌────────────┴──────────────┐││等待回调││└──────────────┬────────────┘│┌────────────┴──────────────┐││空闲,准备││└──────────────┬──────────────┘┌────────────────┐│┌────────────┴──────────────┐│传入:│││投票│<──────┤连接,││└──────────────┬──────────────┘│数据等││┌─────────────┴──────────────┐└────────────────┘││检查││└──────────────┬────────────┘│┌────────────┴──────────────┐└──┤收盘回调│└──────────────────────────┘注意:每个框都“阶段”阶段事件的事件。。。每有有有个一执行执行执行执行执行的的的的执行执行执行执行执行执行的执行,虽然队列队列的执行执行,虽然虽然每每每每阶段个个阶段阶段阶段都都都都都以自己自己的的,但它将阶段任何任何,然后的任何任何执行中执行回调执行,依此类推。由于这些操作中的任何一个都可以调度更多操作,并且在轮询阶段处理的新事件被内核排队,轮询事件可以在处理轮询事件的同时进行排队,因此长时间运行的回调可以让轮询阶段运行的时间远远超过定时器的阈值,详见定时器和轮询部分。注意:Windows和Unix/Linux实现之间存在细微差别,但这对本次演示并不重要,最重要的部分在这里,实际上有七八个步骤,但我们关心的是-Node.js实际上使用了那些——上面那些。阶段概述计时器:此阶段执行由setTimeout()和setInterval()安排的回调。挂起的回调:I/O回调,其执行被延迟到下一个循环迭代。空闲,准备:仅供内部使用。poll:检索新的I/O事件;执行与I/O相关的回调(几乎所有,除了关闭回调,一些由定时器调度,和setImmediate());节点会在适当的时候阻塞在这里。check:这里调用了setImmediate()回调函数。关闭回调:一些关闭回调,比如socket.on('close',...)。在事件循环的每次运行之间,Node.js检查它是否正在等待任何异步I/O或计时器,如果没有,则干净地关闭。阶段定时器定时器的详细信息指定了一个阈值,在该阈值之后,所提供的回调可以执行,而不是人们希望它执行的确切时间,定时器回调将在指定的时间过去后被调度,但是,操作系统调度或运行其他回调他们可能会延迟。注意:从技术上讲,轮询阶段控制计时器何时执行。例如,假设您安排执行在100毫秒的阈值后超时,那么您的脚本将异步读取一个需要95毫秒的文件:constfs=require('fs');functionsomeAsyncOperation(callback){//假设这需要95毫秒才能完成fs.readFile('/path/to/file',callback);}consttimeoutScheduled=Date.now();setTimeout(()=>{constdelay=Date.now()-timeoutScheduled;console.log(`${delay}ms已经过去了,因为我被安排了`);},100);//执行someAsyncOperation,它需要95ms才能完成someAsyncOperation(()=>{conststartCallback=Date.now();//做一些需要10毫秒的事情...while(Date.now()-startCallback<10){//什么都不做}});当事件循环进入轮询阶段时,它有一个空队列(fs.readFile()还没有完成),所以它会等待剩余的毫秒数,直到达到最快的定时器阈值,当它等待95毫秒通过,fs.readFile()完成读取文件,它的回调需要10ms完成,被添加到轮询队列并执行,当回调完成时,队列中没有更多的回调,所以事件循环将看到已经达到最快定时器的阈值,然后返回定时器阶段执行定时器的回调,在本例中,您将看到定时器被调度和回调被执行之间的总延迟为105毫秒。注意:为了防止轮询阶段耗尽事件循环,libuv(实现Node.js事件循环和平台所有异步行为的C库)在停止轮询之前也有一个硬最大值(取决于系统)更多活动。挂起的回调此阶段执行某些系统操作(例如TCP错误类型)的回调。例如,如果TCP套接字在尝试连接时收到ECONNREFUSED,某些*nix系统希望等待报告错误,该错误将在挂起回调阶段排队等待执行。poll轮询阶段有两个主要功能:计算何时应该阻塞和轮询I/O。然后处理轮询队列中的事件。当事件循环进入轮询阶段并且没有定时器被调度时,将发生以下两种情况之一:如果轮询队列不为空,事件循环将遍历其回调队列并同步执行它们,直到队列耗尽,或到达一个系统相关的硬限制。如果轮询队列为空,将发生以下两种情况之一:如果setImmediate()有调度的脚本,事件循环将结束轮询阶段并继续执行检查阶段以执行那些调度的脚本。如果setImmediate()没有调度脚本,事件循环将等待回调被添加到队列中,然后立即执行它们。一旦轮询队列为空,事件循环将检查已达到其时间阈值的定时器,如果一个或多个定时器准备就绪,事件循环将返回到定时器阶段以执行这些定时器的回调。check此阶段允许人们在轮询阶段完成后立即执行回调,如果轮询阶段变为空闲并且脚本已使用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()的主要优点是setImmediate()将始终在任何计时器之前执行(如果在I/O周期内安排),无论存在多少个计时器。process.nextTick()理解process.nextTick()您可能已经注意到process.nextTick()没有显示在图中,即使它是异步API的一部分,这是因为process.nextTick()在技术上不是一个事件loop的一部分,相反,nextTickQueue将在当前操作完成后处理,而不管事件循环的当前阶段。回顾我们的图表,每当在给定阶段调用process.nextTick()时,传递给process.nextTick()的所有回调都将在事件循环继续之前得到解决,这可能会造成一些不好的情况,因为它会让你“挨饿”"通过递归调用process.nextTick()来防止事件循环进入轮询阶段。为什么允许?为什么像这样的东西会包含在Node.js中?这部分是一种设计理念,即API应该始终是异步的,即使它不是必须的,以这个片段为例:functionapiCall(arg,callback){if(typeofarg!=='string')returnprocess.nextTick(callback,newTypeError('argumentshouldbestring'));}此代码段执行参数检查,如果不正确,则将错误传递给回调,最近更新的API允许将参数传递给process.nextTick(),允许它传播回调后传递的任何参数作为回调的参数,因此您不必嵌套函数。我们正在做的是将错误返回给用户,但只有在我们允许其余的用户代码执行之后,通过使用process.nextTick()我们保证apiCall()始终在用户的其余部分之后代码和事件循环之前允许在运行其回调之前,为了实现这一点,允许JS调用堆栈展开,然后立即执行提供的回调,这允许对process.nextTick()进行递归调用而不命中RangeError:从v8开始超出最大调用堆栈大小。这种理念可能会导致一些潜在的问题,以这个片段为例:letbar;//这有一个异步签名,但是同步调用回调}//回调在`someAsyncApiCall`完成之前被调用。someAsyncApiCall(()=>{//因为someAsyncApiCall已经完成,bar还没有被分配任何值console.log('bar',bar);//undefined});酒吧=1;用户将someAsyncApiCall()定义为具有异步签名,但它实际上是同步运行的。当它被调用时,提供给someAsyncApiCall()的回调在事件循环的同一阶段被调用,因为someAsyncApiCall()实际上并不异步执行任何操作。操作。因此,回调会尝试引用bar,即使它可能在范围内没有该变量,因为脚本无法运行完成。通过将回调放在process.nextTick()中,脚本仍然可以运行完成,允许在调用回调之前初始化所有变量、函数等。它还具有不允许事件循环继续的优点。在允许事件循环继续之前,警告用户错误可能很有用。这是前面使用process.nextTick()的示例:}someAsyncApiCall(()=>{console.log('bar',bar);//1});酒吧=1;这是另一个真实世界的例子:constserver=net.createServer(()=>{}).listen(8080);server.on('listening',()=>{});当只传递端口时,端口立即绑定,因此可以立即调用'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');函数MyEmitter(){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');函数MyEmitter(){EventEmitter.call(this);//一旦分配了处理程序,就使用nextTick发出事件process.nextTick(()=>{this.emit('event');});}util.inherits(MyEmitter,EventEmitter);constmyEmitter=newMyEmitter();myEmitter.on('event',()=>{console.log('事件发生!');});上一篇:阻塞和非阻塞概述下一篇:不阻塞事件循环(或工作池)