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

为什么需要了解js的EventLoop

时间:2023-04-03 23:19:24 Node.js

?js的事件循环机制的讲解资料简直铺天盖地,也是面试中比较高频的考察区域。对于新手来说,一个紧迫的问题是:为什么我需要关心事件循环?事件循环机制涉及浏览器和nodejs的不同实现,引入的各种任务队列,不同阶段和阶段间的微任务。吸收这么大又枯燥的信息,如果没有足够的动力来保证,我想是走不下去的。众所周知,js是单线程的。但是js之所以是单线程的,并不是设计上有什么特殊的考虑,更多的可以归结为历史原因。一开始设计js很匆忙,就是为了能够快速轻巧的组成一些脚本代码与浏览器进行交互。在那个时候,创作者万万没想到后面会有如此广泛而深入的应用。于是,js走上了单线程这条路。由于js是单线程的,也就是说如果有一个非常耗时的计算任务或者等待资源的任务(IO交互),整个js世界就会停止,也就是所谓的“假死”状态。因为只有一个工作线程,当它一心一意做那个耗时的任务时,其他的任务自然就不会执行了。所以基于这个特性,js必须使用异步的方式来处理各种耗时的任务。其实我们在定义js函数的时候,大部分涉及到外部资源获取的任务都会被定义为异步任务。从这个角度来看,js中很大一部分代码都是关于异步任务的代码。因此,理解“异步任务是如何执行的”就成了一个很重要的课题。如果对它的执行机制没有很好的了解,就无法按照自己的意图准确地规划和定义这些异步任务。异步任务在js中是通过事件来实现的。那么你需要了解的话题自然就变成了js中异步编程的事件机制是什么,即:如何通过eventloop和taskqueue(事件队列)的配合来实现js的异步编程。在弄清楚为什么需要了解事件循环之后,就可以更进一步,对技术细节进行深入的讨论。首先要注意的是,js是单线程的,并不代表js所在的环境就是单线程的。比如node环境,浏览器环境。可以看出,在浏览器环境中,虽然解析js的V8引擎是单线程的,但是也有构建整个浏览器生态,执行异步任务的WebAPI。提供这些WebAPI的服务并不是单线程的,而是开启一个新的线程来提供相应的服务。接下来是Node平台:可以看出V8引擎部分是单线程的,但是libuv、OpenSSL等其他部分的调用不是单线程的。Node环境下事件循环的处理比浏览器更详细,所以我们现在把目光转向Node环境。js的异步任务的调用过程变成:js被V8解析。通过Nodejs绑定调用相应的服务,很大一部分围绕IO的异步任务统一由libuv接管。libuv将相应的异步任务转移到相应的工作线程中,用于异步任务的“实际执行”。异步任务执行完成后,加入到libuv的任务队列(事件队列)中,然后根据事件循环的执行规则,在合适的时候取出任务队列的完成任务并返回给V8做相应的回调任务(如果有回调任务的话)。整个异步调用的设计是基于发布者-订阅者模型。libuv中将不同的异步IO任务发布给不同的订阅者。每个订阅者在自己的线程中完成相应的任务后,会通知回发布者。这样一来,似乎就没有必要引入任务队列了。但这是一个微妙的问题。如果只有一两个订阅者通知发布者他们已经完成了他们的工作,那还好,但是如果有一系列的事件呢?而在某个时间点,有一堆事件同时要对发布者做回调处理?此时,这些订阅者就相当于一个个浏览器客户端,统一向发布者发出“我的工作完成了”的通知请求。这时,发布者就像一台服务器,要接受和处理来自订阅者的大量并发请求。如果你能理解上面的模型,那么使用队列整个数据结构就很明显了。因为在服务器端,无论是网络层接受TCP请求,还是应用层处理HTTP请求,都需要引入队列数据结构来完成。您必须通过队列控制并发数据包。更进一步,发布者向订阅者发送异步任务的消息并不困难。但是反过来,每个订阅者都不容易向发布者发送一个完成的通知,因为大量的订阅者会给作为服务器的发布者带来很大的压力。一个明显的优化方法是:可以启动一个服务,根据不同类型的异步IO任务进行处理。所以,在libuv中,你不仅会看到任务队列,还会看到不止一个任务队列!这样做的原因和后端处理不同任务的思路是一样的:启动不同的服务节点来处理不同的工作。那么,在libuv中,任务队列分为哪些不同的类别呢?可以参考下图:如图所示,粗略地说,libuv提供了4种任务队列(称为宏任务队列):TimersQueue[所有setTimeout和setInterval调度回调]IOCallbacksQueue[除了定时器,关闭所有这两个阶段的其他回调]CheckQueue[setImmediate()设置的回调]CloseCallbacksQueue[socket.该队列还分别配合另外两个微任务队列:NextTickQueue:是放置process.nextTick(callback)的回调任务OtherMicroQueue:在每个具体的宏任务队列执行完后,放置其他微任务,比如Promise(afterexecution是指队列中没有要执行的任务),上面说的两个微任务队列会立即依次执行(一个宏任务队列称为一个阶段)。同理,执行完一个微任务队列后,必须执行另一个微任务队列(该队列没有待处理的任务)。总结一下,上面的执行过程就是:"Timer"taskqueenexttickqueueothermicrotaskqueue"I/OCallback"taskqueenexttickqueueothermicrotaskqueue"Check"taskqueenexttickqueueothermicrotaskqueue"CloseCallback"taskqueuenexttickqueueother微任务队列...回到步骤1,循环继续处理接收到的异步任务“执行完成”消息。整个事件循环的过程不难理解。有了“执行完成”的提示,按照上面的流程一步步执行即可。但是这里我们似乎忽略了一个重要的事情:这些执行完成的消息是从哪里来的呢?即那些异步任务被相应的工作线程执行完后,如何通知libuv?或者,libuv怎么知道一个工作线程已经执行了呢?这些执行的消息是如何添加到任务队列中的呢?(因为毕竟如果没有完成的消息,任务队列永远是空的,整个进程也就没有可执行数据了。)这个进程其实就是在右下角不显眼的I/OPolling上图。我们可以对上面的EventLoopDiagram做更详细的区分。这里必须祭出Node官方文档中libuv的EventLoop流程图:可以看到,libuv做了更细致的区分,将4个大进程细分为6个部分。其中poll阶段特别标明“forpolling”是否有新的执行完成消息。上图是比较详细的划分以及与js的对应关系。timers阶段用于重点处理js中的setTimeout()和setInterval()事件。close阶段重点处理js中的.on('close',...)事件。check阶段重点处理js中setImmediate()事件的其他IO事件。IO事件分为两部分:pendingI/O阶段,用于处理延迟的IO回调。轮询阶段用于轮询是否有新完成的IO事件,并立即触发相应的回调。最后剩下的idle和prepare阶段只是辅助工作。这样,整个事件循环的运行过程和机制就清晰了。参考:EventLoopandtheBigPicture—NodeJSEventLoopPart1HandlingIO—NodeJSEventLoopPart4Node.js事件循环、定时器和process.nextTick()带你全面理解事件循环