js的event loop机制的讲解材料简直汗牛充栋,它同时也是面试中较为高频的考察区域。对于newbie来讲,一个急需解决的问题便是:为什么我需要关心event loop?event loop的机制涉及browser和nodejs的不同实现,涉及引入的各种task queue,涉及不同的phase和phase之间的micro-task。要吸收这样繁多而枯燥的信息,如果没有足够的motivation来做保障,我想是走不下去的。
众所周知,js是单线程的。但js之所以是单线程的,并不是因为在设计上有什么特殊的考虑,更多的可以归结为历史原因。当初js被急匆匆地设计出来,只是为了能够快速而轻量地形成一些脚本代码来同browser做交互。在那个时间点,创作者是万万没想到会有后来如此多的广泛和深度的应用。于是,js就沿着这条单线程之路走下去了。
由于js是单线程的,这意味着如果中途出现了一个非常耗时的计算任务或者等待资源的任务(IO交互),那么整个js world就停止了,也即是所谓的「假死」状态。因为干活的线程就这么一个,当它全心全意去做那个耗时任务时,别的任务自然也就无法得到执行了。所以基于这个特点,js必须使用异步的方式来处理各种耗时任务。事实上,我们在定义js的函数时,大部分涉及外部资源获取的任务,我们都会将其定义为异步任务。从这个角度讲,js中的很大一部分代码都是关于异步任务的代码。所以,理解“异步任务是如何被执行的”就成为了一个非常重要的课题。如果你对其执行机制不大了解,你就无法按照自己的意图去精准地规划和定义这些异步任务。
而异步任务在js中都是通过event的方式来实现的。所以你所要了解的课题,自然就变成了js中实现异步编程的event机制是什么,即:如何通过event loop和task queue(event queue)的配合实现了js的异步编程。
明确了为什么要了解event loop,就可以迈开步子进行技术细节的深入探讨了。
首先需要注意的问题是,js是单线程并不意味着js所寄宿的环境是单线程的。例如node的环境、browser的环境。
可以看到,在browser环境下,虽然解析js的V8引擎是单线程的,但是构建整个browser生态、执行异步任务的还有WebAPIs,提供这些WebAPIs的服务可不是单线程的,而是另起线程提供相应的服务。
再来是Node平台:
可以看到,V8引擎部分是单线程的,但诸如libuv、OpenSSL等其它部分的调用并不是单线程的。
Node环境在event loop的处理上比browser的处理要更为细致,所以我们现在把重点转向对node环境的关注。
js的异步任务的调用流程就变成:
- js经过V8做解析。
- 通过Nodejs bindings调用相应的服务,而围绕IO的一大部分异步任务统一由libuv来接管处理。
- libuv将相应的异步任务分别转给对应的worker threads做异步任务的“实际执行”。
- 异步任务执行完成后,加入到libuv的task queue(event queue),再根据event loop的执行规则在适当的时候取出task queue的完成任务回到V8做相应的callback任务(如果存在callback任务的话)。
整个异步调用的设计方式是基于publisher-subscriber模式。不同的异步IO任务在libuv中被publish到不同的subscriber中。每个subscriber在各自的线程中完成相应的任务后,再通知回publisher。
如此,似乎没有引入task queue的必要。但这是个微妙的问题。
如果只是一两个subscriber向publisher通知自己完成了工作还好,但是,如果有一系列的event呢?并且在某个特定时间点上,有一堆event同时想要向publisher做callback处理呢?此时,这些subscriber就相当于一个个的browser client,它们统一发送“我的工作做完了”的通知请求给publisher。此时的publisher就像是一个server端,要接受处理一大批subscriber的并发请求。
如果能理解上述模型,那么使用queue整个数据结构就是显然的事情了。因为在server端,无论是网络层接受TCP请求,又或是应用层来处理HTTP请求,都需要引入queue这个数据结构来完成。你必须通过queue来控制并发数据包。
更进一步思考,publisher把异步任务的消息发送给subscriber并不困难。但是反过来,各个subscriber向publisher发送已完成通知时就不大容易了,因为繁多的subscriber会对充当server的publisher造成很大的压力。一个显然的优化方式是:你可以根据异步IO任务的不同类别,来分别启动一个服务做处理。所以,在libuv中,你不仅会看到task queue,你还会看到不止一个task queue!而之所以这样做,其想法跟后端处理不同任务的想法一致:启动不同的服务节点来处理不同的工作。
那么,libuv中,分门别类地划分出了哪些task queue呢?可以参考下图:
如图所示,粗略来讲,libuv提供了4种类型的task queue(被称作macro task queue):
- Timers Queue【所有setTimeout和setInterval预定的callback】
- IO Callbacks Queue【除了timer、close这两个phase的所有其它callback】
- Check Queue【setImmediate()设定的callbacks】
- Close Callbacks Queue【socket.on('close', ....)这些callbacks】
不仅如此,这4大类别的task queue还要分别配合着另外两个micro task queue:
- Next Tick Queue:是放置process.nextTick(callback)的回调任务的
- Other Micro Queue:放置其他microtask,比如Promise等
每一个特定的macro task queue执行完(执行完的意思就是queue里没有待执行任务)后,会立刻去顺序执行上面提到的两个micro task queue(一个macro task queue被称作一个phase)。同样的,必须是一个micro task queue执行完毕(queue没有待执行任务)后再去执行另一个micro task queue。
归结起来,上述的执行过程是:
-
「Timer」task queue
- next tick queue
- other micro task queue
-
「I/O Callback」task queue
- next tick queue
- other micro task queue
-
「Check」task queue
- next tick queue
- other micro task queue
-
「Close Callback」task queue
- next tick queue
- other micro task queue
- ......返回第1步循环
如此往复不断地处理收到的异步任务“执行完毕”的消息。
整个event loop的流程不难理解,有了“执行完毕”的消息,只要按部就班地按照上面的流程执行就可以了。但我们这里似乎忽略了一个重要的事情:这些执行完毕的消息从哪里来?
也即是,那些异步任务被对应的worker threads执行完后,如何去通知libuv呢?又或者是,libuv如何来知晓某个worker thread已经执行完了呢?这些执行完毕的消息如何被加入到task queue的呢?(因为毕竟,如果没有执行完毕的消息,task queue就一直是空的,整个流程也就没有可执行的数据了。)
这个过程其实就在上图右下角的不起眼的 I/O Polling
中。我们可以对上述Event Loop Diagram做更细致的区分。这里就必须祭出Node官方文档中关于libuv的Event Loop流程图了:
可以看到,libuv做了更细致的区分,将4个大的过程细分为了6个部分。其中 poll
phase被特别标注“用于轮询”是否有新的执行完毕的消息进来。
上图是更细致的划分以及同js的关系对应。
- timers phase用于专注处理js中
setTimeout()
、setInterval()
事件。 - close phase专注于处理js中
.on('close', ...)
的事件。 - check phase专注于处理js中
setImmediate()
事件 - 其它IO事件。
而IO事件分为两部分:
- 用于处理被推迟的IO callback的pending I/O phase。
- 用于轮询是否有新完成了的IO事件的polling phase,并立刻触发相应的callback。
最后剩下的idle、prepare phase,不过是些辅助工作。
如此,整个event loop的运行流程和机制就清晰了。
Reference: