【译】NodeJS事件循环 Part 1
译者注:这是我看过最好的解释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
的立即队列
。同时,两个handler
被添加到next tick队列
中。一旦事件循环完成了immediate队列
中的5个handler
,事件循环将检测到,在移动到close handlers队列
之前,有两个项目要在next tick队列
中处理。然后它将执行next tick
队列中的所有`handler`,然后再往前移动处理close handlers队列
。
Next tick队列 vs Other Microtasks
Next tick队列
比Other 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架构中的位置。
NodeJS架构中的Libuv
我希望这篇文章对你有帮助,在后面的文章中,我将阐述:
- timers,immediate和
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/