Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[译]事件循环总览—— Nodejs 事件循环 Part 1 #43

Open
zhangxiang958 opened this issue Feb 16, 2019 · 1 comment
Open

[译]事件循环总览—— Nodejs 事件循环 Part 1 #43

zhangxiang958 opened this issue Feb 16, 2019 · 1 comment

Comments

@zhangxiang958
Copy link
Owner

zhangxiang958 commented Feb 16, 2019

原文链接:https://jsblog.insiderattack.net/event-loop-and-the-big-picture-nodejs-event-loop-part-1-1cb67a182810
NodeJS 与其他语言编程平台就是它处理 I/O 的方式。我们一直以来听到关于 NodeJS 的介绍就是“基于谷歌 v8 javascript 引擎的非阻塞的,事件驱动的运行平台“。这是什么意思呢?什么是 “非阻塞” 和 “事件驱动” ?而这些问题的答案,尽在 NodeJS 的事件循环的核心中。在本系列文章中,我将会解释事件循环是什么,它是如何工作的,以及它是怎么在我们的应用程序中产生影响的和如何充分利用它等等。关于此文章,为什么选择编写系列文章而不是集中到一篇文章里面?这是因为集中到一篇文章中使文章篇幅太长进而导致我会忽略某些知识点,因此我打算编写成一个系列的文章。在第一篇文章中,我将会解释 NodeJS 是如何工作的,它是如何访问底层 I/O 的和它如何与其他不同的平台进行协作等等。

文章系列目录

Reactor 模式

NodeJS 以事件驱动的方式运行,而事件驱动包含了 Event Demultiplexer(事件多路分发器)和Event Queue(事件队列)两个部分。所有的 I/O 请求最终都会生成一个成功或者失败的事件,或者是其他类型的触发器,而这些统称为事件。这些事件都会遵循以下的算法来进行处理自身逻辑。

  1. Event Demultiplexer(事件多路分发器) 接收 I/O 请求并将这些请求委托给合适的硬件。
  2. 一旦这些 I/O 请求被处理(例如,来自某文件的数据已准备就绪可被读取,来自套接字(socket)的数据已准备就绪可被读取等等),Event Demultiplexer(事件多路分发器)针对某种特定的动作将已注册的回调处理函数添加到对应动作的队列中等待被执行,这些回调函数就是事件,而那些队列就是事件队列。
  3. 当那些事件即回调函数已经可以准备被执行的时候,它们会被按照添加到队列的先后次序一个个地顺序执行,直到整个事件队列中为空。
  4. 如果事件队列中没有事件或者 event Demultiplexer(事件分发器)中没有等待的请求,那么程序完成,否则将重新回到第一步开始处理逻辑。

协调编排以上整个处理过程的程序就是事件循环

事件循环是一个单线程与半无限循环的机制,之所以叫做半无限循环是因为事件循环在遇到没有任务需要执行的时候,会退出循环。在开发人员的视角来看,这就是程序退出的地方。

注:不要将事件循环与 NodeJS 的 Event Emitter 机制混淆。Event Emitter 与事件循环是完全不一样的概念。在系列的最后一篇文章,我将会解释 Event Emitter 是如何利用事件循环来处理事件回调的。

上面这个图是对 NodeJS 工作原理的抽象概述,并展示了 Reactor Pattern 设计模式的主要部分。但是实际机制比概述要复杂得多,那有多复杂呢?

Event demultiplexer (事件分发器)不是一个能够处理所有操作系统平台的所有 I/O 类型的单个组件。

事件队列并不是像上面展示的那样所有类型的事件都在同一个队列中被插入与取出。并且 I/O 也不是唯一一种需要排队的事件类型。

让我们一起来深入研究。

Event Demultiplexer

Event Demultiplexer(事件分发器)并不是一个真实存在的组件,而是一个 reactor 模式中一个抽象的概念。在现实中,event demultiplexer(事件分发器)在不同的操作系统中有着不同的实现并有不一样的名字例如在 Linux 系统中是 epoll, 在 BSD 系统(MacOS)中是 kqueue, 在 Solaris 系统中是 event ports, 在 Windows 系统中是 IOCP(Input Output Completion Port) 等等。NodeJS 则是利用了这些底层硬件实现提供的非阻塞异步的 I/O 能力。

Complexities in File I/O

但是令人困惑的是,并不是所有的 I/O 类型可以使用上面这些底层实现来执行。甚至在同一个操作系统中,支持不同类型的 I/O 类型操作都是非常复杂的。通常来说,网络 I/O 可以使用类似 epoll, kqueue, event port 和 IOCP 这些非阻塞方式来实现,但是文件 I/O 会更加复杂。在某些系统中,例如 Linux 系统不支持完全异步访问文件系统。并且在 MacOS 系统的文件系统中使用 kqueue 来实现事件的通知/信号也有诸多限制(你可以在这篇文章中阅读其复杂性)。因此兼容不同的操作系统的文件系统以提供完全异步的文件访问能力是非常复杂的。

Complexities in DNS

和文件 I/O 类似,Node API 提供的某些 DNS 函数也有某些兼容性问题。因为 NodeJS DNS 函数例如 dns.lookup 需要访问系统的配置文件例如 nsswitch.conf, resolve.conf/etc/hosts ,上面所说的文件系统的复杂性也同样体现在 dns.resolve 上。

The solution ?

因此,为了提供访问 I/O 的能力而又不能直接使用底层异步 I/O 函数例如 epoll/kqueue/event port/IOCP 来实现的情况下,我们需要引入线程池。现在我们知道不是所有的 I/O 函数都发生在线程池中。NodeJS 充分利用了底层的异步非阻塞 I/O 函数,但是对于那些阻塞的或处理逻辑复杂的 I/O 类型将会使用线程池来实现。

Gathering All Together

正如我们看到的,在现实中很难基于所有不同类型的操作系统来支持所有不同类型的 I/O(文件 I/O, 网络 I/O, DNS 等等)。一些 I/O 可以使用原生的底层函数来实现,并保持完全异步,而某些 I/O 类型为了确保函数的异步性使用了线程池来实现。

开发人员对于 Node 一个常见的误解就是 Node 将所有的 I/O 类型都在线程池中执行。

为了处理整个流程并支持跨平台 I/O,我们需要一个抽象层来封装各个平台间的与平台内部的复杂操作,并暴露一些通用的 API 供 Node 上层进行调用。

谁比较适合做这些工作呢?各位,欢迎...

libuv 的官方文档来看,

libuv 是最初为了 NodeJs 编写的跨平台支持库。它围绕事件驱动异步 I/O 模型来进行设计。

这个库基于不同的 I/O 轮训机制提供了一个非常简单的抽象层: ‘handles’ 和 'stream' 提供了对 socket 与其他实体的抽象;此外还提供了跨平台文件 I/O 和线程能力等等。

现在我们一起看一下 libuv 的组成部分。下面这个图是来自 libuv 的官网文档的,它描述了 libuv 是如何处理不同类型的 I/O 并暴露出一个通用的 API 的。

现在我们知道 Event Demultiplexer(事件分发器)并不是一个单个实体,而是一个利用 libuv 封装处理 I/O 来给上层调用的一个 API 集合。

libuv 对于 Node 来说它不仅是一个 event demultiplexer(事件分发器),它还提供了包括事件队列排队机制的整个事件循环的核心功能。

我们现在一起看一下事件队列

Event Queue

事件循环被认为是一种数据结构,它能够让所有事件在其中排队和被顺序处理直到整个队列为空。但是实际在 Node 中情况和 reactor 模式中描述的完全不一样,有什么不同呢?

在 NodeJS 中不止有一个队列,不同的事件类型会在其自己的类型的队列中排队。

在处理完一个阶段之后,下一个阶段开始之前,事件循环会处理两个中间队列直到这些中间队列为空

所以到底有多少个队列呢?而所谓的中间队列又是什么呢?

原生的 libuv 事件循环队列会有 4 种主要类型。

  • 到了规定时间被压入的定时器与等时间间隔定时器队列——它们是由使用 setTimeout 过了一定时间间隔后添加到队列的回调和使用 setInterval 添加到队列的回调组成。
  • IO 事件队列——已准备就绪的 IO 事件。
  • Immediates 队列——使用 setImmediate 添加的回调函数。
  • Close Handlers 队列——任意 close 事件的回调。

请注意,为了简单说明,我上面所说的都是 "队列",它们其中有一些实际上是不一样的数据结构类型(例如,定时器会被存放在最小堆类型的数据结构中)

除了这四种主要类型的队列,还有两种就是上面我所说被 Node 处理的 “中间队列”。虽然它们不是 libuv 本身的一部分,但是却是 NodeJS 的一部分,它们就是,

  • Next Ticks 队列——使用 process.nextTick 添加的回调函数队列。
  • 其他 Microtasks 任务队列——包括其他的 microtaks 队列例如 resolved 的 Promise 回调。

How does it work?

正如你下面看到的这个图,Node 从检查定时器队列中有没有到期的定时器回调函数开始,随后在每个步骤中检查其他所有队列,并维护一个引用计数器来记录需要被处理的项目总数。在处理完 close 事件队列之后,如果在所有队列中都没有需要被处理的项目,那么事件循环将会退出。事件循环中的每个队列的处理可以看作是事件循环的一个阶段。

对于使用红色标红的中间队列来说,有趣的在于,只要一个阶段完成之后,事件循环会去检查那两个中间队列是否有可执行的项目。如果有,那么事件循环将会立刻开始处理这两个中间队列的项目直到队列为空。当它们为空,事件循环才会继续下一个阶段的处理。

例如,事件循环目前在处理 immediates 队列,队列中有 5 个回调函数需要被执行,同时,有两个函数被添加到了 next tick 队列中,当 immediates 队列的 5 个函数被执行后,事件循环在移动到 close handlers 队列之前,会立刻检测到在 next tick 队列中有待处理的程序,事件循环会执行 next tick 队列的所有回调,然后才继续处理 close handlers 队列的回调。

注:这里译者按照自己的理解,提供一个 Demo 代码:

setImmediate(() => {
    console.log('immediate 1');
    process.nextTick(() => {
        console.log('next tick 1');
    });
    process.nextTick(() => {
        console.log('next tick 2');
    });
    setTimeout(() => {
        console.log('timeout 1');
    });
});

setImmediate(() => {
    console.log('immediate 2');
});

setImmediate(() => {
    console.log('immediate 3');
});

setImmediate(() => {
    console.log('immediate 4');
});

setImmediate(() => {
    console.log('immediate 5');
});

// 使用 Mac Node v10.8.0 执行
immediate 1
immediate 2
immediate 3
immediate 4
immediate 5
next tick 1
next tick 2
timeout 1

Next tick queue vs Other Microtasks

Next tick 队列比其他 micro tasks 队列有更高的优先级。不过,它们都是在事件循环的两个阶段之间被处理,libuv 会在每个事件阶段结束后将通信回传给 Node 上层抽象。你会注意到上面的图中已被我使用深红色来标注 next tick 队列,这意味着 next tick 队列中的函数会比 resolved promise 优先处理。

next tick 队列的优先级只是适用于原生的 v8 提供的 Js promise。如果你使用像 qbluebird 这样的库,你将会观察到完全不一样的结果,因为它们比原生的 promise 要早出现并有不同的语义。

qbluebird 在处理 resolved promises 的时候使用的方式也有所不同,我将会在后续的文章中讲解。

这些所谓的中间队列机制带来了新的问题,IO 饿死问题。不断地使用 process.nextTick 函数将回调函数添加到 next tick 队列里面将会逼迫事件循环不断地处理 next tick 队列中的函数而迟迟不进入下一个阶段。这样会引起 IO 饿死问题因为 next tick 队列始终不为空导致事件循环停留在某一阶段。

为了解决这个问题,我们可以通过设置 process.maxTickDepth 这个参数来限制 next tick 队列中回调函数的最大数量。但是这个参数已经因为某些原因在 NodeJs v0.12 之后被移除了

我在后面的文章中将会深入讲解每个队列的知识点。

最后,现在你已经知道了什么是事件循环,它是如何实现的,并且 Node 是如何处理异步 I/O 的。一起来看一下 Libuv 在 NodeJs 的结构中处于什么位置。

我希望你能够在读完本文章后有所收获,后面的文章,我将会讲解:

  • 定时器,Immediates 和 process.nextTick
  • Resolved Promises 和 process.nextTick
  • I/O 处理
  • 事件循环的最佳实践

和关于它们的更多细节。如有错误或需添加的内容请随时在评论区添加你的见解。

References:
@ZhangDianPeng
Copy link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants