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

Node.js中的事件循环计时器和process.nextTick()

时间:2023-04-04 01:06:46 Node.js

前言本文翻译自Node.js官网。同名文章算是经典了,不过官网的文章也在随着Node.js的演进不断修改。本文最后编辑时间为2019年9月10日,请注意时效,文章末尾给出地址。第一次翻译英文水平有限,有错误请多多指教。什么是事件循环?事件循环允许node.js执行非阻塞I/O操作。JavaScript虽然是单线程的,但是事件循环会尽可能把操作转移到系统内核。现代操作系统内核是多线程的,可以在后台处理各种操作。一旦这些操作完成,系统内核就会通知Node。js以便将事件回调放入轮询队列中执行。(我们会在后面的内容中讨论它们的具体工作细节)分析事件循环Node.js启动时会初始化事件循环来处理输入的脚本内容(或者进入REPL),脚本可能会调用异步接口,设置一个定时器,或者调用process.nextTick(),然后开始处理事件循环(eventloop)。下图展示了事件循环的运行过程:┌────────────────────────────┐┌─>│定时器││└────────────┬────────────────┘│┌────────────┴──────────────┐││I/O回调││└────────────────────────────────────────────────┬──────────────┘│┌──────────┴──────────────────────┐┐│空闲,准备││└──────────────┬────────────────┘┌──────────────────────┐┐┌────────────┴────────────┐┐传入:│││投票│<──────┤连接,││└──────────┬──────────────────┘│数据等││┌──────────────────────────────────────────────┐┐────────────────────┘││检查...──┐└──┤收盘回调│└────────────────────────────────────────┘每个方框代表事件循环中的不同阶段(所有阶段执行完成都视为一个事件循环)。每个阶段都有一个由执行回调组成的FIFO队列。虽然不同的队列有不同的执行方式,但一般来说,当事件循环进入这个阶段后,会执行相应的操作,然后调用相应的回调,直到队列耗尽或者达到回调执行的上限。达到上述情况后,事件循环进入下一阶段,然后继续这个过程。由于处理单个操作可能会产生新的操作,在轮询阶段产生的循环内新事件会被内核排队,轮询事件会在轮询阶段进行排队。因此,执行一个耗时较长的回调会超过轮询阶段设置的定时器Threshold。Windows和Unix/Linux平台略有不同,但这不影响我们的讨论。我们最关心的是Node.js真正执行的部分,也就是上面的内容。阶段概览timer:这个阶段由setTimeout()和setInterval()回调执行。pendingcallbacks:推迟到下一轮I/O回调执行。idle,prepare:内部使用。poll:获取新的I/O事件;执行I/O回调(除了关闭回调,定时器回调和setImmediate回调会在这里执行),节点在适当的情况下会阻塞在这里。check:setImmediate回调会在下次执行。closecallbacks:一些执行关闭的函数,比如socket.on('close',...).Node会检查两个完整的事件循环之间是否有I/O操作和/或定时器,如果没有,则退出执行。每个阶段定时器timer(timer)的细节指定了执行回调的阈值时间,而不是人们认为的确切执行时间。定时器回调会在指定时间一到就执行,但是由于操作系统调度和其他回调执行的影响,定时器的执行会延迟。从技术上讲,是否执行定时器回调的决定是在控制轮询阶段,在ti这些回调只会在mer阶段执行。例如,你设置了一个延时为100ms的定时器,然后异步读取文件需要95ms:constfs=require('fs');functionsomeAsyncOperation(callback){//假设这需要95ms才能完成readFile('/path/to/file',callback);}consttimeoutScheduled=Date.now();setTimeout(()=>{constdelay=Date.now()-timeoutScheduled;console.log(`${delay}ms已经过去了,因为我被安排了`);},100);//做一些需要95ms才能完成的AsyncOperationsomeAsyncOperation(()=>{conststartCallback=Date.now();//做一些需要10ms的事情。..while(Date.now()-startCallback<10){//什么都不做}});当事件循环进入轮询队列时,此时队列为空(fs.readFile()尚未完成),现在我们等待定时器达到指定的阈值。95毫秒后,读取fs.readFile并执行回调。总共需要10ms。当回调执行完成后,轮询队列中没有任何东西,此时事件循环会看到定时器已经达到阈值,然后在定时器阶段执行回调。所以在这个例子中,延迟函数将在105ms之后执行。为了防止事件循环长时间空闲,libuv有一个最大限制(取决于操作系统)用于限制轮询队列的执行次数。挂起的回调系统操作如:TCP类型错误执行回调将被安排在这个阶段执行。例如,当尝试连接到TCP时收到ECONNREFUSED错误,一些*nix系统将等待而不立即抛出错误。这些回调将被添加到队列中并在挂起的回调阶段执行。poll事件轮询阶段主要有两个功能:计算需要阻塞多长时间,并进行I/O轮询,然后处理队列中轮询的Events当事件轮询到poll阶段,发现没有timer到达门槛。这时候会出现两种情况:如果轮询队列中有内容,事件循环会遍历轮询队列并同步调用其中的回调,直到队列清空或者接近回调执行上限轮询阶段(上限取决于操作系统)。如果轮询队列为空,此时如果有setImmediate()任务,事件循环会结束轮询阶段,直接跳转到检查阶段执行那些setImmediate()任务。如果没有setImmediate()任务需要处理,事件循环会在轮询阶段等待新的任务加入轮询队列,然后立即处理这些加入的任务。轮询队列为空后,事件循环将检查已达到时间阈值的定时器。如果任何计时器达到阈值,事件循环将移至计时器阶段并执行这些计时器回调。检查阶段允许在轮询阶段完成后执行回调。如果轮询阶段进入等待,并且有setImmediate()设置的回调,那么事件循环可能会进入检查阶段,而不是在轮询阶段继续等待。setImmediate()实际上是一个特殊的定时器,在事件循环中它是在轮询阶段的一个单独阶段执行的。它通过libuvAPI在轮询阶段之后执行由setImmediate()设置的回调。一般来说,随着代码的运行,事件循环最终会进入事件轮询阶段,等待连接。传入或请求等。但是如果有setImmediate()设置的任务,时间轮询进入等待(空闲阶段),事件循环将进入检查阶段,而不是继续等待。关闭如果套接字或句柄突然关闭,他们的关闭事件将在这个阶段执行。否则它将通过process.nextTick()执行。setImmediate()vssetTimeout()setImmediate()与setTimeout()非常相似,但根据调用的时机不同,它们的行为有所不同。setImmediate()是在当前事件轮询阶段(pollphase)结束后设计的执行脚本一次。setTimeout()通过设置阈值(以毫秒为单位)来安排脚本的执行。计时器执行的顺序将根据调用它们的上下文而有所不同。如果两者都运行在主模块中,执行的时机会受到进程性能的影响(机器上的其他程序会影响进程的性能)。比如我们在一个不受I/O循环影响的地方(比如主模块)执行下面的代码,这两个定时器的执行顺序是Indeterminate的,因为会受到进程性能的影响//timeout_vs_immediate.jssetTimeout(()=>{console.log('timeout');},0);setImmediate(()=>{console.log('immediate');});$节点timeout_vs_immediate.jstimeoutimmediate$节点timeout_vs_immediate.jsimmediatetimeout译者注:脚本第一次执行完成后,事件循环中加入了setImmediate和setTimeout。在第二轮事件循环中,如果进程性能一般已经达到定时器的阈值,就会在timer阶段执行定时器任务,然后执行setImmediate设置的任务。如果线程性能足够,会因为定时器阈值不够而跳过定时器阶段执行setImmediate设置的任务。但是如果你将这两个定时器移到I/O循环中,setImmediate总是先执行//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翻译注释致作者:文件操作是I/O操作,实际上是轮询阶段执行,回调执行完成后poll队列为空,在poll阶段已经设置了定时器(在timer阶段执行),此时有setImmediate任务,所以直接进入check阶段.使用setImmediate的好处是它总是在定时器之前执行(在I/O循环中),不管有多少个定时器set.process.nextTick()你可能已经注意到process.nextTick()并没有出现在上图,虽然是异步API的一个组件。从技术角度来看,process.nextTick()不是事件循环的一部分。nextTickQueue在当前操作完成后,总是有一个有限的执行级别。原文中对“operation”的定义如下:这里,一个operation被定义为从底层的C/C++handler的转换,处理需要执行的JavaScript。回到刚才的流程图,在图中的任何阶段都可以执行process.nextTick(),所有通过process.nextTick()注册的回调都会在事件循环进入下一阶段之前被处理。这种设计会导致一些不好的情况,如果你递归地调用process.nextTick()它会“饿死”I/O,因为它会阻塞事件循环进入事件轮询阶段。为什么允许这种设计?为什么Node.js中包含这样的设计?这是Node.js设计理念的一部分,一个接口应该始终是异步的,即使它是同步的,例如:functionapiCall(arg,callback){if(typeofarg!=='string'){returnprocess。nextTick(callback,newTypeError('argumentshouldbestring'));}}此代码将检查参数并在类型错误时抛出错误。process.nextTick()允许在最新的更新中传入参数,但是然后将参数传递到回调中,而不是嵌套一个函数来包装和实现类似的功能。在上面的代码中,我们会把错误通知给用户,但是这个错误只有在用户的代码执行完之后才会执行。借助流程。nextTick()我们可以保证apiCall()调用的回调总是在当前用户代码执行完成后,事件循环进入下一阶段之前执行代码。为此,JS调用栈允许那些给定的回调在unwinding后立即执行,这样做允许用户通过process.nextTick创建递归代码,但不会导致V8引擎的栈溢出错误RangeError:Maximumcallstacksizeexceeded从v8.水平有限,原文如下:为此,允许JS调用栈展开,然后立即执行提供的回调,允许递归调用process.nextTick()而不会达到RangeError:Maximum从v8开始超出调用堆栈大小。这个概念可能会引起问题,例如:letbar;//这是一个异步接口设计的函数,里面其实是一个同步函数someAsyncApiCall(callback){callback();}//内部回调会调用someAsyncApiCall(()=>{//因为someAsyncApiCall是立即执行的,此时bar还没有赋值console.log('bar',bar);//undefined});酒吧=1;用户定义了一个带有异步接口的函数,但在内部它是同步的。当提供给someAsyncApiCall的回调被执行时,回调与事件循环执行阶段的someAsyncApiCall相同,因为someAsyncApiCall本质上不执行任何异步操作。结果是回调试图引用bar即使这个变量在范围内没有值,因为脚本还没有被完全解析。通过将回调放入process.nextTick()中,脚本的其余部分将有机会完成、解析所有函数和变量等,以在调用回调之前进行初始化。在事件循环进入下一阶段之前接收错误也非常有用。这是一个使用process.nextTick()的例子:letbar;functionsomeAsyncApiCall(callback){nextTick(callback);}someAsyncApiCall(()=>{console.log('bar',bar);//1});酒吧=1;下面是一个实际的应用例子:constserver=net.createServer(()=>{}).listen(8080);server.on('listening',()=>{});传入端口后,立即绑定端口。所以监听可以立即执行。但问题是.on('listening')回调尚未注册。通过使用nextTick()对内部监听事件进行排队,让脚本有机会完成。这允许用户注册他们想要的Listener.process.nextTick()vssetImmediate()这两个接口的功能与用户类似,但是他们的名字是elusive.process.nextTick()在事件循环的某个阶段setImmediate()在同一阶段立即触发setImmediate()在下一次迭代或事件循环的“滴答”时触发本质上,它们应该交换名称。process.nextTick()启动时间比setImmediate()短,但是这个坑埋得太久了,很难修复。如果改名,会使大一些的包挂掉。随着npm上越来越多的包尝试修复成本越来越高。虽然命名有问题,但是无法修改。我们的开发人员在所有情况下都使用setImmediate,因为它更容易推理(它也可以使代码更兼容,例如在浏览器中运行)。为什么要使用process.nextTick()?主要原因有两个:运行用户处理错误,清理不需要的资源,或者在事件循环进入下一阶段之前尝试再次发送请求。)但在事件循环进入下一阶段之前。有一个符合用户期望的例子:constserver=net.createServer();server.on('connection',(conn)=>{});server.listen(8080);server.on('listening',()=>{});假设事件循环中首先运行的是listen(),但是监听的回调是使用setImmediate设置的。除非传入了host名称,否则会立即绑定到端口。为了让事件循环继续下去,它必须进入轮询阶段。这意味着在侦听之前建立的连接将在侦听事件触发之前执行连接事件。另一个例子是运行一个函数构造函数,它继承自EventEmitter,它想在构造函数中调用一个事件。constEventEmitter=require('事件');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('事件');constutil=require('util');functionMyEmitter(){EventEmitter.call(this);//一旦分配了处理程序,使用nextTick发出事件process.nextTick(()=>{this.emit('event');});}util.inherits(MyEmitter,EventEmitter);constmyEmitter=newMyEmitter();myEmitter.on('event',()=>{console.log('aneventoccurred!');});参考Node.jsEventLoop,Timers,andprocess.nextTick()fromtherandomnesssetTimeout和setImmediate的执行顺序之窥探Node的事件循环机制NodeQuest的EventLoop(二)--setTimeout/setImmediate/process.nextTick的区别Nodetimer详解nodejseventloop、timers和process.nextTick()