【译】NodeJS事件循环 Part 1

【译】NodeJS事件循环 Part 1

译者注:这是我看过最好的解释NodeJS事件循环的系列文章。点击查看原文(请自备梯子)

作为开篇第一章,作者非常详细认真甚至有点啰嗦地介绍了事件循环的基本工作流程,解释了libuv主要解决的问题,同时从应用层JavaScript的角度出发,将事件循环的所有阶段区分为lbuv原生的和NodeJS额外添加的(事实也是这样。很多时候我们并不知道需要区分这两者),我觉得有了这些基础,会更加容易理解事件循环的其余部分和细节。不管是新手还是资深NodeJS程序员,这都是一篇不可多得值得一读的文章。

NodeJS与其他编程平台的区别在于它如何处理I / O。我们经常听到NodeJS被称为“基于谷歌的v8 javascript引擎的非阻塞事件驱动平台”。什么意思?“非阻塞”和“事件驱动”是什么意思?所有这些答案都在NodeJS的事件循环的核心。 在本专题中,我将介绍什么是事件循环,它是如何工作的,它如何影响我们的应用程序,如何充分利用它以及更多。为什么是专题而不就一篇文章?那样的话,将会是一篇非常长的文章,我肯定会忽略某些地方,因此我将撰写一个关于NodeJS事件循环的专题。 在第一篇文章中,我将介绍NodeJS如何工作,如何访问I / O以及如何与不同的平台一起工作等。

本专题目录

Reactor模式

NodeJS以事件驱动模型运行,涉及到Event Demultiplexer 和 Event Queue。所有I / O请求最终都会产生一个完成或失败的事情,或者其他触发器,这些即称为“事件”。这些事件按照以下算法进行处理。

  1. Event Demultiplexer 接收I / O请求并将这些请求委托给适当的硬件。
  2. 一旦I / O请求被处理(例如,来自文件的数据可被读取,来自套接字的数据可被读取等),Event Demultiplexer 将针对特定动作的已注册的回调添加到队列中等待处理。这些回调称为事件,添加事件的队列称为事件队列
  3. 当事件可以在事件队列中处理时,它们按照它们接收的顺序依次执行,直到队列为空。
  4. 如果事件队列中没有事件,或 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.confresolv.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种主要类型。

  1. 过期的定时器和间隔(timers and intervals)队列:由通过setTimeoutsetInterval添加的过期的定时器的回调。
  2. IO事件队列: 完成的IO事件
  3. Immediates队列:使用setImmediate函数添加的回调
  4. 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。如果你正在使用像qbluebird这样的库,你会观察到一个完全不同的结果,因为它们比原生 promise 早出现,而且具有不同的语义。
qbluebird在处理 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处理
  • 事件循环的最佳实践

还有更多细节。如果有任何需要更正或添加的内容,请随时添加评论。


参考文献:

编辑于 2018-06-23 16:45