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

【NodeJs系列】【翻译】理解NodeJs中的EventLoop、Timers和process.nextTick()

时间:2023-04-03 19:46:44 Node.js

译者注:为什么要翻译?其实在翻译这篇文章之前,作者已经谷歌了中文翻译,但是我不是很懂,所以打算自己翻译一下。当然,本人能力有限,文章中可能存在错误或疏漏之处。请纠正我。文末会有几个小问题。大家不妨一起考虑一下。对NodeJs系列感兴趣的朋友请关注微信公众号:前端神盾局。或者githubNodeJs系列文章什么是EventLoop?虽然JavaScript是单线程的,但是NodeJs可以通过EventLoop将I/O操作尽可能地卸载到系统内核,从而实现非阻塞I/O功能。由于大多数现代系统内核都是多线程的,它们可以在后台执行多个操作。当其中一个操作完成后,内核会通知NodeJs,这样(这个操作)指定的回调就会被加入到轮询队列中最后执行。我们将在后续章节中进一步解释这一点。事件循环分析当NodeJs启动时,会立即初始化事件循环,然后执行相应的输入脚本(将脚本直接放入REPL执行不在本文讨论范围内)。在这个过程中(脚本执行)可能会有异步的API调用产生一个定时器或者调用process.nextTick(),然后启动事件循环。译者注:这段话的意思是NodeJs先执行同步代码。在同步代码执行过程中,可能会调用异步API。当同步代码和process.nextTick()回调被执行时,事件循环就会开始图中简单概括了事件循环的运行顺序:┌────────────────────────────────────┐┌──>│定时器││└──────────────┬──────────────┘│┌────────────┴──────────────────────────────────────────────────┐││I/O回调……──┐││空闲,准备││└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐│┌────────────┴──────────────┐┐传入:│││投票│<──────┤连接,││└────────────┬────────────────┘│数据等││┌────────────┴────────────────┐└────────────────────┘││检查││└────────────────────────────────────────────────────────────────────────┘│┌────────────┴──────────────┐└──┤收盘回调│└──────────────────────────────┘注意:每个方框代表事件循环中的一个阶段。每个阶段都有一个FIFO(先进先出)回调队列等待执行。虽然每个阶段都有其独特性,但一般情况下,当事件循环进入指定阶段后,会执行该阶段的任何操作,并执行相应的回调,直到队列中没有可执行的回调或回调执行到上层limit,然后事件循环会进入下一阶段。由于其中任何一个阶段的操作都可能产生更多的操作,内核也会将新的事件推入轮询阶段的队列中,所以在处理轮询事件的同时,允许新的轮询事件继续加入队列,这也意味着很长一段时间运行的回调可以允许轮询阶段运行时间超过计时器阈值注意:Windows和Unix/Linux有一些实现差异,但这对本文并不重要。实际上有7或8个步骤,但上面列出的是Node.js中实际使用的步骤。Phaseoverviewtimers:执行setTimeout()和setInterval()的回调I/Ocallbacks:执行几乎所有的回调,除了close回调,定时器回调和setImmediate()设置的回调idle,prepare:internaluseonlypoll:ReceivenewI/O事件,在合适的时候,node会阻塞在这里(==什么合适?==)查看:setImmediatecallback在这里触发close回调:比如socket.on('close',...)eventloop每次执行完之后,Node.js会检查是否还有需要等待的I/O或者定时器还没有处理完。如果没有,该过程将退出。Phasedetailstimers定时器指定一个阈值,并在达到阈值后执行给定的回调,但通常阈值会超过我们预期的时间。定时器回调会尽可能早的执行,但是操作系统的调度和其他回调的执行时间会造成一定的延迟。注意:严格来说,定时器何时执行取决于轮询阶段。例如,假设定时器给定的阈值为100ms,异步读取文件需要95ms。constfs=require('fs');functionsomeAsyncOperation(callback){//假设这里花费了95msfs.readFile('/path/to/file',callback);}consttimeoutScheduled=Date.now();setTimeout(function(){constdelay=Date.now()-timeoutScheduled;console.log(delay+'从我被调度到现在已经过了ms');},100);//95ms后异步操作完成someAsyncOperation(function(){conststartCallback=Date.now();//这花了10毫秒while(Date.now()-startCallback<10){//什么都不做}});在这种情况下,当事件循环到达轮询阶段时,它的队列是空的(fs.readFile()尚未完成),因此它会停留在这里直到达到最早的定时器阈值。fs.readFile()读取文件用了95ms,之后它的回调被推入轮询队列并执行(执行用了10ms)。callback执行完后,队列中没有其他callback要执行,这时eventloop会检查是否有timercallback可以执行,如果有则跳回timer阶段执行相应的回调。在这个例子中,你可以看到从定时器被调用到它的回调被执行总共需要105ms。注意:为了防止事件循环在poll阶段被阻塞,libuv(http://libuv.org/是一个用c语言实现Node.js事件循环和各平台异步行为的库)会指定一个硬最大值以防止将更多事件推入轮询。I/O回调阶段该阶段用于对一些系统操作进行回调,例如TCP错误。例如,当TCP套接字在尝试连接时收到ECONNREFUSED错误时,某些*nix系统将希望报告这些错误,并将其推送到I/O回调以执行。轮询阶段轮询阶段有两个功能:执行达到阈值的定时器脚本和处理轮询队列中的事件。当事件循环进入轮询阶段,并且这段代码没有设置定时器时,会发生以下情况:如果轮询队列不为空,事件循环会遍历执行队列中的回调函数,直到队列为空或者达到系统上限。如果轮询队列为空,则会发生以下情况:如果脚本中有对setImmediate()的调用,事件循环将结束轮询阶段并进入检查阶段并执行预定代码。如果脚本中没有调用setImmediate(),事件循环会阻塞在这里,直到添加回调,新添加的回调会立即执行。如果轮询队列为空,事件循环将检查是否有定时器达到阈值。如果一个或多个定时器满足要求,事件循环将回到定时器阶段,执行新阶段的回调。一旦check阶段polls阶段完成,这个阶段的回调会立即执行。如果poll阶段空闲,脚本中执行了setImmediate(),那么事件循环会跳过poll阶段的等待,进入该阶段。实际上setImmediate()是一个特殊的计时器,它运行在事件循环的一个单独阶段,它使用libuvAPI来安排回调执行。一般来说,随着代码的执行,事件循环最终会进入轮询阶段,等待新事件(比如新的连接和请求等)的到来。但是,如果有setImmediate()回调,并且poll阶段空闲,那么事件循环就会停止在poll阶段等徘徊,直接进入check阶段。在关闭回调阶段,如果一个套接字或句柄突然关闭(例如:socket.destory()),关闭事件将被提交到这个阶段。否则它将由process.nextTick()setImmediate()和setTimeout()触发setImmediate和setTimeout()看起来很相似,但它们有不同的行为,具体取决于它们被调用的时间。setImmediate()被设计为在poll阶段完成后立即被调用,并且setTimeout()在达到最小阈值时被触发执行。两次调用的顺序取决于它们的执行上下文。如果两者都在主模块中调用,那么它们的回调执行点取决于进程的性能(可能会受到同一台机器上运行的其他应用程序的影响)例如,如果以下脚本不在I/O循环运行,这两个定时器运行的顺序不一定(==这是为什么?==),取决于处理过程的性能://timeout_vs_immediate.jssetTimeout(functiontimeout(){console.log('timeout');},0);setImmediate(functionimmediate(){console.log('immediate');});$nodetimeout_vs_immediate.jstimeoutimmediate$nodetimeout_vs_immediate.jsimmediatetimeout但是如果把上面的代码放在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使用setImmediate()而不是setTimeout()主要好处是:如果在I/O循环中调用代码,那么setImmediate()总是优先于其他计时器(无论存在多少计时器)process.nextTick()理解process.nextTick()你可能已经注意到process.nextTick()不在上图中,即使它也是一个异步api。这是因为process.nextTick()严格来说并不是事件循环的一部分,它忽略了事件循环的当前阶段,直接处理nextTickQueue中的内容。回顾图表,您可以在任何给定阶段调用process.nextTick(),并且在继续事件循环之前执行传递到process.nextTick()的所有回调。这会导致一些不好的情况,因为它允许你递归调用process.nextTick()使得事件循环无法进入poll阶段,导致无法接收新的I/O事件。为什么这是允许的?那么为什么Node.js中包含这样的东西呢?部分原因是Node.js的设计理念:API应该始终是异步的,即使在某些地方没有必要。例如:functionapiCall(arg,callback){if(typeofarg!=='string')returnprocess.nextTick(callback,newTypeError('argumentshouldbestring'));}这是参数校准的部分验证码,如果参数不正确,将错误信息传递给回调。最近更新了process.nextTick(),这样我们就可以在不嵌套多个函数的情况下将多个参数传递给回调。我们所做的(在此示例中)是在确保其余(同步)代码的执行完成后将错误传递给用户。通过使用process.nextTick()我们可以确保apiCall()回调总是在其他(同步)代码完成运行之后和事件循环开始之前被调用。为了实现这一点,JS调用堆栈展开(==什么是堆栈展开?==)并立即执行提供的回调,然后我们可以递归(==如何?==)调用process.nextTick而不是触发RangeError:Maximumcallstacksizeexceededfromv8错误。这种理念可能会导致一些潜在的问题。例如:letbar;//这有一个异步签名,但是同步调用回调函数someAsyncApiCall(callback){callback();}//在`someAsyncApiCall`完成之前调用回调。someAsyncApiCall(()=>{//因为someAsyncApiCall已经完成,bar还没有被赋值console.log('bar',bar);//undefined});酒吧=1;用户定义了一个异步签名函数someAsyncApiCall()(函数名可见),但实际上操作是同步的。当它被调用时,它的回调也在事件循环的同一阶段被调用,因为someAsyncApiCall()实际上并不执行任何异步操作。结果,回调尝试在所有(同步)代码执行之前访问变量bar。通过将回调放在process.nextTick()中,脚本可以完整运行(同步代码全部执行),这使得变量、函数等可以在回调之前执行。同时,它还有一个好处就是可以防止事件循环继续执行。有时我们可能希望在事件循环继续之前抛出一个错误,在这种情况下process.nextTick()就很有用了。以下是上例的process.nextTick()转换://1});酒吧=1;这是一个实际的例子:constserver=net.createServer(()=>{}).listen(8080);server.on('监听',()=>{});当只传入一个端口作为参数时,会立即绑定该端口。因此可以立即调用侦听器回调。问题是:当时没有注册on('listening')回调。要解决此问题,请将侦听事件排队到nextTick()以允许脚本先完成(同步代码)。这允许用户(在同步代码中)设置他们需要的任何事件处理程序。process.nextTick()和setImmediate()与users非常相似,但它们的名称令人困惑。process.nextTick()会在同一阶段执行setImmediate()会在后续迭代中执行本质上,两者的名字应该互换,process.nextTick()比setImmediate()更接近immediate,但是由于对于这不太可能改变的历史原因。名称交换会影响大多数npm包,每天提交的包数量之多意味着你退得越远,交换造成的损害就越大。因此,即使它们的名称令人困惑,也不可能更改它们。我们建议开发者在所有情况下都使用setImmediate(),因为这可以让你的代码兼容浏览器等更多环境。为什么要使用process.nextTick()?这里主要有两个原因:允许开发人员处理错误,清除无用资源,或者在事件循环继续之前尝试再次重新请求资源。在循环继续执行我们想要的操作之前运行以下示例:constserver=net.createServer();server.on('connection',function(conn){});server.listen(8080);server.on('聆听',function(){});假设listen()在事件循环开始之前运行,但是监听回调被包裹在setImmediate中,除非指定了hostname参数,否则会立即绑定端口(监听回调被触发),event循环必须执行到poll阶段,也就是说在监听事件的回调执行之前,有可能接收到一个连接,相当于在监听事件之前触发了连接事件。另一个例子是运行一个继承自EventEmitter的构造函数,并在这个构造函数中发布一个事件。constEventEmitter=require('事件');constutil=require('util');函数MyEmitter(){EventEmitter.打电话(这个);这。发出('事件');实用程序。inherits(MyEmitter,EventEmitter);constmyEmitter=newMyEmitter();myEmitter.on('event',function(){console.log('事件发生!');});您实际上不能立即从构造函数触发事件,因为脚本不会运行到用户为此事件分配回调的位置。因此,在构造函数中,您可以使用process.nextTick()设置回调以在构造函数完成后发出事件,提供预期结果constEventEmitter=require('events');constutil=require('util');functionMyEmitter(){EventEmitter.call(this);//一旦分配了处理程序,就使用nextTick发出事件process.nextTick(function(){this.emit('event');}.bind(this));}util.inherits(MyEmitter,EventEmitter);constmyEmitter=newMyEmitter();myEmitter.on('event',function(){console.log('aneventoccurred!');});译者注(Q&A)这篇文章翻译后,作者问了多少问题他自己?轮询阶段什么时候会被阻塞?为什么在非I/O循环中setTimeout和setImmediate的执行顺序不一定?JS调用栈展开是什么意思?为什么可以递归调用process.nextTick()?笔者将在后面的文章《Q&A之理解NodeJs中的Event Loop、Timers以及process.nextTick()》中讨论这些问题。感兴趣的同学可以关注笔者的公众号:前端情报局-NodeJs系列,获取最新资讯。原文地址:https://github.com/nodejs/nod..