说起Node.js事件循环,网上已经有很多文章介绍其原理,大同小异。经过一段时间的学习,对Node.js的事件循环有了一些体会。有了一定的了解之后,再写一篇博客总结自己的学习成果。在笔者看来,事件和周期是两个概念。事件是可以被控件识别的操作,例如按下OK按钮、选择单选按钮或复选框。每个控件都有自己可识别的事件,例如窗体加载、单击、双击等事件,编辑框(文本框)的文本变化事件。但是,一个循环包含了GUI线程中的一个循环,但这个循环对开发者和用户来说是不可见的,只有在程序关闭后循环才会结束。当用户触发按钮事件时,会产生相应的事件,并将这些事件加入到一个队列中。用户会在前台继续产生事件,但是后台会继续处理这些事件,这些事件会被添加到一个队列中,由于主循环中循环的存在,这些对应的事件会被处理一次一个。对于JavaScript,由于JavaScript是单线程的,对于一个耗时的操作,使用异步的方式(Ajax...)来解决。对于不同的异步事件,不同的线程各司其职进行处理。Node.js中的事件循环Node.js的事件循环与浏览器的事件循环有很大的不同。Node.js启动时,会初始化事件循环;处理提供的输入脚本(或者丢进REPL,本文不涉及),可能会调用一些异步API函数调用,调度任务处理事件,或者调用process.nextTick(),然后开始处理事件环形。有一点很清楚,事件循环也是运行在单线程环境下,JavaScript的事件循环是由浏览器实现的,而Node.js是由Libuv实现的。根据Node.js官方介绍,每个事件循环包括6个阶段,对应Libuv源码中的实现,如下图所示,展示了事件循环的概况和执行顺序。timersj阶段:该阶段执行定时器的回调(setTimeout,setInterval)I/O回调:执行一些系统调用错误,如网络通信错误回调idle,prepare:只有poll被节点内部使用:获取新的I/O事件,适当的情况下,node会阻塞在这里check:executesetImmediate()callbackclosecallbacks:executesocketcloseeventcallback下面是Node.js事件循环的源码:intuv_run(uv_loop_t*loop,uv_run_modemode){int暂停;诠释;intran_pending;r=uv__loop_alive(loop);如果(!r)uv__update_time(循环);while(r!=0&&loop->stop_flag==0){uv__update_time(loop);//计时器阶段uv__run_timers(loop);//I/O回调阶段ran_pending=uv__run_pending(loop);//空闲阶段uv__run_idle(loop);//准备阶段uv__run_prepare(loop);超时=0;如果((mode==UV_RUN_ONCE&&!ran_pending)||mode==UV_RUN_DEFAULT)timeout=uv_backend_timeout(loop);//轮询阶段uv__io_poll(loop,timeout);//检查阶段uv__run_check(loop);//关闭回调阶段uv__run_closing_handles(loop);如果(模式==UV_RUN_ONCE){uv__update_time(lo操作);uv__run_timers(循环);}r=uv__loop_alive(loop);如果(模式==UV_RUN_ONCE||模式==UV_RUN_NOWAIT)中断;}if(loop->stop_flag!=0)loop->stop_flag=0;returnr;}假设事件循环进入到某个阶段,即使在此期间其他队列中的事件已经就绪,在继续向下执行之前,当前阶段对应的队列中的所有回调方法都会执行完。结合代码也很好理解。不难得出,事件循环系统中回调的执行顺序是有迹可循的,同样会造成事件阻塞。varfs=require("fs");飞秒。readFile('input.txt',function(err,data){if(err){console.log(err.stack);return;}console.log(data.toString());});fs.readFile('test.txt',function(err,data){if(err){console.log(err.stack);return;}console.log(data.toString());});控制台。log("Theprogramisexecuted");在对整个事件循环有了一个大概的了解之后,接下来会针对每个阶段进行详细的描述。timers这个阶段主要用来处理定时器相关的回调方法。当一个定时器被取代时,会在该阶段的队列中加入一个事件,事件循环会跳转到该阶段执行相应的回调方法。定时器的回调在触发后会尽早调用,为什么要尽早调用呢?因为实际的触发事件可能比预设的时间还要长。Node.js不能保证定时器在预设时间一到就立即执行,因为Node.js对定时器的过期检查不一定可靠,会受到机器上其他正在运行的程序,或者主线程的影响那个时候也没闲着。I/O回调在这个阶段,除了定时器、setImmediate、close操作之外的大部分回调方法都在这个阶段执行。例如,如果在TCP套接字的执行过程中发生了一些错误,那么这个回调函数将在I/O回调阶段执行。该名称可能会误导I/O回调处理程序的执行,但一些常见的回调将在轮询阶段处理。I/O回调阶段主要经历以下过程:检查是否有待处理的I/O回调。如果是,则执行回调。如果没有,请退出舞台。查看是否有process.nextTick任务,如果有则全部执行。检查是否有微任务,如果有,则全部执行。退出这个阶段。Poll在Poll阶段主要有两个功能:处理poll队列中的事件,当有定时器超时时执行其回调函数。当事件循环进行到poll阶段,如果此时没有处理定时器的回调方法,会做如下判断:如果poll队列不为空,事件循环会方便的执行其中的回调方法队列有序,这个过程是同步的。如果轮询队列为空,则重新判断。如果有预设的setImmediate(),事件循环会结束poll阶段进入check阶段,执行check阶段的任务队列。如果没有预设setImmediate(),事件循环可能会关闭。进入等待状态,等待新事件的产生,这也是这个阶段命名为poll的原因。如果出现这些事故,本阶段将继续检查是否有相关的定时器超市。如果有,则跳转到timers阶段,然后执行相应的回调方法检查,执行本阶段setImmediate()的回调函数。关于setImmediate是一个特殊的定时器方法,setImmediate的回调会加入到checkqueue中。从事件循环的阶段图可以知道,check阶段的执行顺序是在poll之后。一般情况下,事件循环到达轮询阶段后,会检查当前代码是否调用了setImmediate方法。在描述轮询阶段时已经提到了这一点。如果setImmediate方法调用了回调函数,事件循环就会跳出poll阶段,进入check阶段。(这段有点重复。。。)close关闭阶段用来管理关闭事件和清理应用程序的状态。比如程序中的socket关闭会被加入到关闭队列中,当本轮事件结束后,会进入下一轮循环。总结对于事件循环,每个阶段都有一个任务队列。当事件循环到达某个阶段时,就会执行这个阶段的任务队列。直到清空队列或执行swap达到系统上限后才会转移。下个阶段。当所有阶段都执行一次时,事件循环完成一次滴答。process.nextTick这是一个Node.js特有的方法,它不存在于任何浏览器(和进程对象)中,process.nextTick是一个异步动作,让这个动作在事件循环的当前阶段执行完后立即执行,也就是上面提到的刻度线。process.nextTick(()=>{console.log("1")})console.log("2")//2//1官方对process.nextTick有一个很有意思的解释:从语义的角度看来,setImmediate(后面会提到)应该在process.nextTick之前执行,但实际上这个命名是历史性的,很难改变。但是对于process.nextTick来说,这个方法并不是事件循环的一部分,但是它的回调方法确实是被事件循环调用的,这个方法定义的回调方法会被加入到nextTickQueue的队列中。相反,nextTickQueue将在当前操作完成后立即处理,而不管事件循环的当前阶段。Node.js对process.nextTick有限制。如果在倒回nextTickQueue的最大限制后递归调用process.nextTick,则会抛出错误。functionnextTick(i){while(i<9999){process.nextTick(nextTick(i++));}}//超过最大调用堆栈大小nextTick(0);由于队列中也存在process.nextTick,所以它的执行顺序也是按照程序编写的顺序执行的。process.nextTick(()=>{console.log(1)});process.nextTick(()=>{console.log(2)});//1//2和其他回调函数一样,过程。nextTick定义的回调也由事件循环执行。如果在process.nextTick的回调方法中发生阻塞操作,则后续要执行的回调函数也会被阻塞。process.nextTick将在每个事件阶段之间执行。一旦执行完,直到nextTickQueue被清空才会进入下一个事件阶段,所以如果递归调用process.nextTick,会造成I/Ostarving问题,比如下面例子的readFile已经完成,但是它的回调一直无法执行。constfs=require('fs')conststarttime=Date.now()letendtime;fs.readFile('text.txt',()=>{endtime=Date.now()console.log('读完时间:',endtime-starttime)})letindex=0functionhandler(){if(index++>=1000)returnconsole.log(`nextTick${index}`)process.nextTick(handler)}handler();//nextTick1//nextTick2//......//nextTick999//nextTick1000//读完时间:170process.nextTick()vssetImmediate()seImmediate方法不是ECMAScript标准的一部分,但是被提议通过Node.js也为事件队列添加了一个回调函数。与setTimeout和setInterval不同,setImmediate不接受时间作为参数。setImmediate的事件会在当前事件循环结束时触发,相应的回调方法会在事件循环结束时在当前Execution中(check)。虽然它确实存在于某些浏览器中,但它并没有在所有浏览器中实现一致的行为,因此在跨浏览器使用它时需要非常小心。它类似于setTimeout(fn,0)代码,但有时优先于它。这里的命名也不是最好的。process.nextTick中的回调在事件循环的当前阶段立即执行。setImmediate中的回调在下一次迭代或事件循环的滴答时执行。本质上,这两个名称应该互换。process.nextTick()的执行时机比setImmediate()(上面提到的)更及时。实施此更改将破坏许多npm包。每天都会添加许多新模组,这意味着您等待的每一天,都有更多潜在的损害发生。虽然他们的名字互相混淆,但是没有互换的意思(建议开发者处处使用setImmediate,这样程序更容易让人理解)。还是用上面的例子,如果把nextTick换成setImmediate会怎样?constfs=require('fs')conststarttime=Date.now()letendtime;fs.readFile('text.txt',()=>{endtime=Date.now()console.log('读完时间:',endtime-starttime)})letindex=0functionhandler(){if(index++>=1000)returnconsole.log(`setImmediate${index}`)setImmediate(handler)}handler();//setImmediate1//setImmediate2//读完时间:80//......//setImmediate999//setImmediate1000这是因为嵌套的setImmediatecall()回调被安排在下一个事件循环中执行,所以不会有阻塞。setImmediate与setTimeout计时器的行为在Node.js和浏览器中是相同的。关于定时器的一个重要的事情是我们提供的延迟并不意味着回调将在这个时间之后执行。它真正的意思是一旦主线程完成了所有的事情(包括微任务)并且没有其他更高优先级的定时器,Node.js将在这个时间之后执行回调。setImmediate()用于在poll阶段结束后立即执行回调setTimeout()用于在达到指定的下限时间后执行回调setTimeout(functiontimeout(){console.log('timeout');},0);setImmediate(functionimmediate(){console.log('immediate');});//结果1//超时//immediate/**--------华丽的分割线-------**///结果二//立即//超时为什么?为什么会有两个结果,笔者在这里研究的时候有点不清楚,所以我做了第二个例子:varfs=require('fs')fs.readFile(__filename,()=>{setTimeout(()=>{console.log('timeout')},0)setImmediate(()=>{console.log('immediate')})});//运行N次//immediate//如果两个都超时在主模块中被调用,那么执行顺序取决于进程的性能,也就是随机的。如果两者都没有在主模块中调用,那么setImmediate回调将始终首先执行。虽然已经得出结论,但这是为什么呢?回想一下本文前半部分描述的事件循环。首先进入计时阶段。如果我们的机器性能一般,那么在进入定时器阶段的时候,可能已经过去了1毫秒(setTimeout(fn,0)相当于setTimeout(fn,1)),那么setTimeout的回调会先执行。如果小于一毫秒,那么我们可以知道在check阶段,setImmediate的回调会先执行。为什么在fs.readFile回调中设置时setImmediate总是先执行?因为fs.readFile的回调是在poll阶段执行的,所以接下来的check阶段会先执行setImmediate的回调。我们可以注意到,在UV_RUN_ONCE模式下,事件循环会在开始和结束时执行定时器。阅读本文你有什么收获?让我们看看下面的代码并预测输出结果。先不要急着看答案...constfs=require('fs');console.log('程序开始');constpromise=newPromise(resolve=>{console.log('我在promise函数!');resolve('resolvedmessage');});promise.then(()=>{console.log('Iaminthefirstresolvedpromise');}).then(()=>{console.log('我在第二个已解决的承诺中');});process.nextTick(()=>{console.log('我现在在处理下一个tick');});fs.readFile('index.html',()=>{console.log('==================');setTimeout(()=>{console.log('我在setTimeout的回调中,延迟为0毫秒');},0);setImmediate(()=>{console.log('我来自setImmediate回调');});});setTimeout(()=>{console.log('我在setTimeout的回调中,延迟为0ms');},0);setImmediate(()=>{console.log('我在setImmediate回调中');});//输出结果//程序开始//我在promise函数中!//我现在正在处理下一个滴答//我在第一个已解决的承诺中//我在第二个已解决的承诺中//我在setTimeout的回调中,延迟为0毫秒//我来自setImmediate回调//==================//我是从setImmediate回调//我是从setTimeout回调0ms延迟总结本文中的一些知识点还是有点模糊和懵懂,一直在研究,也通过学习事件循环看了一些文档,看到里面有这么一句话:除了你的代码,一切都是同步的。我觉得很有道理,对理解事件循环Node很有帮助。js的事件循环分为6个阶段。process.nextTick不属于事件循环,但是生成的回调会加入到nextTickQueuesetImmediate和setTimeout中。setTimeout的执行顺序会受到环境的影响。文章有点长。文中如有错误,请在评论区指出,我会尽快修复。大家可以积极发言,共同进步,交流。
