掘金 后端 ( ) • 2024-03-20 09:10

theme: cyanosis

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

你已经用 Node.js 工作了一段时间。你可能已经用它构建了一些应用程序,尝试了它不同的模块,甚至已经习惯了异步编程。但有一些东西一直在困扰着你——事件循环。

如果你像我一样,花费了无数个小时来阅读文档和观看视频,试图理解时间循环。但即使是一个资深开发也很难全面的了解这一切是如何工作的。所以我想通过可视化的方式帮助大家充分理解 Node.js 时间循环。坐下来,让我们深入了解 Node.js 事件循环的世界。

JavaScript 异步编程

我们先从复习 JavaScript 中的异步编程开始。尽管 JavaScriptWeb、移动和桌面应用程序中都有使用,但重要的是,JavaScript 是一种同步、阻塞、单线程语言。下面让我们用一小段代码来理解这一行:

// index.js

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

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

A()
B()

// Logs A and then B

JavaScript 是同步的

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

JavaScript 是阻塞的

JavaScript 由于其同步特性而阻塞。无论前一个程序需要多长时间,在前一个程序完成之前,后续程序都不会开始。在代码片段中,如果函数 A 必须执行大量代码,则 JavaScript 必须完成该操作,在这之前不会执行函数 B。即使 A 函数需要 10 秒或 1 分钟。

我们可能在浏览器中遇到过这种情况。当 Web 应用在浏览器中运行时,它执行大量代码而不将控制权返回给浏览器,则浏览器可能看起来处于卡顿状态,这称为阻塞。浏览器被阻止继续处理用户输入和执行其他任务,直到 Web 应用拿到对处理器的控制权。

JavaScript 是单线程的

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

The Node.js Runtime

assets_YJIGb4i01jvw0SRdL5Bt_0adb9612e27a439e8bbe2a65b861bbb3.webp Node.js 运行时是一个环境,它可以使你在浏览器外部运行 JavaScript 程序。Node 运行时的核心由三个主要组件组成。

  • 外部依赖项(如 V8libuvcrypto等)Node.js 运行所需的外部依赖项
  • 提供文件系统访问和网络等功能的 C++ 功能
  • JavaScript 库,提供函数和实用工具,用于执行 JavaScript 代码中的 C++ 功能

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

libuv

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

Node.js 运行时的代码执行

assets_YJIGb4i01jvw0SRdL5Bt_83b4388889874aa19042402856ed3883.webp

让我们了解一下代码通常如何在 Node 运行时中执行。当我们执行代码时,位于图像左侧的 V8 引擎处理 JavaScript 代码的执行。该引擎由内存堆和调用栈组成。

每当我们声明变量或函数时,内存都会在堆上分配;每当我们执行代码时,函数都会被推送到调用栈中。当函数返回时,它会从调用栈中弹出。这是栈数据结构的简单实现,简单来说就是后进先出。在图像的右侧,我们有 libuv,它负责处理异步方法。

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

同步代码执行过程

首先,让我们看一下同步代码的执行过程。以下代码由三个打印控制台日志语句组成,它们依次打印 “First”、“Second”和“Third”

// index.js
console.log("First");
console.log("Second");
console.log("Third");

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

assets_YJIGb4i01jvw0SRdL5Bt_c512f58d54b8433490a319bf283cf842_compressed.gif

执行的主线程始终从全局范围开始。全局函数就会被推送到栈上。然后,在第 1 行,我们有一个控制台日志语句。该函数被推送到栈上。假设这种情况发生在 1 毫秒后,则“First”将记录到控制台。然后,该函数从栈中弹出。

执行第 3 行。假设在 2 毫秒时,log 函数再次推送到栈上。“Second”被记录到控制台,并且该函数将从栈中弹出。

最后,执行在第 5 行。在 3 毫秒时,该函数被推送到栈上,“Third”被记录到控制台,并且该函数被从栈中弹出。没有更多的代码要执行,global也被弹出。

异步代码执行过程

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

