Node.js作为JavaScript服务器运行时,主要处理网络和文件,没有事件循环在浏览器中的渲染阶段。浏览器中有一个HTML规范来定义事件循环的处理模型,然后由各个浏览器厂商实现。Node.js中事件循环的定义和实现来自Libuv。Libuv围绕事件驱动的异步I/O模型设计,最初是为Node.js编写的,提供跨平台支持库。下图显示了它的组件。NetworkI/O是与网络处理相关的部分。右边是文件操作,DNS,下面是epoll,kqueue,eventports,IOCP。这些是不同底层操作系统的实现。事件循环的六个阶段Node.js启动时,会初始化事件循环,处理提供的脚本,直接在栈上执行同步代码。异步任务(网络请求、文件操作、定时器等)在调用API时通过回调函数后,将操作转移到后台由系统内核处理。目前大多数内核都是多线程的。当其中一个操作完成后,内核通知Node.js将回调函数添加到轮询队列中等待执行机会。下图左边是Node.js官网对事件循环流程的描述,右边是Libuv官网对Node.js的描述。文档通常是事件循环比较直接的学习参考文档,在Node.js官网上有详细的介绍,可以作为学习的参考资料。左侧Node.js官网展示的事件循环分为6个阶段,每个阶段都有一个FIFO(先进先出)队列来执行回调函数。这些阶段之间执行的优先顺序仍然很明确。它在右侧有更详细的描述。在事件循环迭代前,首先判断循环是否处于活动状态(有等待异步I/O、定时器等)。如果它处于活动状态,则开始迭代,否则循环将立即退出。下面分别讨论每个阶段。timers(定时器阶段)首先,事件循环进入定时器阶段,包含两个APIsetTimeout(cb,ms),setInterval(cb,ms)前者只执行一次,后者重复执行。该阶段检查是否存在过期定时器功能。如果有过期定时器回调函数,和浏览器中一样。定时器函数传入的延迟时间总是比我们预期的要晚,会受到操作系统或其他正在运行的回调函数的影响。例如,在下面的示例中,我们设置了一个定时器函数,并期望它在1000毫秒后执行。constnow=Date.now();setTimeout(functiontimer1(){log(`delay${Date.now()-now}ms`);},1000);setTimeout(functiontimer2(){log(`delay${Date.now()-now}ms`);},5000);someOperation();functionsomeOperation(){//同步操作...while(Date.now()-now<3000){}}调用setTimeout异步函数后,程序立即执行someOperation()函数。中间一些比较耗时的操作消耗了3000ms左右。完成这些同步操作后,进入一个事件循环。首先检查timer阶段是否有过期任务。定时器脚本按照延迟时间从小到大的顺序存放在堆内存中。首先,取出超时时间最小的定时器函数进行查看。如果nowTime-timerTaskRegisterTime>delay,取出回调函数执行。否则,继续检查。当未过期的被检查到定时器功能或系统相关的最大数量限制后,进入下一阶段。在我们的例子中,假设执行someOperation()函数后的当前时间为T+3000:查看timer1函数,当前时间为T+3000-T>1000,已经超过了预期的延迟时间,取出回调函数执行,继续检查。查看timer2函数,当前时间为T+3000-T<5000,还没有达到预期的延时时间,此时退出定时器阶段。pendingcallbacks定时器阶段完成后,事件循环进入pendingcallbacks阶段,执行上一轮事件循环遗留下来的I/O回调。根据Libuv文档:在大多数情况下,所有I/O回调都会在轮询I/O之后立即调用,但是,在某些情况下,调用此类回调会延迟到下一次循环迭代。听了之后,更像是前一阶段的遗风。idle、prepareidle和prepare阶段由系统内部使用。空闲这个名字很混乱。虽然它被称为空闲,但当它们处于活动状态时,它会在每个事件循环中被调用。关于这块的信息不多。略...pollpoll是一个重要的阶段,这里有一个概念观察者,有文件I/O观察者,网络I/O观察者等,它会观察是否有新的请求进来,包括读取文件和等待responses,等待新的socket请求,这个阶段在某些情况下会被阻塞。BlockingI/Otimeout在阻塞I/O之前,要计算它应该阻塞多长时间,参考Libuv文档上的一些描述,下面是计算超时的规则:如果循环使用UV_RUN_NOWAIT标志运行,超时是0。如果循环即将停止(调用uv_stop()),则超时为0。如果没有活动的处理程序或请求,则超时为0。如果任何空闲处理程序处于活动状态,则超时为0。如果有pendinghandlers,则超时为0。如果以上条件都不存在,则使用最新定时器的超时时间,或者如果没有活跃的定时器,则超时时间无限长,轮询阶段将永远被封锁。示例1是一段非常简单的代码。我们启动一个服务器。现在事件循环的其他阶段没有要处理的任务。它会在这里等待,直到有新的请求进来。consthttp=require('http');constserver=http.createServer();server.on('request',req=>{console.log(req.url);})server.listen(3000);示例2结合第一阶段的定时器,我们来看一个例子,首先启动app.js作为服务端,模拟3000ms的响应延迟,这个只是为了测试。然后运行client.js,可以看到事件循环的执行过程:首先,程序调用了一个1000ms后超时的定时器。然后调用异步函数someAsyncOperation()从网络读取数据。我们假设这个异步网络读取需要3000ms。当事件循环开始时,首先进入定时器阶段,如果没有发现超时的定时器函数,则继续向下执行。期间,经过pendingcallbacks->idle,prepare,进入poll阶段时,此时http.get()还没有完成,其队列为空。参考上面轮询阻塞超时时间规则,事件循环机制会检查最快达到阈值。计时器而不是永远在这里等待。大约1000ms后,进入下一个事件循环进入定时器,执行过期定时器回调函数,1003ms后我们会看到setTimeout运行的日志。timer阶段结束后,会再次进入poll阶段,继续等待。//client.jsconstnow=Date.now();setTimeout(()=>log(`setTimeoutrunafter${Date.now()-now}ms`),1000);someAsyncOperation();functionsomeAsyncOperation(){http.get('http://localhost:3000/api/news',()=>{log(`在${Date.now()-now}ms之后获取数据成功);});}//app.jsconsthttp=require('http');http.createServer((req,res)=>{setTimeout(()=>{res.end('OK!')},3000);})。听(3000);当轮询阶段队列为空,脚本通过setImmediate()调度,此时事件循环也会结束轮询阶段,进入下一阶段检查。check检查阶段在poll阶段之后运行,其中包含一个APIsetImmediate(cb),如果有setImmediate触发的回调函数,就会取出执行,直到队列为空或者达到系统的最大限制。setTimeoutVSsetImmediate比较setTimeout和setImmediate。这是一个常见的例子。根据被调用的时间和计时器可能会受到计算机上其他正在运行的应用程序的影响,它们的输出顺序并不总是固定的。setTimeout(()=>log('setTimeout'));setImmediate(()=>log('setImmediate'));//第一次运行setTimeoutsetImmediate//第二次运行setImmediatesetTimeoutsetTimeoutVSsetImmediateVSfs.readFile但是一次调用这两个函数在I/O循环中,setImmediate总是首先被调用。因为setImmediate属于check阶段,所以在事件循环中总是在poll阶段之后运行,这个顺序是确定的。fs.readFile(__filename,()=>{setTimeout(()=>log('setTimeout'));setImmediate(()=>log('setImmediate'));})关闭回调在Libuv中,如果调用关闭句柄uv_close(),它将调用关闭回调,这是事件循环关闭回调的最后阶段。这个阶段的工作更像是做一些清理工作,比如调用socket.destroy()时,会在这个阶段发送'close'事件,事件循环执行完这个阶段队列中的回调函数,检查循环是否还活着,如果没有则退出,否则继续下一个新的事件循环。在浏览器的事件循环中,任务分为Task和Microtask。前端训练在Node.js中分为阶段。上面我们介绍了Node.js事件循环的六个阶段。用户主要有四个阶段:timer、poll、check、closecallback,其余两个阶段由系统调度。这些阶段产生的任务可以看作任务任务源,也就是常说的“Macrotask宏任务”。通常我们在谈论事件循环时也会包括Microtask。Node.js中的microtask有Promise,还有一个函数queueMicrotask可能不太受关注。它在Node.jsv11.0.0之后实现,参见PR/22951。Node.js中的事件循环在每个执行阶段后检查微任务队列中是否有未决任务。Node.js11.x前后差异Node.jsv11.x前后,如果每个阶段都有可执行的Tasks和Microtasks,会有一些差异。先看一段代码:setImmediate(()=>{log('setImmediate1');Promise.resolve('Promisemicrotask1').then(log);});setImmediate(()=>{log('setImmediate2');Promise.resolve('Promisemicrotask2').then(log);});在Node.jsv11.x之前,如果当前stage有多个可执行Task,会先执行完,再执行microtask。基于v10.22.1版本,结果如??下:setImmediate1setImmediate2Promisemicrotask1Promisemicrotask2Node.jsv11.x后,如果当前阶段有多个可执行Task,先取出一个Task执行,清除对应的microtask入队,再次取出下一个可执行任务,继续执行。基于v14.15.0版本,结果如??下:setImmediate1Promisemicrotask1setImmediate2Promisemicrotask2这个Node.jsv11.x之前的执行顺序问题被认为是v11.x之后应该修复的bug,其执行时机已经修改的。它与浏览器一致,详情请参阅issues/22257discussion。特别是process.nextTick()Node.js中还有一个异步函数process.nextTick()从技术上讲它不是事件循环的一部分,它是在当前操作完成后处理的。如果存在递归process.nextTick()调用,这将很糟糕,会阻塞事件循环。如以下示例所示,它显示了process.nextTick()递归调用的示例。当前事件循环位于I/O循环中。当同步代码执行完成后,会立即执行process.nextTick(),陷入死循环。与同步递归不同,它不会触及v8最大调用堆栈限制。但是会破坏事件循环调度,setTimeout永远不会执行。fs.readFile(__filename,()=>{process.nextTick(()=>{log('nextTick');run();functionrun(){process.nextTick(()=>run());}});log('syncrun');setTimeout(()=>log('setTimeout'));});//输出syncrunnextTick把process.nextTick改成setImmediate虽然是递归的,但是不会影响Eventloop调度,setTimeout在下一个事件循环中执行。fs.readFile(__filename,()=>{process.nextTick(()=>{log('nextTick');run();functionrun(){setImmediate(()=>run());}});log('syncrun');setTimeout(()=>log('setTimeout'));});//输出syncrunnextTicksetTimeoutprocess.nextTick立即执行,setImmediate在下一个事件周期的check阶段执行.然而,它们的名称非常混乱,人们可能认为交换这两个名称会更好,但这是一个遗留问题,不太可能更改,因为它会破坏NPM上的大多数包。在Node.js文档中,也建议开发者尽量使用setImmediate(),这样更容易理解。
