译者注:这是我看过最好的解释NodeJS事件循环的系列文章。点击查看原文(请自备梯子)
作为开篇第一章,作者非常详细认真甚至有点啰嗦地介绍了事件循环的基本工作流程,解释了libuv主要解决的问题,同时从应用层JavaScript的角度出发,将事件循环的所有阶段区分为lbuv原生的和NodeJS额外添加的(事实也是这样。很多时候我们并不知道需要区分这两者),我觉得有了这些基础,会更加容易理解事件循环的其余部分和细节。不管是新手还是资深NodeJS程序员,这都是一篇不可多得值得一读的文章。
NodeJS与其他编程平台的区别在于它如何处理I / O。我们经常听到NodeJS被称为“基于谷歌的v8 javascript引擎的非阻塞事件驱动平台”。什么意思?“非阻塞”和“事件驱动”是什么意思?所有这些答案都在NodeJS的事件循环的核心。 在本专题中,我将介绍什么是事件循环,它是如何工作的,它如何影响我们的应用程序,如何充分利用它以及更多。为什么是专题而不就一篇文章?那样的话,将会是一篇非常长的文章,我肯定会忽略某些地方,因此我将撰写一个关于NodeJS事件循环的专题。 在第一篇文章中,我将介绍NodeJS如何工作,如何访问I / O以及如何与不同的平台一起工作等。
本专题目录
- Event Loop and the Big Picture (本文)
- Timers, Immediates and Next Ticks
- Promises, Next-Ticks and Immediates
- Handling I / O
- Event Loop Best Practices
Reactor模式
NodeJS以事件驱动模型运行,涉及到Event Demultiplexer 和 Event Queue。所有I / O请求最终都会产生一个完成或失败的事情,或者其他触发器,这些即称为“事件”。这些事件按照以下算法进行处理。
- Event Demultiplexer 接收I / O请求并将这些请求委托给适当的硬件。
- 一旦I / O请求被处理(例如,来自文件的数据可被读取,来自套接字的数据可被读取等),Event Demultiplexer 将针对特定动作的已注册的回调添加到队列中等待处理。这些回调称为事件,添加事件的队列称为
事件队列
。 - 当事件可以在
事件队列
中处理时,它们按照它们接收的顺序依次执行,直到队列为空。 - 如果
事件队列
中没有事件,或 Event Demultiplexer 没有任何等待中的请求,则程序将完成。否则,该过程将从第一步继续下去。
编排整个机制的程序称为事件循环。
事件循环是一个单线程和半无限的循环。之所以叫半无限的循环,是因为当没有任务执行时,该循环实际上会停止。从开发人员的角度来看,这也是程序退出的地方。
注意:不要把事件循环和NodeJS Event Emitter 混淆。Event Emitter 与此机制完全不同。在后面的文章中,我将解释Event Emitter 如何通过事件循环影响事件处理过程。
上图是对NodeJS如何工作的抽象概括,同时展示了Reactor模式的主要组成部分。 但实际情况比这要复杂。那么这有多复杂?
Event Demultiplexer 不是一个可以在所有操作系统平台中执行所有类型I / O的单个组件。
事件队列
不是像这里显示的、所有类型的事件都在其中排队出队的单个队列。而I / O也不是唯一一种需要排队的事件类型。
所以,让我们深入挖掘。
Event Demultiplexer
Event Demultiplexer 不是现实世界中存在的组件,而是 Reactor 模式中的抽象概念。在现实世界中,Event Demultiplexer 已经在不同的系统中以不同的名称实现,例如Linux中的epoll,BSD系统中的kqueue(MacOS),Solaris中的事件端口,Windows中的IOCP(输入输出完成端口)等。而NodeJS利用底层非阻塞异步的硬件I / O功能。
文件I / O的复杂性
但令人困惑的是,并非所有类型的I / O都可以使用这些实现来执行。即使在同一个操作系统平台上,支持不同类型的I / O也很复杂。通常,使用这些epoll,kqueue,事件端口和IOCP可以以非阻塞的方式执行网络I / O,但是文件I / O要复杂得多。某些系统(如Linux)不支持文件系统访问的完全异步。MacOS系统中的文件系统事件通知和kqueue信号存在局限性(您可以在这里查看更多)。解决所有这些文件系统的复杂性以提供完全的异步是非常复杂,几乎不可能的。
DNS中的复杂性
与文件I / O类似,Node API提供的某些DNS功能也具有一定的复杂性。因为NodeJS的DNS功能(诸如dns.lookup
)需要访问系统配置文件(如nsswitch.conf
,resolv.conf
和/etc/hosts
),上述文件系统的复杂性也适用于dns.resolve
。
解决方案?
因此,为了支持那些不能由硬件异步I / O实用程序(如epoll、kqueue、event端口或IOCP)直接处理的I / O功能,引入了线程池。现在我们知道并非所有的I / O功能都发生在线程池中。NodeJS已经尽最大努力使用非阻塞和异步硬件I / O来完成大部分I / O,但对于阻塞或复杂的I / O类型,它使用线程池。
聚集在一起
正如我们所看到的,在现实世界中,在所有不同类型的操作系统平台中支持所有不同类型的I / O(文件I / O,网络I / O,DNS等)是非常困难的。一些I / O可以使用本机硬件实现来执行,保持完全异步,还有一些I / O类型在线程池中执行,以确保是异步的。
开发人员对Node的一个常见误解是Node在线程池中执行所有I / O。
为了在支持跨平台I / O的同时管理整个流程,应该有一个抽象层,它封装了这些平台间和平台内的复杂性,并为Node的上层公开了一个通用的API。
那么,谁呢?女士们,先生们,欢迎...。
从官方libuv文档中,
libuv是最初为NodeJS编写的跨平台支持库。它围绕事件驱动的异步I / O模型进行设计。
该库提供的不仅仅是对不同I / O轮询机制的简单抽象:'handles'和'streams'为套接字和其他实体提供了高级抽象, 还提供了跨平台的文件I / O和线程功能。
现在让我们看看libuv是如何组成的。下图来自官方的libuv文档,描述了在暴露广义API时如何处理不同类型的I / O。
现在我们知道 Event Demultiplexer 不是单个实体,而是由Libuv提取并暴露给NodeJS上层的处理I / O的API集合。它不仅是libuv为Node提供的 Event Demultiplexer。而且Libuv为NodeJS提供了整个事件循环功能,包括事件排队机制。
现在让我们看看事件队列
。
事件队列
事件队列
应该是一个数据结构,其中所有的事件都被顺序排列并由事件循环处理,直到队列为空。 但是这个过程在Node中的实际发生情况和 Reactor 模式描述的完全不同。那它有什么不同?
NodeJS中有多个队列,其中不同类型的事件在自己的队列中排队。
在处理完一个阶段后,在进入下一个阶段之前,事件循环将处理两个中间队列,直到中间队列清空。
那么有几个队列呢?中间队列是什么?
原生的libuv事件循环处理的队列有4种主要类型。
- 过期的定时器和间隔(timers and intervals)队列:由通过
setTimeout
和setInterval
添加的过期的定时器的回调。 - IO事件队列: 完成的IO事件
Immediates队列
:使用setImmediate
函数添加的回调close handlers队列
:任何close
事件处理。
请注意,尽管为了简单起见,我提到所有这些都是“ 队列 ”,但其中一些实际上是不同类型的数据结构(例如,定时器存储在最小堆中)
除了这4个主要队列之外,还有2个有趣的队列,我之前提到这些队列是“中间队列”并由Node处理。虽然这些队列不是libuv本身的一部分,但它们是NodeJS的一部分。他们是,
- Next Ticks队列:使用
process.nextTick
函数添加的回调 - Other Microtasks队列:包括其他 microtask,如 resolved promise回调
它是如何工作的? 如下图所示,Node通过检查定时器队列中的任何过期定时器来启动事件循环,在每个步骤中经过每个队列,同时维护一个引用计数器,表示要处理的总项目数。处理完close handlers队列
后,如果在任何队列中没有要处理的项目,则循环将退出。执行事件循环中的每个队列可以被视为事件循环的一个阶段。
红色描述的中间队列的有趣之处在于,只要一个阶段完成,事件循环就会检查这两个中间队列中的任何可执行项。如果中间队列中有任何项可执行,则事件循环将立即开始处理它们,直到两个直接队列被清空。一旦它们是空的,事件循环将继续到下一个阶段。
例如,事件循环当前正在处理具有5个
handler
的immediates队列
。同时,两个回调被添加到next tick队列
中。一旦事件循环完成了immediates队列
中的5个handler
,事件循环将检测到,在移动到close handlers队列
之前,有两个项目要在next tick队列
中处理。然后它将执行next tick队列
中的所有回调,然后再往前移动处理close handlers队列
。
Next tick队列 vs Other Microtasks
Next tick队列
比microtasks队列
具有更高的优先级。不过,它们都在事件循环的两个阶段之间进行处理,也就是在结束一个阶段后libuv通信回传到上层的时候【译者注:这里其实是NodeJS在libuv触发每个阶段执行的hook上注入了这个逻辑,详情可见作者的另一篇博客】。您会注意到,我已经以深红色显示Next tick队列
,这意味着在开始处理microtasks队列
中的 resolved promise之前,先清空Next tick队列
。
Next tick队列
优先于 resolved promise 仅适用于v8提供的原生JS promise。如果你正在使用像q
、bluebird
这样的库,你会观察到一个完全不同的结果,因为它们比原生 promise 早出现,而且具有不同的语义。
q
和bluebird
在处理 resolved promise 方面也有所不同,我将在稍后的文章中解释。
这些所谓的“中间”队列的惯例引入了一个新问题,即IO饥饿。使用process.nextTick
函数不断地填充Next tick队列
,将强制事件循环无限期地继续处理Next tick队列
,而不向前移动进入一个阶段。这将导致IO饥饿,因为如果不清空Next tick队列
,事件循环无法继续。
为了防止这种情况发生,以前可以设置
process.maxTickDepth
参数限制Next tick队列
,但是由于某种原因,它已经从NodeJS v0.12中删除。
我将在后面的帖子中用实例深入描述每个队列。
最后,现在您知道什么是事件循环,它是如何实现的以及Node如何处理异步I / O。现在我们来看看Libuv在NodeJS架构中的位置。
我希望这篇文章对你有帮助,在后面的文章中,我将阐述:
- timers,immediates和
process.nextTick
- resolved promise 和
process.nextTick
- I / O处理
- 事件循环的最佳实践
还有更多细节。如果有任何需要更正或添加的内容,请随时添加评论。
参考文献:
- NodeJS API文档https://nodejs.org/api
- NodeJS Github https://github.com/nodejs/node/
- Libuv官方文件http://docs.libuv.org/
- NodeJS设计模式https://www.packtpub.com/mapt/book/web-development/9781783287314
- 关于Node.js事件循环需要了解的一切,Bert Belder,IBM https://www.youtube.com/watch?v=PNa9OMajw9w
- Node的事件循环,Sam Roberts,IBM https://www.youtube.com/watch?v=P9csgxBgaZ8
- 异步磁盘I / O http://blog.libtorrent.org/2012/10/asynchronous-disk-io/
- JavaScript中的事件循环https://acemood.github.io/2016/02/01/event-loop-in-javascript/