理解Node.js中Event Loop(一)

理解Node.js中Event Loop(一)

本文翻译至:A Complete Visual Guide to Understanding the Node.js Event Loop

目录

JavaScript 中的异步编程

我们首先回顾一下 JavaScript 中的异步编程。尽管 JavaScript 在 Web、移动端和桌面应用程序中都有使用,但重要的是,就其最基本的形式而言,JavaScript 是一种同步、阻塞、单线程语言。让我们用一小段代码来理解这一行。

index.js
function A() {
  console.log("A");
}

function B() {
  console.log("B");
}

A()
B()

// Logs A and then B

如果我们有两个将变量打印到控制台的函数,则代码会自上而下执行,在任何给定时间内只执行一行。在代码片段中,我们看到 A 在 B 之前被打印。

JavaScript 会阻塞代码执行

JavaScript 由于其同步特性而具有阻塞性。无论前一个流程需要多长时间,在前一个流程完成之前,后续流程都不会开始。

在代码片段中,如果函数 A 需要执行大量代码,JavaScript 必须完成该代码块,而不会继续执行函数 B。即使该代码需要 10 秒或 1 分钟。

JavaScript 是单线程的

线程只是 JavaScript 程序用来运行任务的一个进程。每个线程一次只能执行一个任务。与其他一些支持多线程从而可以并行运行多个任务的语言不同,JavaScript 只有一个线程,称为主线程,用于执行任何代码。

等待 JavaScript

正如您可能已经猜到的那样,这种 JavaScript 模型会产生一个问题,因为我们需要等待数据获取,然后才能继续执行代码。此等待可能需要几秒钟,在此期间我们无法进一步运行任何代码。如果 JavaScript 不等待就继续运行,我们将遇到错误(没有获取到数据)。

我们需要一种方法能在 JavaScript 中具有异步行为。

Node.js Runtime(运行时)

Node.js 运行时是一个可以在浏览器之外使用和运行 JavaScript 程序的环境。 Node 运行时的核心由三个主要组件组成。

  • Node.js 运行所需的外部依赖项(例如 V8、libuv、crypto

  • 提供文件系统访问和网络等功能的 C++ 特性

  • 一个 JavaScript 库,提供能在 JavaScript 代码中使用 C++ 函数和utils工具函数的功能。

虽然所有的部分都很重要,但 Node.js 中异步编程的关键是外部依赖项 libuv

Libuv

Libuv 是一个用 C 语言编写的跨平台开源库。在Node.js运行时中,它的作用是为处理异步操作提供支持。让我们回顾一下它是如何工作的。

Node.js 运行时中的代码执行

让我们概念化一下,通常代码如何在 Node 运行时执行。当我们执行代码时,位于图像左侧的 V8 引擎会处理 JavaScript 代码的执行。该引擎包括Heap(内存堆)和Call Stack(调用执行栈)

每当我们声明变量或函数时,内存都会在堆上分配,每当我们执行代码时,函数都会被推入执行栈。当函数返回时,它会从执行栈中弹出。这是Heap数据结构的简单实现,其中最后添加的项目是第一个要删除的项目。在图像的右侧,我们有 libuv,它负责处理异步函数。

每当我们执行异步方法时,libuv 就会接管任务的执行。然后 Libuv 使用操作系统的本机异步机制运行该任务。如果本地机制不可用或不充分,它会利用其线程池来运行任务,确保主线程不会被阻塞。

同步代码执行

首先,我们来看一下同步代码执行。以下代码由三个Log语句组成,这些语句依次打印“First”、“Second”和“Third”。让我们像Node运行时执行代码一样浏览代码。

console.log("First");
console.log("Second");
console.log("Third");

下面是同步代码执行在 Node 运行时的可视化方式。

主线程始终在全局作用域内开始运行。全局函数global()被压入执行栈。然后,在第 1 行,我们有一个控制台日志语句。该函数被压入执行栈。假设这发生在 1 毫秒,“First”将打印到控制台。然后,该函数从执行栈中弹出。

执行到第 3 行。比方说,在 2ms 时,日志函数再次被推入执行栈。"Second"被打印到控制台,函数从执行栈中弹出。

最后,执行到第 5 行。3ms 时,函数被推入执行栈,"Third "被打印到控制台,函数从执行栈中弹出。没有更多代码要执行,全局也被弹出。

异步代码执行

接下来,我们看一下异步代码执行。考虑下面的代码片段。有三个日志语句,但这次第二个日志语句位于传递给 fs.readFile() 的回调函数内。

主线程始终在全局范围内启动。全局函数被压入执行栈。然后执行到第 1 行。在 1 毫秒时,控制台中会打印“First”,并且函数会从执行栈中弹出。然后执行移至第 3 行。在 2ms 时,readFile 方法被压入执行栈。由于 readFile 是一个异步操作,因此它被移交到 libuv。

大约 4 毫秒后,假设文件读取任务在线程池中完成。相关的回调函数现在在调用栈上执行。在回调函数中,会遇到日志打印语句() => console.log('Second')

它被推送到执行栈,“Second”被打印到控制台,并且日志函数被弹出。由于回调函数中没有更多的语句要执行,因此它也会被弹出。没有更多的代码可以运行,因此全局函数也会从堆栈中弹出。

控制台输出将显示“First”、“Third”,然后是“Second”。

Libuv 和异步操作

很明显,libuv 有助于处理 Node.js 中的异步操作。对于处理网络请求等异步操作,libuv 依赖于操作系统原语。对于异步操作(例如读取没有本机操作系统支持的文件),libuv 依赖其线程池来确保主线程不被阻塞。然而,这确实引发了一些问题。

  • 当异步任务在 libuv 中完成时,Node 如河决定什么时间在调用堆栈上执行关联的回调函数?

  • 在执行回调函数之前,Node 是等待调用堆栈清空,还是中断正常执行流程以运行回调函数

  • 其他异步方法(例如 setTimeout 和 setInterval)也会延迟回调函数的执行吗?

  • 如果两个异步任务(例如 setTimeout 和 readFile)同时完成,Node 如何决定在调用堆栈上首先执行哪个回调函数?其中一个优先于另一个?

所有这些问题都可以通过理解libuv的核心部分(即Event Loop)来回答。

什么是事件循环?

从技术上讲,事件循环只是一个 C 程序。但是,您可以将其视为一种设计模式,用于编排或协调 Node.js 中同步和异步代码的执行。

可视化事件循环

事件循环是一段只要 Node.js 应用程序正在运行,它就会持续运行的程序。每个循环中有六个不同的队列,每个队列保存一个或多个最终需要在调用堆栈上执行的回调函数

  • Timer队列(技术实现上是一个min-heap最小堆),它保存与 setTimeoutsetInterval 关联的回调。

  • I/O 队列包含与所有异步方法关联的回调,例如与 fshttp 模块关联的方法。

  • Check队列,用于保存与 setImmediate 函数相关的回调,这是 Node 特有的。

  • Close队列,它保存与异步任务的关闭事件相关的回调。

最后,还有包含两个独立的MicroTask(微任务)队列

  • nextTick 队列,其中包含与 process.nextTick 函数关联的回调。

  • Promise 队列,用于保存与 JavaScript 中的原生 Promise 关联的回调。

需要注意的是,Timer、I/O、Check和Close队列都是 libuv 的一部分。但是,两个微任务队列不是 libuv 的一部分

尽管如此,它们仍然是 Node 运行时的一部分,并在回调的执行顺序中发挥着重要作用。说到这里,接下来我们来了解一下。

事件循环如何工作

解释一下队列的优先级顺序。首先,要知道所有用户编写的同步 JavaScript 代码都优先于运行时想要执行的异步代码。这意味着只有在执行栈为空后Event Loop才会发挥作用

在事件循环中,执行顺序遵循一定的规则。有很多规则需要理解,所以让我们一次回顾一下它们:

  1. 执行微任务队列中的回调。首先执行 nextTick 队列中的任务,然后才执行 promise 队列中的任务

  2. 执行Timer队列中的所有回调。

  3. 再次执行微任务队列中的回调,即重复步骤1。

  4. 执行 I/O 队列中的所有回调。

  5. 重复步骤1。

  6. 执行Check队列中的所有回调。

  7. 重复步骤1。

  8. 执行Close队列中的所有回调。

  9. 最后,在同一个循环中执行微任务队列。即最后一次重复步骤1。

如果此时还有更多回调需要处理,则循环将继续运行一次,并重复相同的步骤。另一方面,如果所有回调都已执行并且没有更多代码要处理,则退出Event Loop。

这就是 libuv 的事件循环在 Node.js 中执行异步代码时所扮演的角色。记住这些规则,我们可以重新审视之前的问题。

  • Q: 当 libuv 中的异步任务完成时,Node 会在什么时候决定运行调用栈中的相关回调函数?

    A: 仅当调用堆栈为空时才会执行回调函数。

  • Q: Node 是等待执行栈为空才运行回调函数,还是中断正常执行流程来运行回调函数?

    A: 正常的执行流程不会因为回调函数的运行而中断。

  • Q: 其他异步方法(例如 setTimeout 和 setInterval)也会延迟回调函数的执行吗?

    A: setTimeout 和 setInterval 回调具有第一优先级。

  • Q: 如果两个异步任务(例如 setTimeout 和 readFile)同时完成,Node 如何决定在调用堆栈上首先运行哪个回调函数?其中一个优先于另一个吗?

    A: 即使Timer回调和 I/O 回调同时准备就绪,Timer回调也会在 I/O 回调之前执行。


“但是等等,验证此可视化规则的代码在哪里?”你可能会问。好吧,事件循环中的每个队列在执行中都有细微差别,因此最好一次处理一个队列。这篇文章是有关 Node.js 事件循环的一系列博客文章中的第一篇。请务必查看下面链接的其他部分。

此系列文章:

理解Node.js中Event Loop(一)

理解Node.js中Event Loop(二)

理解Node.js中Event Loop(三)

理解Node.js中Event Loop(四)

理解Node.js中Event Loop(五)

理解Node.js中Event Loop(六)

理解Node.js中Event Loop(七)