焉逢

事件循环总体概览 —— Node 事件循环 Part 1

原文链接: jsblog.insiderattack.net

Node 处理 I/O 的方式使得它与其他编程平台区分开来。“一个基于 Chrome V8 引擎、非阻塞(non-blocking)、事件驱动(event-driven)的平台”这样的介绍我们听过很多次了。非阻塞?事件驱动?这些都是什么意思呢?所有的这些问题都可以在 Node 的核心找到答案,即事件循环(Event Loop)。在这一系列文章中,我将给大家剖析什么是事件循环,它是怎么工作的,它如何影响到我们的应用,以及如何更好地利用它等等。为什么要用一系列文章呢?如果只写一篇的话,那内容太长了,我一定会漏掉某些东西的。因此我决定用一系列文章来回答这些问题。在第一篇文章中,我将介绍 Node 如何工作,如何访问 I/O,以及它是如何实现跨平台的等内容。

本系列文章指引

  • 事件循环总体概览 (本文)

  • 定时器, Immediates 和 Process.nextTick

  • Resolved Promises 和 Process.nextTick — 未完成

  • I/O 的处理 — 未完成

  • 处理事件循环的最佳实践 — 未完成

  • 编写异步组件(async Add-ons) — 未完成

反应器模式

Node 工作在以事件驱动的模型中,该模型涉及到了事件分离器(Event Demultiplexer)事件队列(Event Queue)。在 Node 中,所有的 I/O 请求最终都会产生一个完成或失败的状态,或其他触发动作,这些都被抽象为事件。这个处理过程可以抽象如下:

  1. 事件分离器接收到 I/O 请求,并将这些请求代理到相对应的请求处理器。

  2. 一旦这些 I/O 请求完成(比如一个文件的数据读取完成,可以使用了),事件分离器会将已注册的回调函数添加到待处理的队列中。这些回调函数可称为事件,所处的队列则称为事件队列

  3. 这些事件会按照它们排队的顺序依次被执行,直到队列为空。

  4. 如果事件队列中不再有事件,或者事件分离器没有任何后续的请求,则完成整个程序。否则将从第一步继续循环下去。

以上这整个处理机制就是事件循环

事件循环是单线程模式以及⎣半无限循环⎤的。之所以说它是半无限循环,是因为它实际上会在某个时间点退出。从开发者的角度来看,就是程序退出的时候。

注意:不要混淆事件循环和 Event Emitter ,这实际上是两个完全不同的概念。在后续的文章里,我会解释 Event Emitter 是如何影响事件循环的处理过程的。

上面的图示是对 Node 工作过程的一个抽象的概览,它显示了构成反应器模式的主要组件。但实际情况比这个要复杂得多,怎么说呢?

实际上,事件分离器并不是所有操作系统中处理所有 I/O 类型的单一组件。

事件队列也并不像图中描绘的那样,所有事件都排在同一个队列中。再者,I/O 并不是唯一的事件类型。

我们继续往下看。

事件分离器

事件分离器并不是一个真实存在的东西,它只是反应器模式中一个抽象的概念。而在实际中,事件分离器在不同的操作系统中都有实现,只不过名字不同罢了。比如 Linux 中的 epoll,BSD 系统(MacOS)中的 kqueue,Solaris 中的 event ports,以及 Windows 中的 IOCP(Input Output Completion Port) 等。Node 正是依靠了底层的这些实现提供的非阻塞、异步 I/O 的能力。

文件 I/O 的复杂性

事情的难处在于,并不是所有的 I/O 都可以靠这些实现(epoll, kqueue 等)来完成。即使在相同的操作系统平台上,想要支持不同类型的 I/O 也有其不可回避的复杂性。通常,上述实现能以非阻塞方式执行网络 I/O (network I/O),但相比之下,文件 I/O 要复杂得多。某些系统(如 Linux)并不支持完全异步的文件访问。而在 MacOS 中,kqueue 的文件系统事件通知(event notifications/signaling)有一些限制(可以在这里查看更多相关信息)。想要解决所有文件系统的这些问题以实现完全的异步功能非常复杂,几乎不可能完成。

DNS 的复杂性

与文件 I/O 类似,Node API 提供的某些 DNS 功能也有其复杂性。由于某些 DNS 功能(如 dns.lookup)会访问系统配置文件,如 nsswitch.confresolv.conf/etc/hosts,上述文件系统的复杂性同样会在 dns.resolve 里出现。

所以解决方案是?

线程池(thread pool)。引入线程池是为了支持那些不能被 epoll/kqueue/event port/IOCP 直接处理的功能。现在我们知道并不是所有的 I/O 功能都发生在线程池中。Node 已经尽它所能利用非阻塞和异步 IO 来完成部分 I/O 请求,而对于那些阻塞或复杂的 I/O 类型,则使用线程池来完成。

让我们稍微捊一下

正如我们所见,在不同的操作系统平台上,支持不同类型的 I/O (文件 I/O,网络 I/O,DNS 等)是非常困难的。有些 I/O 可以通过本机硬件实现(native hardware implementations)来执行,保持完全的异步。有些 I/O 则需要在线程池中执行,以此确保它的异步性。

开发者中有一个关于 Node 的常见误解就是,认为所有 I/O 都是通过线程池执行的。

为了管理整个流程,同时支持跨平台 I/O,应该有一个抽象层来封装这些平台间和平台内的复杂性,并为 Node 上层暴露通用的 API。

那么谁能担此大任呢?主角独角龙登场……

官方 libuv logo (https://github.com/libuv/libuv)

如 libuv 官方文档所说,

libuv 是最初为 Node 编写的跨平台支持库。它围绕事件驱动的异步 IO 模型进行设计。

该库对不同的 I/O 轮询(polling)机制提供的不仅仅是简单的抽象:handles 和 streams 为 sockets 和其他实体提供了高级抽象;此外还提供了跨平台文件 I/O 和线程功能。

我们来看看 libuv 是如何组成的。以下图示来自 libuv 官方文档,它描述了通过暴露通用的接口,不同类型的 I/O 是如何被处理的。

现在我们知道了,事件分离器并不是一个原子实体,而是一个由 libvu 抽象并暴露给 Node 上层的处理 I/O 的 API 的集合。不单单如此,libuv 还提供了整个事件循环功能,其中包括事件排队机制。

接下来我们来聊聊事件队列

事件队列

事件队列首先是一个队列数据结构,其中所有的事件都被按顺序地排入,并由事件循环处理,直到队列为空。但在 Node 中,这个过程和在反应器模式中描述的完全不同。不同在哪里呢?

在 Node 中,并不只有一个事件队列,不同类型的事件排在不同的队列中。

每处理完一个阶段(队列)的事件之后,在开始处理下一个阶段(队列)的事件之前,事件循环会处理两个中间队列,直至两个中间队列都为空。

那么一共有几个队列呢?中间队列又是指什么呢?

由 libuv 本身处理的队列有4种主要类型:

  • 定时器队列 —— 由通过 setTimeout 或 setInterval 设置的定时器回调组成

  • I/O 事件队列 —— 包含已经完成的 I/O 事件

  • Immediates 队列 —— 由 setImmediate 添加的回调

  • close 处理队列 —— 包含任何 close 事件处理

除了这 4 个主要队列之外,还有 2 个有趣的队列,就是我上面提到的“中间队列”。它们是:

  • nextTick 队列 — 由 process.nextTick 函数添加的回调

  • 其他 microtask 队列 — 包含其他类型的 microtask,比如已 resolve 的 promise 回调

这几个队列是如何工作的呢?

如下图所示,启动事件循环时,Node 开始检查定时器队列,并在每个步骤中遍历每个队列,同时保留着要处理的事件数量的引用计数器。在执行完 close 处理队列之后,如果在任何队列中都没有待处理的事件,事件循环将退出。可以将事件循环中的每个队列的处理视为事件循环的一个阶段。

我已经用红色标示出了两个中间队列,这里有趣的是,每当事件循环完成一个阶段的处理,它就会去检查这两个中间队列里面是否有可处理的事件。如果有的话,事件循环会直接处理它们,直至两个中间队列为空。然后事件循环接着处理下一个阶段。

比如说,事件循环当前正在处理 immediates 队列,此队列中有 5 个事件。与此同时,有 2 个事件被排进 nextTick 队列中。一旦事件循环处理完 immediates 队列中的 5 个事件,开始处理 close 队列之前,它发现在 nextTick 队列中有 2 个需要处理的事件。于是它先处理完 nextTick 队列中的这 2 个事件,接着才会前往下一个阶段(close)。

nextTick vs 其他 microtask

虽然同是在事件循环的两个阶段之间被执行,但 nextTick 队列比其他 microtask 队列有更高的优先级。你应该注意到我用暗红色给 nextTick 队列标识了这一点。事件循环会确保 nextTick 队列会比其他 microtask 队列先被处理完。

以上所说的优先级,只针对 V8 原生提供的 Promise 有效。如果你使用的是第三方库比如 q 或者 bluebird,那你会得到完全不同的结果。这是因为这些库早于原生 Promise 出现,有着不同的语义。

在后面的文章中我还会讲到,qbluebird 在处理 promise 时也有所不同。

这些所谓的“中间队列”的引入带来了一个新的问题,IO 饥饿(IO starvation)。不断地用 process.nextTick 填充 nextTick 队列将强制事件循环无限期地处理 nextTick 队列,从而无法前往下一个阶段。

为了防止这个情况,在以前,可以通过 process.maxTickDepth 参数设置 nextTick 队列的上限,但由于某些原因已经在 v0.12 中被删除了。

在后续的系列文章里面,我会用例子深入地讲解上面提到的每一个队列。

最后,相信你已经知道事件循环是什么,它是如何实现的以及 Node 是如何处理异步 I/O 的。现在我们一起来看一下在整个 Node 架构中,libuv 处于什么位置。

希望本文以及后续的文章对你有所帮助,我将跟你一起探讨以下内容:

  • 定时器, Immediates 和 process.nextTick

  • Resolved Promises 和 process.nextTick

  • I/O 的处理

  • 处理事件循环的最佳实践

以及更多细节。如果你发现了需要更正或者漏掉的内容,别犹豫,记得提醒我。

参考链接: