理解Node.js中Event Loop(一)
本文翻译至:A Complete Visual Guide to Understanding the Node.js Event Loop
目录
JavaScript 中的异步编程
我们首先回顾一下 JavaScript 中的异步编程。尽管 JavaScript 在 Web、移动端和桌面应用程序中都有使用,但重要的是,就其最基本的形式而言,JavaScript 是一种同步、阻塞、单线程语言。让我们用一小段代码来理解这一行。
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最小堆),它保存与 setTimeout 和 setInterval 关联的回调。
I/O 队列包含与所有异步方法关联的回调,例如与 fs 和 http 模块关联的方法。
Check队列,用于保存与 setImmediate 函数相关的回调,这是 Node 特有的。
Close队列,它保存与异步任务的关闭事件相关的回调。
最后,还有包含两个独立的MicroTask(微任务)队列。
nextTick 队列,其中包含与 process.nextTick 函数关联的回调。
Promise 队列,用于保存与 JavaScript 中的原生 Promise 关联的回调。
需要注意的是,Timer、I/O、Check和Close队列都是 libuv 的一部分。但是,两个微任务队列不是 libuv 的一部分。
尽管如此,它们仍然是 Node 运行时的一部分,并在回调的执行顺序中发挥着重要作用。说到这里,接下来我们来了解一下。
事件循环如何工作
解释一下队列的优先级顺序。首先,要知道所有用户编写的同步 JavaScript 代码都优先于运行时想要执行的异步代码。这意味着只有在执行栈为空后Event Loop才会发挥作用。
在事件循环中,执行顺序遵循一定的规则。有很多规则需要理解,所以让我们一次回顾一下它们:
执行微任务队列中的回调。首先执行 nextTick 队列中的任务,然后才执行 promise 队列中的任务。
执行Timer队列中的所有回调。
再次执行微任务队列中的回调,即重复步骤1。
执行 I/O 队列中的所有回调。
重复步骤1。
执行Check队列中的所有回调。
重复步骤1。
执行Close队列中的所有回调。
最后,在同一个循环中执行微任务队列。即最后一次重复步骤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 事件循环的一系列博客文章中的第一篇。请务必查看下面链接的其他部分。
此系列文章: