Node.js 中的 Event Loop

published

预备知识

事件轮询的基本模型

事件轮询中的角色:

  • 事件队列:属于 FIFO 模型,一端推入事件,另一端取出事件。
  • 事件生产者:将事件推入事件队列。
  • 事件消费者:从事件队列取出事件并进行处理。
  • 工作线程:帮助事件消费者执行耗时的阻塞操作。

事件轮询的过程:

  1. 事件生产者将事件推入事件队列。
  2. 事件消费者不断查询队列中是否有事件,如果有事件,则立即处理事件。
  3. 事件消费者为了防止处理过程中的阻塞操作影响事件消费者继续处理事件,会委托工作线程来处理阻塞操作。
  4. 工作线程执行完阻塞操作后,会作为事件生产者生成事件,并将事件放入事件队列中。

同步 / 异步

同步和异步关注的是消息通信机制。应用在调用场景中时,可具像化为同步调用 / 异步调用,用来描述对调用结果的处理方法:

  • 同步调用:调用者在发出一个调用后,主动等待调用结果。在没有得到结果之前,该调用不返回;得到结果后,该调用才返回。
  • 异步调用:调用者在发出一个调用后,该调用直接返回,而不主动等待调用结果。被调用者通过某种手段通知调用者调用结果。

阻塞 / 非阻塞

阻塞和非阻塞描述的是进程 / 线程在等待调用结果(消息,返回值)时,进程 / 线程的状态。

  • 阻塞:调用发起后,得到调用结果之前,调用者会持续等待(此时,进程 / 线程进入阻塞状态),直到得到被调用者返回的结果。
  • 非阻塞:调用发起后,不等待被调用者返回结果(此时,进程 / 线程并未进入阻塞状态),而是被调用者通过某种手段通知调用者调用结果。

User Code

本文中特指 Node.js 平台使用者所编写的代码。

Node.js 整体架构

╔══════════════════════════════════════════════════════╗
║                     Node.js API                      ║
╚══════════════════════════════════════════════════════╝
╔══════════════════════════════════════════════════════╗
║ ┌───────────────────────────┐   ┌──────────────────┐ ║
║ │     Node.js Bindings      │   │  C/C++ Add-ons   │ ║
║ └───────────────────────────┘   └──────────────────┘ ║
╚══════════════════════════════════════════════════════╝
╔══════════════════════════════════════════════════════╗
║ ┌─────────┐ ┌─────────┐ ┌───────────┐ ┌────────────┐ ║
║ │         │ │         │ │           │ │http_parser │ ║
║ │   V8    │ │  libuv  │ │  C-ares   │ │  OpenSSL   │ ║
║ │         │ │         │ │           │ │    zlib    │ ║
║ │         │ │         │ │           │ │    ...     │ ║
║ └─────────┘ └─────────┘ └───────────┘ └────────────┘ ║
╚══════════════════════════════════════════════════════╝
  • Node.js API:内置标准库。具体实现位于 lib/
  • Node Bindings:封装 V8 和 libuv 的细节,向上层提供基础 API 服务,是实现 JavaScript 与底层 C/C++ 沟通的关键 —— JavaScript 通过 Node bindings 调用底层 C/C++。具体实现位于 src/node.cc
  • V8 / libuv / C-ares / …:Node.js 的核心,由 C/C++ 实现。具体实现位于 deps/
    • V8:Google 开发的 JavaScript 引擎,提供 JavaScript Runtime。
    • libuv:跨平台的异步 I/O 库,为上层的 Node.js 提供了统一的 API 调用,隐藏了底层实现,使其不用考虑平台差异。
    • C-ares:提供异步处理 DNS 的能力。
    • http_parser、OpenSSL、zlib 等:提供 HTTP 解析、SSL、数据压缩等其他方面的能力。
    • ……

Event Loop 的地位

「Node.js 的模型是单线程非阻塞 I/O 模型」,从中分解出两个关键词:

  • 单线程
  • 非阻塞 I/O

其中,单线程 指的是构建在 V8 JavaScript VM 上的 JavaScript Runtime 的单线程;非阻塞 I/O 指的是 libuv 中的 Event Loop 提供的非阻塞 I/O 能力。 可见,Event Loop 是 Node.js 的重要组成部分。

Event Loop 的基本结构

Event Loop 由 libuv 提供,是一个包含多个 Phase(阶段)的循环结构,其中:

  1. 大部分 Phase 都有一个对应自身的 FIFO 队列,其中包含当前 Phase 需要执行的回调函数。
  2. 当 Event Loop 执行到某一个 Phase 时,Event Loop 会执行当前 Phase 所对应的回调函数,直到回调函数耗尽或达到了回调函数执行量的上限。
  3. 回调函数耗尽或达到回调函数执行量的上限后,Event Loop 开始执行下一个 Phase。
  4. 当所有 Phase 被顺序执行一次后,称 Event Loop 完成了一个 Tick。

下面是一幅简化过的 Event Loop 的结构图,其中包含开发者需要了解的 Phase:

             [ Phases ]                   [ 队列 ]

——          每个方框代表一个 Phase        每个 | 代表一个待执行回调
|        ┌───────────────────────┐
|     ┌─>│        timers         │     | | | |
|     │  └──────────┬────────────┘
|     │  ┌──────────┴────────────┐
|     │  │     I/O callbacks     │     | |
|     │  └──────────┬────────────┘
|     │  ┌──────────┴────────────┐
      │  │     idle, prepare     │     | | |
Tick  │  └──────────┬────────────┘
      │  ┌──────────┴────────────┐
|     │  │         poll          │     |
|     │  └──────────┬────────────┘
|     │  ┌──────────┴────────────┐
|     │  │        check          │     | | | |
|     │  └──────────┬────────────┘
|     │  ┌──────────┴────────────┐
|     └──┤    close callbacks    │     |
——       └───────────────────────┘

各 Phase 队列所保存的内容:

  • timers:保存 setTimeout() / setInterval() 设置的回调函数。
  • I/O callbacks: 保存除 setTimeout() / setInterval() / setImmediate() / close callbacks 以外的所有回调函数。
  • poll: 该 Phase 为下一个 Tick 获取新的 I/O 事件
  • check: 保存 setImmediate() 设置的回调函数。
  • close callbacks: 保存为 close 事件注册的回调函数。

Event Loop 的运行过程

如果不特别说明,Event Loop 在各个 Phase 中,均执行到回调函数耗尽或达到回调函数执行数量上限为止。

Event Loop 基本上符合事件轮循的基本模型,但有更多需要了解的细节。Node.js 开始运行时:

  1. Node.js 依照 JavaScript 的 Run To Completion 原则从头到尾解释执行所有 User Code(可能会触发异步 API 调用、定时器、process.nextTick()等)。
  2. Node.js 初始化 Event Loop,准备运行 Event Loop。
  3. Node.js 检查是否有需要等待的异步 I/O 或定时器:
    • 如果没有,Node.js 结束自身进程。
    • 如果有,运行 Event Loop:
      1. 进入 timers Phase,执行 timers 队列中的回调函数。
      2. 进入 I/O callbacks Phase,执行 I/O callbacks 队列中的回调函数。
      3. 计算 poll Phase 阻塞时间:
        • timers 队列为非空,阻塞至最近一个定时器的执行时间。
        • timers 队列为空,阻塞时间为无穷。
      4. 进入 poll Phase:
        • 如果 poll 队列非空,执行 poll 队列中的回调函数。
        • 如果 poll 队列为空且 check 队列非空,进入 check Phase(跳转到 4)。
        • 如果 poll 队列为空且 check 队列为空,使用上一步计算的阻塞时间阻塞 Event Loop,等待回调函数被加入 poll 队列,然后执行它们。
      5. 进入 check Phase,执行 check 队列中的回调函数。
      6. 进入 close calbacks Phase,执行 close callbacks 队列中的回调函数。
      7. 开始新一轮 Loop。

参考