跳至主要內容

(一)可视化理解 Node.js 事件循环的完整指南

Yihui大约 12 分钟

(一)可视化理解 Node.js 事件循环的完整指南

assets_YJIGb4i01jvw0SRdL5Bt_22911d4752a44b48a42c7fabae9a64ee

原文:A Complete Visual Guide to Understanding the Node.js Event Loopopen in new window

作者:VISHWAS GOPINATHopen in new window

假设你使用 Node.js 已有一段时间了。你已经构建了一些应用程序,试用了不同的模块,甚至熟悉了异步编程。但是有件事一直困扰着你——事件循环。

如果你像我一样,已经花了无数时间阅读文档和观看视频,试图理解事件循环。但即使是经验丰富的开发人员,也很难全面了解其工作原理。这就是为什么我将这个可视化指南放在一起以帮助你完全理解 Node.js 事件循环的原因。坐下来,喝杯咖啡,让我们深入了解 Node.js 事件循环的世界。

JavaScript 中的异步编程

我们将从复习 JavaScript 中的异步编程开始。尽管 JavaScript 在 Web、移动和桌面应用程序中都有使用,但重要的是要记住,在其最基本的形式中,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。即使该代码需要 10 秒或 1 分钟。

你可能在浏览器中遇到过这种情况。当 Web 应用程序在浏览器中运行并且它执行大量代码而不将控制权返回给浏览器时,浏览器可能看起来被冻结了。这称为阻塞。浏览器被阻止继续处理用户输入和执行其他任务,直到网络应用程序返回处理器的控制权。

JavaScript 是单线程的

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

JavaScript 等待

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

Node.js 运行时(runtime)

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

  • Node.js 运行所需的外部依赖项——例如 V8、libuv、crypto
  • 提供文件系统访问和网络等功能的 C++ 特性
  • 一个 JavaScript 库,提供函数和实用程序以从 JavaScript 代码中挖掘 C++ 特性

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

Libuv

Libuvopen in new window是一个用 C 编写的跨平台开源库。在 Node.js 运行时,它的作用是提供对处理异步操作的支持。让我们来看看这是如何工作的。

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

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

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

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

同步代码执行

首先,让我们看一下同步代码执行。以下代码由三个控制台日志语句组成,依次记录“First”、“Second”和“Third”。让我们看一下代码,就好像运行时正在执行它一样。

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

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

执行的主线程总是在全局范围内启动。全局函数(如果我们可以这样称呼它)被压入堆栈。然后,在第 1 行,我们有一个控制台日志语句。该函数被压入堆栈。假设这发生在 1 毫秒,“First”将记录到控制台。然后,函数从堆栈中弹出。

执行到第 3 行。假设在 2ms 时,日志函数再次被压入堆栈。“Second”被记录到控制台,函数从堆栈中弹出。

最后,在第 5 行执行。在 3 毫秒时,函数被压入堆栈,“Third”被记录到控制台,函数被弹出堆栈。没有更多的代码可以执行,global 也被弹出。

异步代码执行

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

执行的主线程总是在全局范围内启动。全局函数被压入堆栈。然后执行到第 1 行。在 1 毫秒时,“First”被记录在控制台中,函数从堆栈中弹出。然后执行到第 3 行。在 2 毫秒时,readFile 方法被压入堆栈。由于 readFile 是一个异步操作,它被卸载到 libuv。

JavaScript 从调用堆栈弹出 readFile 方法,因为就第 3 行的执行而言,它的工作已经完成。在后台,libuv 开始在单独的线程上读取文件内容。在 3ms,JavaScript 进行到第 7 行,将日志函数压入堆栈,“Third”被记录到控制台,函数从堆栈中弹出。

4ms左右,假设文件读取任务在线程池中完成。关联的回调函数现在在调用堆栈上执行。在回调函数中,遇到log语句。

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

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

Libuv 和异步操作

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

  • 当异步任务在 libuv 中完成时,Node 在什么时候决定在调用堆栈上运行关联的回调函数?
  • Node 是在运行回调函数之前等待调用堆栈为空,还是打断正常的执行流程来运行回调函数?
  • 其他异步方法(如 setTimeout 和 setInterval)又会延迟回调函数的执行吗?
  • 如果 setTimeout 和 readFile 等两个异步任务同时完成,Node 如何决定在调用堆栈上先运行哪个回调函数?一个优先于另一个吗?

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

什么是事件循环?

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

事件循环可视化

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

  • 首先,有计时器队列(技术上是最小堆min-heap实现),它保存与 setTimeout 和 setInterval 关联的回调。
  • 其次,I/O 队列包含与所有异步方法关联的回调,例如与httpfs模块关联的方法。
  • 第三,检查队列保存与 setImmediate 函数关联的回调,这是 Node 特有的。
  • 第四,关闭队列保存与异步任务的关闭事件关联的回调。

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

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

重要的是要注意计时器、I/O、check和close队列都是 libuv 的一部分。然而,这两个微任务队列不是 libuv 的一部分。尽管如此,它们仍然是 Node 运行时的一部分,并且在回调的执行顺序中扮演着重要的角色。说到这里,接下来我们就来了解一下。

事件循环是如何工作的

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

在事件循环中,执行顺序遵循一定的规则。有很多规则可以绕过你的头脑,所以让我们一次过一遍:

  1. 执行微任务队列中的任何回调。首先,nextTick 队列中的任务,然后才是 promise 队列中的任务。
  2. 执行计时器队列中的所有回调。
  3. 微任务队列中的回调(如果存在)在定时器队列中的每个回调之后执行。首先是 nextTick 队列中的任务,然后是 promise 队列中的任务。
  4. 执行 I/O 队列中的所有回调。
  5. 执行微任务队列(如果存在)中的回调,从 nextTickQueue 开始,然后是 Promise 队列。
  6. 执行检查队列中的所有回调。
  7. 微任务队列中的回调(如果存在)在检查队列中的每个回调之后执行。首先是 nextTick 队列中的任务,然后是 promise 队列中的任务。
  8. 执行关闭队列中的所有回调。
  9. 在同一个循环中的最后一次,微任务队列被执行。首先是 nextTick 队列中的任务,然后是 promise 队列中的任务。

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

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

FAQ

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

回答:

回调函数只有在调用栈为空时才会执行。


Node 是在运行回调函数之前等待调用堆栈为空,还是打断正常的执行流程来运行回调函数?

回答:

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


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

回答:

setTimeout 和 setInterval 回调具有最高优先级。


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

回答:

setTimeout 和 setInterval 回调具有最高优先级。


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

回答:

定时器回调在 I/O 回调之前执行,即使两者同时准备就绪。


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

“等等,验证这个可视化的代码在哪里?” 你可能会问。好吧,事件循环中的每个队列在执行时都有细微差别,因此最好一次处理一个。这篇文章是有关 Node.js 事件循环的系列博文中的第一篇。请务必查看下面链接的其他部分,以了解一些可能会误导你的陷阱,即使你的脑海中已经印上了这张图片。

结论

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

本可视化指南涵盖了 JavaScript 异步编程的基础知识、Node.js 运行时和负责处理异步操作的 libuv。有了这些知识,你就可以构建一个强大的事件循环心智模型,这将帮助你编写利用 Node.js 异步特性的代码。

继续阅读

第1部分:Node.js事件循环实现可视化

第2部分:Node.js中的nextTick和Promise队列可视化

第3部分:Node.js中的定时器队列可视化

第4部分:Node.js事件循环中的I/O队列可视化