theme: cyanosis
本文翻译于 A Complete Visual Guide to Understanding the Node.js Event Loop
你已经用
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。即使 A
函数需要 10 秒或 1 分钟。
我们可能在浏览器中遇到过这种情况。当 Web
应用在浏览器中运行时,它执行大量代码而不将控制权返回给浏览器,则浏览器可能看起来处于卡顿状态,这称为阻塞。浏览器被阻止继续处理用户输入和执行其他任务,直到 Web
应用拿到对处理器的控制权。
JavaScript 是单线程的
线程是 JavaScript
程序可用于运行任务的进程。并且每个线程一次只能执行一个任务。与其他一些支持多线程的语言不同,它们可以并行运行多个任务,而 JavaScript
只有一个线程称为主线程,用于执行所有代码。
The Node.js Runtime
Node.js
运行时是一个环境,它可以使你在浏览器外部运行 JavaScript
程序。Node
运行时的核心由三个主要组件组成。
- 外部依赖项(如
V8
、libuv
、crypto
等)Node.js
运行所需的外部依赖项 - 提供文件系统访问和网络等功能的
C++
功能 -
JavaScript
库,提供函数和实用工具,用于执行JavaScript
代码中的C++
功能
虽然所有部分都很重要,但 Node.js
中异步编程的关键组件是外部依赖项 libuv
。
libuv
libuv
是一个用 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 行。假设在 2 毫秒时,log 函数再次推送到栈上。“Second”被记录到控制台,并且该函数将从栈中弹出。
最后,执行在第 5 行。在 3 毫秒时,该函数被推送到栈上,“Third”被记录到控制台,并且该函数被从栈中弹出。没有更多的代码要执行,global也被弹出。
异步代码执行过程
接下来,让我们看一下异步代码执行。请考虑下面的代码片段。有三个 log
语句,但这次第二个 log
语句位于传递给 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
是等待调用堆栈为空后再执行回调函数,还是中断正常的执行流程来执行回调函数? - 其他异步方法(如
setTimeout
和setInterval
)也会延迟回调函数的执行吗? - 如果
setTimeout
和readFile
等两个异步任务同时完成,Node
如何决定在调用堆栈上先运行哪个回调函数?哪一个优先于另一个吗?
所有这些问题都可以通过理解 libuv
的核心部分来回答,即事件循环。
事件循环
从技术上讲,事件循环只是一个 C
程序。也可以将其视为一种设计模式,用于协调或协调 Node.js
中同步和异步代码的执行。
事件循环可视化
事件循环是一个循环,只要您的 Node.js
应用程序启动并运行,它就会运行。每个循环中有六个不同的队列,每个队列都包含一个或多个最终需要在调用栈上执行的回调函数。
- 首先是
timer queue
(计时器队列,技术实现上是一个最小堆),它保存与setTimeout
和setInterval
关联的回调。 - 其次,有一个
I/O queue
,其中包含与所有异步方法关联的回调,例如与fs
和http
模块关联的方法。 - 第三,有一个
check queue
(检查队列),它保存与setImmediate
函数关联的回调,该函数只用Node
有。 - 第四,有一个
close queue
(关闭队列),它保存与异步任务关闭事件相关的回调。
最后,还有包含两个 microtask queue
(微任务队列)。
-
nextTick
队列,用于保存与process.nextTick
函数关联的回调。 -
Promise
队列,用于保存与JavaScript
中的原生Promise
关联的回调。
需要注意的是,timer queue
、I/O queue
、check queue
和close queue
都是 libuv
的一部分。但这两个 microtask queue
不是 libuv
的一部分。它们属于 Node
运行时的一部分,在回调的执行顺序中发挥着重要作用。说到这里,接下来让我们来了解一下他们的功能。
事件循环是怎样工作的
箭头函数,但很容易混淆。让我解释一下队列的优先级顺序。首先,要知道所有用户编写的同步 JavaScript
代码都优先于运行时要执行的异步代码。这意味着只有在调用栈为空后,事件循环才会发挥作用。
在事件循环中,执行顺序遵循特定的规则。有很多规则需要你注意,让我们看一下执行顺序:
-
microtask queue
中的回调函数都会首先执行。先是nextTick queue
中的任务,然后是promise queue
中的任务。 - 执行
timer queue
中的所有回调函数。 - 如果
microtask queue
中存在回调函数,则执行microtask queue
中的函数。先是nextTick queue
中的任务,然后是promise queue
中的任务。 - 执行
I/O queue
中的所有回调函数。 - 如果
microtask queue
中存在回调函数,则执行microtask queue
中的函数。先是nextTick queue
中的任务,然后是promise queue
中的任务。 - 执行
check queue
中的所有回调函数。 - 如果
microtask queue
中存在回调函数,则执行microtask queue
中的函数。先是nextTick queue
中的任务,然后是promise queue
中的任务。 - 执行
close queue
中的所有回调。 - 在同一循环中最后一次执行
microtask queue
。先是nextTick queue
中的任务,然后是promise queue
中的任务。
如果此时要处理的回调更多,则循环将保持活动状态以再运行一次,并重复相同的步骤。另一方面,如果执行了所有回调之后没有更多代码需要执行,则事件循环将退出。
这就是 libuv
的事件循环在 Node.js
中执行异步代码时所扮演的角色。我们了解了执行过程,我们可以重新尝试解答之前的问题。
当异步任务在 libuv
中完成时,Node
决定在什么时候在调用栈上运行关联的回调函数??
仅当调用栈为空时,才会执行回调函数。
Node 是等待调用堆栈为空后再运行回调函数,还是中断正常的执行流程来运行回调函数?
运行回调函数不会中断正常的执行流程。
其他异步方法(如 setTimeout
和 setInterval
)也会延迟回调函数的执行吗?
运行回调函数不会中断正常的执行流程。
如果 setTimeout
和 readFile
等两个异步任务同时完成,Node
如何决定在调用堆栈上先运行哪个回调函数?哪一个优先于另一个吗?
timer queue
在 I/O queue
之前执行,即使两者同时准备就绪。
我们学习了很多东西,希望你记住下面的这个图(与上面相同),因为它展示了 Node.js
如何在后台执行异步代码。
“但是等等,验证这些规则的代码在哪里?”事件循环中的每个队列在执行中都有细微差别,因此最好一次处理一个。本文是 Node.js
事件循环系列博客文章中的第一篇。请查看下面链接的其他部分,以了解一些可能会遇到的坑。
总结
事件循环是 Node.js
的基础,它通过确保主线程不被阻塞来实现异步编程。了解事件循环的工作原理可能具有挑战性,但对于构建高性能应用程序至关重要。
本章介绍了 JavaScript
、Node.js
运行时和 libuv
(负责处理异步操作)中的异步编程的基础知识。有了这些知识,你就可以建立一个事件循环模型,这将帮你写好 Node.js
的异步代码。
原文
A Complete Visual Guide to Understanding the Node.js Event Loop