assets_YJIGb4i01jvw0SRdL5Bt_d5131502a8e84dfcab11f1c3820101b7_compressed.gif

执行的主线程始终从全局范围开始。全局函数被推送到栈上。然后执行第 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)也会延迟回调函数的执行吗?
  • 如果 setTimeoutreadFile 等两个异步任务同时完成,Node 如何决定在调用堆栈上先运行哪个回调函数?哪一个优先于另一个吗?

所有这些问题都可以通过理解 libuv 的核心部分来回答,即事件循环。

事件循环

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

事件循环可视化

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

image.png

  • 首先是 timer queue (计时器队列,技术实现上是一个最小堆),它保存与 setTimeoutsetInterval 关联的回调。
  • 其次,有一个 I/O queue,其中包含与所有异步方法关联的回调,例如与 fshttp 模块关联的方法。
  • 第三,有一个 check queue(检查队列),它保存与 setImmediate 函数关联的回调,该函数只用 Node 有。
  • 第四,有一个 close queue(关闭队列),它保存与异步任务关闭事件相关的回调。

最后,还有包含两个 microtask queue(微任务队列)。

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

需要注意的是,timer queueI/O queuecheck queueclose queue 都是 libuv 的一部分。但这两个 microtask queue 不是 libuv 的一部分。它们属于 Node 运行时的一部分,在回调的执行顺序中发挥着重要作用。说到这里,接下来让我们来了解一下他们的功能。

事件循环是怎样工作的

箭头函数,但很容易混淆。让我解释一下队列的优先级顺序。首先,要知道所有用户编写的同步 JavaScript 代码都优先于运行时要执行的异步代码。这意味着只有在调用栈为空后,事件循环才会发挥作用。

在事件循环中,执行顺序遵循特定的规则。有很多规则需要你注意,让我们看一下执行顺序:

  1. microtask queue 中的回调函数都会首先执行。先是 nextTick queue 中的任务,然后是 promise queue 中的任务。
  2. 执行 timer queue 中的所有回调函数。
  3. 如果 microtask queue 中存在回调函数,则执行 microtask queue 中的函数。先是 nextTick queue 中的任务,然后是 promise queue 中的任务。
  4. 执行 I/O queue 中的所有回调函数。
  5. 如果 microtask queue 中存在回调函数,则执行 microtask queue 中的函数。先是 nextTick queue 中的任务,然后是 promise queue 中的任务。
  6. 执行 check queue 中的所有回调函数。
  7. 如果 microtask queue 中存在回调函数,则执行 microtask queue 中的函数。先是 nextTick queue 中的任务,然后是 promise queue 中的任务。
  8. 执行 close queue 中的所有回调。
  9. 在同一循环中最后一次执行 microtask queue。先是 nextTick queue 中的任务,然后是 promise queue 中的任务。

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

这就是 libuv 的事件循环在 Node.js 中执行异步代码时所扮演的角色。我们了解了执行过程,我们可以重新尝试解答之前的问题。

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

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

Node 是等待调用堆栈为空后再运行回调函数,还是中断正常的执行流程来运行回调函数?

运行回调函数不会中断正常的执行流程。

其他异步方法(如 setTimeoutsetInterval)也会延迟回调函数的执行吗?

运行回调函数不会中断正常的执行流程。

如果 setTimeoutreadFile 等两个异步任务同时完成,Node 如何决定在调用堆栈上先运行哪个回调函数?哪一个优先于另一个吗?

timer queueI/O queue 之前执行,即使两者同时准备就绪。

我们学习了很多东西,希望你记住下面的这个图(与上面相同),因为它展示了 Node.js 如何在后台执行异步代码。

image.png

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

总结

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

本章介绍了 JavaScriptNode.js 运行时和 libuv(负责处理异步操作)中的异步编程的基础知识。有了这些知识,你就可以建立一个事件循环模型,这将帮你写好 Node.js 的异步代码。

原文

A Complete Visual Guide to Understanding the Node.js Event Loop