Node.js 底层原理 #3 - 深入探究事件循环

2025-05-28

Node.js 底层原理 #3 - 深入探究事件循环

在上一篇文章中,我们讨论了调用栈、栈帧、栈溢出以及其他一些 JS 相关的知识。我们了解了引擎与 JavaScript 的关系,以及所有代码的执行实际上是如何在 JS 运行时中运行的。

现在我们将进入另一个领域——事件循环,并理解为什么所有 JS 运行时和 JS 引擎都提供事件循环。首先,让我们深入了解它的核心。

Libuv

什么是 libuv?为什么我们需要它?

Libuv 是一个开源库,它处理线程池、信号发送、进程间通信以及实现异步任务所需的所有其他功能。Libuv 最初是为 Node.js 本身开发的,作为 的一个抽象libev,但现在已有多个项目在使用它。

大多数人认为 libuv 就是事件循环本身,但事实并非如此,libuv 实现了一个功能齐全的事件循环,而且也是 Node 其他几个关键部分的所在地,例如:

  • TCP 和 UDPnet套接字的封装
  • 异步 DNS 解析
  • 异步文件和文件系统操作(就像我们在这里做的一样)
  • 文件系统事件
  • 工业控制计算机
  • 子进程和 shell 控制
  • 线程池
  • 信号处理
  • 高分辨率时钟

这主要是 Node.js 使用它的原因,它是围绕每个操作系统的几个关键部分的完整抽象,并且整个运行时有必要与其周围环境进行交互。

事件循环

让我们暂时抛开 Node.js 环境。在浏览器中,用纯 JavaScript 编写时,如果调用栈中有一个长时间运行的函数,会发生什么?那些需要一段时间才能完成的函数,比如复杂的图像处理或耗时的矩阵变换?

在大多数语言中,你应该不会遇到问题,因为它们是多线程的。然而,在单线程语言中,这是一个非常严重的问题。因为虽然调用堆栈中有函数要执行,但浏览器实际上无法执行任何其他操作,而且浏览器不仅仅处理 HTML 和 CSS,还有其他一些功能,例如渲染引擎,它会绘制屏幕以绘制你用标记编写的任何内容。这意味着,如果你有长时间运行的函数,你的浏览器实际上会暂停该页面的所有执行。这就是为什么大多数浏览器将标签页视为线程或独立进程,这样就不会因为一个标签页冻结所有其他标签页。

另一个可能引发的问题是,浏览器就像一个控制欲极强的“老大哥”,如果某个标签页响应时间过长,它们就会抛出一个错误,询问你是否要终止该网页。所以……这可不是我们能拥有的最佳用户体验,对吧?另一方面,复杂的任务和长时间运行的代码才是我们创建优秀软件的关键,那么,如何才能在不惹恼“老大哥”的情况下完成这些任务呢?异步回调,正是 Node.js 的核心所在。

异步回调

大多数 JavaScript 应用程序的工作原理是将单个.js文件加载到内存中,然后所有神奇的事情都发生在这个入口点执行之后。这可以分为几个构建块,即“现在”块和“稍后”块。通常,这些块中只有一个是“现在”块,这意味着它将在主线程中执行(将调用推送到调用堆栈),而所有其他块都将稍后执行。

异步编程最大的问题是,大多数人认为“稍后”指的是“现在”到之后一毫秒之间的某个时间,这完全是谎言。JavaScript 中所有计划稍后执行并完成的操作并不一定严格发生在主线程之后,根据定义,它们会在完成时完成。这意味着你无法立即得到你想要的答案。

例如,让我们进行一个调用 API 的简单 AJAX 调用:



const response = call('http://api') // call() is some http request package, like fetch
console.log(response)


Enter fullscreen mode Exit fullscreen mode

由于 AJAX 调用在调用后不会立即完成 - 执行 HTTP 握手、获取数据、下载数据需要一些时间...... - 所以此调用将在稍后的状态下完成,因此响应尚未分配值,这意味着我们的console函数将打印undefined

一种简单的“等待”响应的方式是回调。回调自编程之初就是一种自动调用的函数,它会作为参数传递给另一个函数,该函数将在“现在”之后执行并/或返回其值。所以,回调本质上就是一种说法:“嘿,当你得到这个值时,就调用这个回调”。让我们改进一下我们的例子:



