【一】理解Node.js事件循环


你已经使用 Node.js 一段时间了。你已经构建了一些应用程序,尝试了不同的模块,甚至对异步编程感到很舒适。但有件事一直在困扰着你 —— 事件循环

如果你像我一样,你已经花了无数个小时阅读文档和观看视频,试图理解事件循环。但即使作为一名经验丰富的开发者,要完全理解它的工作原理也很困难。

JavaScript 中的异步编程

我们将从 JavaScript 中的异步编程开始。尽管 JavaScript web、移动和桌面应用程序中被广泛使用,但重要的是要记住,JavaScript 在其最基本的形式中是一种同步、阻塞、单线程的语言。让我们用一小段代码来理解这句话。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// index.js

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

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

A()
B()

// Logs A and then B

JavaScript 是同步的

如果我们有两个函数将消息记录到控制台,代码将自上而下执行,任何时候只有一行在执行。在代码片段中,我们看到AB之前被执行。

JavaScript 是阻塞的

JavaScript是阻塞的,因为它是同步的。无论前一个过程花费多长时间,后续的过程都不会启动,直到前一个过程完成。在代码片段中,如果函数 A 必须执行一大块密集的代码,JavaScript 必须在执行完毕之前完成它,而不会转到函数 B。即使那段代码需要花费 10 秒或 1 分钟的时间。

你可能在浏览器中经历过这种情况。当 web 应用程序在浏览器中运行并且执行了一大块密集的代码而没有将控制返回给浏览器时,浏览器可能会出现冻结的情况。这就是所谓的阻塞。浏览器被阻止继续处理用户输入和执行其他任务,直到 web 应用程序将处理器的控制权返回。

JavaScript 是单线程的

线程就是你的 JavaScript 程序可以用来运行任务的过程。每个线程一次只能做一件事情。不像一些其他支持多线程的语言可以同时运行多个任务,JavaScript 只有一个线程,称为主线程,用于执行任何代码。

等待 JavaScript

正如你可能猜到的那样,这种JavaScript模型会产生问题,因为我们必须等待数据被获取,然后才能继续执行代码。这个等待可能需要几秒钟,在此期间我们无法运行任何进一步的代码。如果 JavaScript 在等待过程中继续执行,我们将会遇到错误。我们需要一种方法来在JavaScript中实现异步行为。这时候就引入了 Node.js

Node.js 运行时

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

    1. 外部依赖项 —— 例如 V8libuvcrypto —— Node.js 所需的用于其运行的外部依赖项。
    1. 提供文件系统访问和网络功能等功能的 C++ 特性。
    1. 一个 JavaScript 库,提供函数和实用程序,以便从您的 JavaScript 代码中利用 C++ 特性。

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

Libuv

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

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

图片显示左侧代表 V8 引擎的矩形块,右侧代表libuv的矩形块
让我们来概括一下代码在 Node 运行时中的典型执行方式。当我们执行代码时,位于图像左侧的 V8 引擎负责执行 JavaScript 代码。该引擎包括内存堆和调用堆栈。

无论我们声明变量还是函数,都会在堆上分配内存,无论何时执行代码,函数都会被推送到调用堆栈中。当函数返回时,它就会从调用堆栈中弹出。这是堆栈数据结构的一个简单实现,其中最后添加的项目是第一个被移除的。在图像右侧,我们有 libuv,它负责处理异步方法。

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

同步代码执行

首先,让我们来看看同步代码执行。以下代码包含三个控制台日志语句,分别记录 “First”、”Second” 和 “Third”。让我们像运行时正在执行一样来看代码。

1
2
3
4
// index.js
console.log("First");
console.log("Second");
console.log("Third");

以下是使用 Node 运行时可视化同步代码执行的方式。

执行的主线程总是从全局范围开始。全局函数,如果我们可以这样称呼它,被推送到堆栈上。然后,在第 1 行,我们有一个控制台日志语句。函数被推送到堆栈上。假设这发生在 1 毫秒时,"First" 被记录到控制台。然后,函数从堆栈中弹出。

执行到达第 3 行。假设在 2 毫秒时,日志函数再次被推送到堆栈上。"Second" 被记录到控制台,然后函数从堆栈中弹出。

最后,执行到达第 5 行。在 3 毫秒时,函数被推送到堆栈上,"Third" 被记录到控制台,然后函数从堆栈中弹出。没有更多的代码要执行,全局范围也被弹出。

异步代码执行

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

执行的主线程总是从全局范围开始。全局函数被推送到堆栈上。然后,执行来到第 1 行。在 1 毫秒时,"First" 在控制台中被记录,然后函数从堆栈中弹出。然后,执行继续到第 3 行。在 2 毫秒时,readFile 方法被推送到堆栈上。由于readFile是一个异步操作,它被转移到 libuv。

JavaScript 从调用堆栈中弹出 readFile 方法,因为就第 3 行的执行而言,它的任务已经完成。在后台,libuv 开始在单独的线程上读取文件内容。在 3 毫秒时,JavaScript 继续到第 7 行,将log函数推送到堆栈上,”Third” 被记录到控制台,然后函数从堆栈中弹出。

大约在 4 毫秒时,假设文件读取任务在线程池中完成。现在,相关的回调函数在调用堆栈上执行。在回调函数中,遇到了 log语句。

它被推送到调用堆栈上,"Second" 被记录到控制台,然后 log 函数被弹出。由于在回调函数中没有更多的语句要执行,回调函数也被弹出。没有更多的代码可运行,所以全局函数也被从堆栈中弹出。

控制台输出将会是 "First""Third",然后是 "Second"

Libuv 和异步操作

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

  • libuv 中的异步任务完成时,Node 在什么时候决定在调用堆栈上运行相关的回调函数?
  • Node 是否等待调用堆栈为空才运行回调函数,还是打断正常的执行流程来运行回调函数?
  • 其他异步方法如 setTimeoutsetInterval,也会延迟执行回调函数,那它们呢?
  • 如果两个异步任务,比如 setTimeout readFile,同时完成,Node 如何决定在调用堆栈上首先运行哪个回调函数?是否有一个比另一个更优先?
    所有这些问题都可以通过理解 libuv 的核心部分 —— 事件循环来回答。

什么是事件循环?

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

事件循环视图展示

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

事件循环由 6 个不同队列组成。

  • 首先,有定时器队列(技术上是一个最小堆),其中保存与 setTimeoutsetInterval 相关的回调。
  • 其次,有 I/O 队列,其中包含与所有异步方法相关的回调,比如与 fs http 模块相关的方法。
  • 第三,有检查队列,其中保存与 setImmediate 函数相关的回调,这是特定于 Node 的函数。
  • 第四,有关闭队列,其中保存与异步任务的关闭事件相关的回调。

最后,有微任务队列,其中包含两个单独的队列。

  • nextTick 队列,其中保存与 process.nextTick 函数相关的回调。
  • Promise 队列,其中保存与 JavaScript 中的原生Promise相关的回调。

需要注意的是,定时器、I/O、检查和关闭队列都是 libuv 的一部分。然而,两个微任务队列不是 libuv 的一部分。尽管如此,它们仍然是 Node 运行时的一部分,并且在回调执行顺序中起着重要作用。说到这一点,让我们继续理解下一步。

事件循环的工作原理

箭头已经给了提示,但很容易让人感到困惑。让我解释一下队列的优先顺序。首先,要知道所有用户编写的同步 JavaScript代码优先于运行时希望执行的异步代码。这意味着只有在调用堆栈为空时,事件循环才会发挥作用。

  • 在事件循环中,执行顺序遵循某些规则。有很多规则需要理解,所以让我们逐一来看看:
  • 微任务队列中的任何回调都会被执行。首先是 nextTick 队列中的任务,然后是 promise 队列中的任务。
    所有定时器队列中的回调都会被执行。
  • 微任务队列中的回调(如果存在)会在定时器队列中的每个回调后被执行。首先是 nextTick 队列中的任务,然后是 promise 队列中的任务。
  • 所有I/O队列中的回调都会被执行。
  • 微任务队列中的回调(如果存在)会被执行,从 nextTick 队列开始,然后是 Promise 队列。
  • 所有检查队列中的回调都会被执行。
  • 微任务队列中的回调(如果存在)会在检查队列中的每个回调后被执行。首先是 nextTick 队列中的任务,然后是 promise 队列中的任务。
  • 所有关闭队列中的回调都会被执行。
  • 在同一循环中,最后一次执行微任务队列。首先是 nextTick 队列中的任务,然后是promise队列中的任务。

如果在此时还有更多的回调需要处理,循环将被保持活动状态进行另一次运行,并重复相同的步骤。另一方面,如果所有回调都已执行,并且没有更多的代码需要处理,则事件循环退出。

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

  • 当 libuv 中的异步任务完成时,Node 何时决定在调用堆栈上运行相关的回调函数?
    只有在调用堆栈为空时才会执行回调函数。

  • Node 是否等待调用堆栈为空才运行回调函数,还是打断正常的执行流程来运行回调函数?
    不会打断正常的执行流程来运行回调函数。

  • 其他异步方法如 setTimeoutsetInterval 如何处理,它们也会延迟执行回调函数?
    setTimeoutsetInterval 的回调函数被优先考虑。

  • 如果两个异步任务,例如 setTimeout readFile,在同一时间完成,Node 如何决定在调用堆栈上先运行哪个回调函数?是否有一个比另一个更优先?
    定时器回调函数在 I/O 回调函数之前执行,即使两者在完全相同的时间准备就绪。

我们学到了更多,但下面这个可视化表示(与上面相同)是我希望你将其牢记在心的,因为它展示了 Node.js 在幕后执行异步代码的方式。

“但等等,验证这个可视化的代码在哪里?”你可能会问。好吧,事件循环中的每个队列在执行上都有细微差别,因此最好一次处理一个。

结论

事件循环是 Node.js 的基本部分,通过确保主线程不被阻塞来实现异步编程。理解事件循环的工作原理可能会有挑战,但对于构建性能优异的应用程序至关重要。

文章涵盖了 JavaScript 中异步编程的基础知识、Node.js 运行时以及负责处理异步操作的 libuv。有了这些知识,您可以构建事件循环的强大思维模型,这将帮助您编写利用 Node.js 异步特性的代码。