当前位置: 首页 > 科技观察

图解Node.js核心Event-loop

时间:2023-03-17 17:34:26 科技观察

本次我们来聊一聊Node.js中涉及到的一个核心概念:event-loop。只有理解了它,我们才能理解node的进程模型,理解异步调用在实现层面是什么样子的,才能更好的理解当同步代码和异步代码混合在一起时,CPU跑到了我们代码的哪一行。文章分为两部分:事件循环和Promise/Generator/async。今天我们主要关注事件循环部分。1.代码思考我写了两个函数,在函数内部直接用while(true){}写了一段死循环的代码。我们先想一下下面的Node.js代码执行结果是什么?很多人说Node.js是单线程的。如果这样,CPU会不会卡在whileLoop_1()的while循环里出不来?'usestrict';asyncfunctionsleep(intervalInMS){returnnewPromise((resolve,reject)=>{setTimeout(resolve,intervalInMS);});}asyncfunctionwhileLoop_1(){while(true){try{console.log('新一轮whileLoop_1');等待睡眠(1000);//LINE-A}catch(error){//...}}}异步函数whileLoop_2(){while(true){try{console.log('新一轮whileLoop_2');等待睡眠(1000);//LINE-B}catch(error){//...}}}whileLoop_1();//LINE-CwhileLoop_2();//LINE-D已经不是什么秘密了,我先把执行结果发出来。新一轮whileLoop_1新一轮whileLoop_2新一轮whileLoop_1新一轮whileLoop_2新一轮whileLoop_1新一轮whileLoop_2新一轮whileLoop_1新一轮whileLoop_2新一轮whileLoop_1新一轮whileLoop_2...是的,如你所见这两个while循环交替执行,CPU不会陷入无限循环而无法脱身。那么问题来了:当CPU执行到LINE-A时会发生什么情况才能成功逃逸,有机会执行whileLoop_2?为什么CPU执行到LINE-B后还能回到whileLoop_1继续执行呢?2.Event-loop在回答上述问题之前,我们需要了解一个至关重要的概念:event-loop。其实我们平时说Node.js是单线程的,就是说node来执行我们的JS代码。更确切地说,V8是在单线程中执行JS代码。其实打开node进程,你会发现它有很多工作线程。这是一个典型的单进程多线程模型。这些工作线程放在线程池中,V8执行JS代码的线程称为主线程。主线程和线程池的关系如下图所示。主线程负责执行JS代码,线程池中的工作线程负责执行访问DB、访问文件等耗时费力的工作。他们通过消息队列协调他们的工作。这类似于餐厅工作流程。餐厅里,一位漂亮的小姐姐招呼客人入座,负责收拾各桌的点单。每当接到点好的菜单,小姐姐就会迅速通过小窗口递交到后厨。后厨有个小看板,所有订单都显示在看板上。主厨根据点餐时间和菜品安排不同的厨师进行烹饪。菜做好后,由小姐负责上菜。图一:Node.js单进程多线程模型嗯,上面的图还是太简单了。它可以用来欺骗新手,但我知道它不会让你满意。让我们把它变大。下图中,左边是主线程,右边是线程池和一个工作线程,中间是消息队列。图2:Node.js主线程与工作线程关系图(一)主线程主线程只做一件事:拼命执行JS代码,做非阻塞I/O操作。这些JS代码既有我们自己写的,也有我们依赖的npm包。这里所说的非阻塞I/O操作是指主线程基本上是在做非阻塞的工作,比如2+3求和,迭代数组等,主线程工作在tick粒度。是的,你一定听说过process.nextTick(),所谓的nexttick就是下一次执行tick的时间。每个tick包含几个阶段。根据Node.js官网的介绍,目前为止一共有6个阶段。timers:这个阶段执行setTimeout()和setInterval()设置的回调函数。Pendingcallbacks:这个阶段会执行一些与系统操作相关的回调,比如建立TCP连接时收到的ECONNREFUSED相关的回调。空闲,准备:仅供Node.js内部使用。poll:从消息队列中获取一个新的I/O事件,并执行相应的回调(不包括setImmediate/closecallback/和timer设置的回调)。check:执行setImmediate()设置的回调。closecallbacks:执行一些close回调,比如这段代码设置的回调socket.on('close',...)。在大多数情况下,这些回调是用JS编写的,Node通过GoogleV8引擎在主线程中执行这些回调。我们把以上6个阶段和tick的关系放在时间轴上,这样或许可以更形象的说明主线程所做的工作。图3:Node.js主线程时序图(2)消息队列主线程不只是执行JS代码,也不只是做非阻塞I/O操作。在执行代码的过程中,还会产生各种异步请求。直观的是setImmediate(callback[,...args])/fs.readFile(path[,options],callback)生成的,晦涩的是Promise/async生成的。在大多数情况下,这些异步请求有一些共性:需要一定的时间来处理它们。让主线程忽略其他事情并等待这个操作的结果是不明智的。所以它们会被封装成asyncRequest,交给线程池处理。还记得我们之前给出的餐厅工作流程示例吗?做饭是一项耗时的工作。如果小姐姐接到我们的订单去后厨做饭会怎样?如果她在去下一张桌子点菜之前把清单上的所有菜都煮好了,就会对客人有一个阻塞的I/O操作:当他们进入餐厅时,没有人会欢迎他们。消息队列就像后厨的看板。小姐姐只负责向看板添加新订单,订单的制作交由厨师团队完成。(3)工作线程工作线程完成特定的I/O请求操作。通常这个过程是通过操作系统提供的异步机制来完成的。比如Windows中的IO完成端口(IOCP)和Linux中的异步IO。如图2所示,当工作线程完成一个异步请求后,会将操作结果放入一个消息队列中。从图中可以看出,主线程运行所涉及的每个阶段都有自己专属的消息队列。消息队列中有消息,这意味着主线程需要重新工作。在工作过程中,会不断产生新的异步请求,工作线程会不断地不知疲倦地搬砖。完美循环。有一个场景图2没有画出来,当Node.js收到系统外的事件,比如网络请求,工作流是什么样子的?目前我们讲的事件都是JS代码主动触发的。如果说此类事件是自上而下触发的,那么网络请求等事件则是自下而上触发的。聪明的你可以在脑子里大致画一条线:这条线的起点是内核中的网卡驱动,终点是Node.js主线程,中间的那条线穿过内核协议栈和Node.js消息队列。3.总结至此,我们可以看出Node.js是一个完整的消息驱动模型。Node进程活着的最大意义在于,有各种事件和回调以及事件绑定的数据需要由它处理(主线程和工作线程)。也可以在事件的回调中产生新的异步请求,从而产生新的事件。正是这些持续不断的事件驱动着Node得以生存。如果没有Node进程需要处理的事件,就没有存在的必要。Node.js也是标准的单线程模型。主线程用来执行我们写的JS代码,线程池中的工作线程用来执行各种耗时的I/O操作。这些操作可能会导致工作线程被阻塞。工作线程阻塞了无所谓,但是主线程阻塞了就没那么美了。最后再次强调:我们写的JS代码是交给V8单线程运行的,所以尽量不要在JS代码中进行耗时的同步操作。