const response = call('http://api', (response) => {
  console.log(response)
})


Enter fullscreen mode Exit fullscreen mode

这基本上意味着,当调用结束时,(response) => void将自动调用一个带有签名的匿名函数,因为调用返回了响应,所以这个参数被传递给回调函数。现在我们就可以在响应中记录日志了。

在我们的第一个代码示例中,调用readFile,我们基本上把它转换成一个 Promise,Promise 是一种代码,它会在后续状态返回它的值,然后打印出来,我们正在异步读取一个文件。但它究竟是如何工作的呢?

事件循环内部

在 ES6 之前,JS 核心中实际上从未内置任何形式的共识或异步概念。这意味着 JS 会收到你执行某些异步代码的命令,并将其发送给引擎,然后引擎会点赞并回复“我以后会研究一下”。所以,引擎中既没有内置任何关于“稍后”如何执行的命令,也没有内置任何逻辑。

JS 引擎实际上并非独立于一切运行。它们运行在所谓的托管环境中。这个环境可以是 JS 运行的任何地方,例如浏览器、Node.js,或者由于 JS 几乎无处不在,它也可以是一个烤面包机或飞机。每个环境都各不相同,每个环境都有自己的技能和能力,但它们都有一个事件循环

事件循环实际上负责 JS 引擎的异步代码执行,至少是调度部分。它调用引擎并发送待执行的命令,同时也将引擎返回的响应回调排队,以便之后调用。因此,我们开始理解 JS 引擎只不过是一个按需执行的环境,无论 JS 代码是否运行。围绕着它的所有东西,即环境,即事件循环,都负责调度 JS 代码的执行,这些代码被称为事件。

现在让我们回到readFile代码。运行代码时,该readFile函数被包装到一个 Promise 对象中,但本质上,它readFile是一个回调函数。所以我们只分析这部分:



fs.readFile(filePath, function cb (err, data) => {
      if (err) return reject(err)
      return resolve(callback(data))
    })


Enter fullscreen mode Exit fullscreen mode

看到我们有一个回调函数了(err, data) => string吗?这基本上是告诉引擎对文件执行一个读取操作。然后,JS 引擎会告诉托管环境,它将暂时暂停执行这段代码,但是,一旦环境(事件循环)收到响应,它就会安排这个匿名回调函数(cb)尽快执行。然后,环境(在我们的例子中是 Node.js)会设置为监听文件操作的响应,当响应到达时,它会cb通过将函数插入事件循环来安排执行。

让我们回顾一下旧图表:

Web API 本质上是线程,开发者无法访问,只能调用它们。通常,这些是内置于环境本身的部分,例如,在浏览器环境中,这些 API 可能是documentXMLHttpRequestsetTimeout,它们大多是异步函数。在 Node.js 中,这些 API 就是我们在指南第一部分中看到的 C++ API。

简而言之,每当我们像setTimeout在 Node.js 中一样调用一个函数时,这个调用都会被发送到另一个线程。所有这些都由 libuv 控制和提供,包括我们正在使用的 API。

让我们放大事件循环部分:

事件循环只有一个任务:监视调用堆栈以及所谓的回调队列。一旦调用堆栈为空,它会从回调队列中取出第一个事件并将其推送到调用堆栈,从而有效地运行它。对于这次从队列中取出回调并将其执行到调用堆栈的迭代,我们将其命名为tick

让我们举一个更简单的例子来展示事件循环实际上是如何工作的:



console.log('Node.js')
setTimeout(function cb() { console.log(' awesome!') }, 5000)
console.log(' is')


Enter fullscreen mode Exit fullscreen mode

这应该会在控制台中以分隔行的形式打印出“Node.js is awesome!”。但这是怎么发生的呢?让我们一步一步来运行它:

  1. 状态为空,调用堆栈为空,没有被调用

  1. console.log('Node.js')被添加到调用堆栈

  1. console.log('Node.js')被执行

  1. console.log('Node.js')从堆栈中移除

  1. setTimeout(function cb() {...}被添加到调用堆栈

  1. setTimeout(function cb() {...}执行。环境会创建一个计时器作为 Web API 的一部分。该计时器将处理倒计时

  1. setTimeout(function cb() {...}本身已完成并从调用堆栈中删除

  1. console.log(' is')被添加到调用堆栈

  1. console.log(' is')被执行

  1. console.log(' is')从调用堆栈中删除

  1. 至少 5000 毫秒后,计时器完成并将cb回调函数推送到回调队列

  1. 事件循环检查堆栈,如果堆栈为空,它将从回调队列中弹出事件并将其推入堆栈

  1. cb执行并添加console.log(' awesome!')到调用堆栈中

  1. console.log(' awesome!')被执行

  1. console.log(' awesome!')从堆栈中移除

  1. cb从堆栈中移除

正如我们之前提到的,ES6 指定了事件循环的行为方式,所以现在从技术上讲,调度已经属于 JS 引擎的职责范围,而引擎不再仅仅扮演一个托管环境的角色。这主要是因为 ES6 引入了原生的 Promises,正如我们稍后会看到的,它需要对调度操作和队列进行一些细粒度的控制。

一旦调用堆栈和所有队列都为空,事件循环就会终止该过程。

值得注意的是,回调队列与调用堆栈类似,是另一种数据结构,即队列。队列的行为类似于堆栈,但区别在于它们的顺序。堆栈框架被推送到堆栈顶部,而队列项则被推送到队列末尾。堆栈的弹出遵循 LIFO 原则,而队列遵循 FIFO(先进先出)原则,这意味着弹出操作将从队列的头部(即最旧的队列)弹出。

稍后并不一定意味着“稍后”

在上面的代码中,需要注意的一点是,回调函数执行完成后,不会自动将其放入事件循环队列。setTimeout是一个 Web API,它的唯一作用是设置一个计时器,以便稍后执行其他函数。计时器到期后,环境会将回调函数放入事件循环回调队列,以便将来的某个 tick 可以将其拾取并启动到调用堆栈中。setTimeout

所以,我们setTimeout(cb, 1000)期望cb函数在 1000 毫秒后被调用,对吗?没错,但实际上并非如此。这只是在说:“嘿!我注意到你的请求了,所以 1000 毫秒后我会把你的cb函数放到队列里”。但请记住,队列的顺序与堆栈不同,所以回调会被添加到队列的末尾,这意味着队列中可能还有其他事件之前被添加——所以你的回调必须等待所有事件完成才能被处理。展示这种异步疯狂如何运作的最佳示例之一是将超时函数设置为 0。你自然希望这个函数在添加到代码后很快就能执行,对吗?错了。



console.log('Node.js')
setTimeout(() => console.log('is'), 0)
console.log('Awesome!')


Enter fullscreen mode Exit fullscreen mode

我们首先想到的是:“打印的代码应该Node.js is Awesome!只有三行”,但实际情况并非如此。将超时设置为 0 只会将其回调的执行推迟到调用堆栈清空后的下一个时刻。事实上,我们的反应应该是一句类似尤达大师的短语:



Node.js
Awesome!
is


Enter fullscreen mode Exit fullscreen mode

微任务和宏任务

这就是为什么 ES6 对 JS 中的异步执行如此重要,它标准化了我们所知的关于异步的一切,使它们都能以相同的方式运行,并且还添加了另一个概念,称为“微任务队列”——或“作业队列”。它是回调队列(现在称为“宏任务队列”)之上的一层,您在使用 Promises 时很可能会遇到它。

简而言之,微任务队列是一个附加到事件循环中每个 tick 末尾的队列。因此,在事件循环的 tick 期间发生的某些异步操作不会导致在宏任务队列中添加新的回调,而是会在当前 tick 的微任务队列末尾添加一个项目(称为“微任务”或“作业”)。这意味着,现在您可以放心,您可以在微任务队列中添加稍后执行的功能,并且它会在您的 tick 之后立即执行,在宏任务队列中的任何内容出现之前。

由于微任务对代码的操作没有任何限制,因此微任务可能会在同一个队列的末尾无休止地添加另一个微任务,从而导致所谓的“微任务循环”,使程序无法获得所需的资源,并阻止其在下一个 tick 继续运行。这相当于while(true)在代码中运行一个循环,只不过是异步的。

为了防止这种饥饿,引擎内置了称为的保护process.maxTickDepth,其值设置为 1000,在同一个 tick 中调度并运行 1000 个微任务后,再运行下一个宏任务。

setTimeout(cb, 0)是一种“解决方法”,用于添加在队列执行后立即添加的回调,就像微任务一样,但是,微任务是一种更加清晰和明确的排序规范,这意味着事情将稍后执行,但会尽快执行。

根据WHATVG规范,在事件循环的一个 tick 内,宏任务队列中应该处理一个(且恰好一个)宏任务。该宏任务完成后,所有其他可用的微任务都应在同一 tick 内处理。由于微任务可以加入其他微任务的队列,因此,如果微任务队列中有微任务,则应依次运行这些微任务,直到微任务队列为空。如下图所示:

并非所有任务都是微任务,以下是一些微任务的示例:

  • process.nextTick
  • 承诺
  • Object.observe

这些是宏任务:

  • setTimeout
  • setInterval
  • setImmediate
  • 任何 I/O 操作

我们以此代码为例:



console.log('script start')

const interval = setInterval(() => {
  console.log('setInterval')
}, 0)

setTimeout(() => {
  console.log('setTimeout 1')

  Promise.resolve()
    .then(() => console.log('promise 3'))
    .then(() => console.log('promise 4'))
    .then(() => {
      setTimeout(() => {
        console.log('setTimeout 2')
        Promise.resolve().then(() => console.log('promise 5'))
          .then(() => console.log('promise 6'))
          .then(() => clearInterval(interval))
      }, 0)
    })
}, 0)

Promise.resolve()
  .then(() => console.log('promise 1'))
  .then(() => console.log('promise 2'))


Enter fullscreen mode Exit fullscreen mode

这将记录:



script start
promise 1
promise 2
setInterval
setTimeout 1
promise 3
promise 4
setInterval
setTimeout 2
setInterval
promise5
promise6


Enter fullscreen mode Exit fullscreen mode

如果我们一步一步地进行下去,我们会得到如下结果:

第一个刻度

  • 第一个console.log将被压入调用堆栈并执行,然后它将被弹出
  • setInterval被安排为一项任务
  • setTimeout 1被安排为一项任务
  • 两个“then”都Promise.resolve 1被安排为微任务
  • 由于堆栈为空,因此运行微任务
    • 调用堆栈将两个console.log表达式压入堆栈并弹出
    • 打印“承诺 1”和“承诺 2”

我们的宏任务队列有:[ setInterval, setTimeout 1]

第二次滴答

  • 微任务队列为空,setInterval处理程序可以运行。
    • 调用堆栈运行并弹出console.log表达式
    • 打印“setInterval”
    • setInterval之后安排另一次setTimeout 1

我们的宏任务队列有:[ setTimeout 1, setInterval]

第三次滴答

  • 微任务队列保持为空
  • setTimeout 1处理程序已运行
    • 调用堆栈运行并弹出console.log表达式
    • 打印“setTimeout 1”
    • “Promise 3”和“Promise 4”处理程序被安排为微任务
    • Promises 3 和 Promises 4 的处理程序均已运行
    • 调用堆栈运行并弹出两个console.log表达式
    • 打印“承诺 3”和“承诺 4”
    • 下一个处理程序为承诺 3 和 4 安排一项setTimeout 2任务

我们的宏任务队列有:[ setInterval, setTimeout 2]

第四刻

  • 微任务队列为空,setInterval处理程序正在运行,setInterval紧接着将另一个微任务加入队列setTimeout

我们的宏任务队列有:[ setTimeout 2, setInterval]

  • setTimeout 2处理程序已运行
    • Promise 5 和 6 被安排为微任务
    • 运行承诺 5 和承诺 6 的处理程序
    • 调用堆栈又收到两个console.log调用
    • 打印“承诺 5”和“承诺 6”
    • 清除间隔

我们的宏任务队列有:[]

这就是为什么关注事情的底层工作原理很重要,否则我们永远不会知道 Promises 的执行速度比回调更快。

文章来源:https://dev.to/_staticvoid/node-js-under-the-hood-3-deep-dive-into-the-event-loop-135d
PREV
一些 Javascript 的最佳实践,以实现更简洁、更优的代码质量...
NEXT
Node.js 底层原理 #2 - 理解 JavaScript