首页 >web前端 >js教程 >Node.js事件循环:开发人员的概念和代码指南

Node.js事件循环:开发人员的概念和代码指南

Christopher Nolan
Christopher Nolan原创
2025-02-12 08:36:12614浏览

Node.js 的异步编程:深入理解事件循环

The Node.js Event Loop: A Developer's Guide to Concepts & Code

异步编程在任何编程语言中都极具挑战性。并发、并行和死锁等概念让即使是最资深的工程师也感到棘手。异步执行的代码难以预测,出现bug时也难以追踪。然而,这个问题是不可避免的,因为现代计算拥有多核处理器。每个CPU内核都有其热限制,单核性能提升已达到瓶颈。这促使开发者编写高效的代码,充分利用硬件资源。

JavaScript 是单线程的,但这是否限制了 Node.js 利用现代架构的能力呢?最大的挑战之一是处理多线程的固有复杂性。创建新线程和管理线程间的上下文切换代价高昂。操作系统和程序员都需要付出大量努力才能提供一个处理众多边缘情况的解决方案。本文将阐述 Node.js 如何通过事件循环来解决这个难题,深入探讨 Node.js 事件循环的各个方面并演示其工作原理。事件循环是 Node.js 的杀手级特性之一,因为它以一种全新的方式解决了这个棘手的问题。

关键要点

  • Node.js 事件循环是一个单线程、非阻塞和异步并发的循环,允许高效处理多个任务,而无需等待每个任务完成。这使得同时处理多个 Web 请求成为可能。
  • 事件循环是半无限的,这意味着如果调用栈或回调队列为空,它可以退出。该循环负责轮询操作系统以获取来自传入连接的回调。
  • 事件循环在多个阶段运行:时间戳更新、循环活跃性检查、定时器执行、待处理回调执行、空闲处理程序执行、准备 setImmediate 回调执行的句柄、计算轮询超时、阻塞 I/O、检查句柄回调执行、关闭回调执行以及迭代结束。
  • Node.js 利用两个主要部分:V8 JavaScript 引擎和 libuv。网络 I/O、文件 I/O 和 DNS 查询通过 libuv 进行。线程池中可用于这些任务的线程数量有限,可以通过 UV_THREADPOOL_SIZE 环境变量进行设置。
  • 在每个阶段结束时,循环执行 process.nextTick() 回调,它不是事件循环的一部分,因为它在每个阶段结束时运行。setImmediate() 回调是整个事件循环的一部分,因此它并不像名称暗示的那样立即执行。一般建议使用 setImmediate()。

什么是事件循环?

事件循环是一个单线程、非阻塞和异步并发的循环。对于没有计算机科学学位的人来说,想象一下一个执行数据库查找的 Web 请求。单线程一次只能执行一项操作。它不会等待数据库响应,而是继续处理队列中的其他任务。在事件循环中,主循环展开调用栈,并且不等待回调。由于循环不会阻塞,因此它可以同时处理多个 Web 请求。多个请求可以同时排队,使其具有并发性。循环不会等待一个请求的所有操作都完成,而是根据回调的出现顺序进行处理,而不会阻塞。

循环本身是半无限的,这意味着如果调用栈或回调队列为空,它可以退出循环。可以将调用栈视为同步代码,例如 console.log,在循环轮询更多工作之前展开。Node.js 使用底层的 libuv 来轮询操作系统以获取来自传入连接的回调。

您可能想知道,为什么事件循环在单线程中执行?对于每个连接所需的数据而言,线程在内存中相对较重。线程是操作系统资源,需要启动,这无法扩展到数千个活动连接。

通常情况下,多线程也会使情况复杂化。如果回调返回数据,它必须将上下文编组回正在执行的线程。线程间的上下文切换速度很慢,因为它必须同步当前状态,例如调用栈或局部变量。事件循环在多个线程共享资源时可以避免bug,因为它单线程。单线程循环减少了线程安全边缘情况,并且可以更快地进行上下文切换。这就是循环背后的真正天才之处。它在保持可扩展性的同时有效地利用了连接和线程。

理论足够了;现在来看看代码是什么样的。您可以随意在 REPL 中进行操作或下载源代码。

半无限循环

事件循环必须回答的最大问题是循环是否处于活动状态。如果是,则确定在回调队列上等待多长时间。在每次迭代中,循环展开调用栈,然后进行轮询。

这是一个阻塞主循环的示例:

<code class="language-javascript">setTimeout(
  () => console.log('Hi from the callback queue'),
  5000); // 保持循环活动这么长时间

const stopTime = Date.now() + 2000;
while (Date.now() < stopTime) {}</code>

如果您运行此代码,请注意循环被阻塞了两秒钟。但是,循环会保持活动状态,直到回调在五秒钟后执行。一旦主循环解除阻塞,轮询机制就会确定它在回调上等待多长时间。当调用栈展开并且没有剩余回调时,此循环结束。

回调队列

现在,当我阻塞主循环然后调度回调时会发生什么?一旦循环被阻塞,它就不会将更多回调添加到队列中:

<code class="language-javascript">const stopTime = Date.now() + 2000;
while (Date.now() < stopTime) {}
// 这需要 7 秒才能执行
setTimeout(() => console.log('Ran callback A'), 5000);</code>

这次循环保持活动状态七秒钟。事件循环在其简单性方面是愚蠢的。它无法知道将来可能会排队什么。在实际系统中,传入的回调会排队并在主循环可以进行轮询时执行。事件循环在解除阻塞时会顺序地经历几个阶段。因此,为了在关于循环的面试中脱颖而出,请避免使用“事件发射器”或“反应器模式”等花哨的术语。它是一个简单的单线程循环,并发且非阻塞。

使用 async/await 的事件循环

为了避免阻塞主循环,一个想法是用 async/await 包装同步 I/O:

<code class="language-javascript">const fs = require('fs');
const readFileSync = async (path) => await fs.readFileSync(path);

readFileSync('readme.md').then((data) => console.log(data));
console.log('The event loop continues without blocking...');</code>

await 之后出现的任何内容都来自回调队列。代码看起来像同步阻塞代码,但它不会阻塞。请注意,async/await 使 readFileSync 成为可 then 的,这将其从主循环中移除。可以将 await 之后出现的任何内容视为通过回调进行的非阻塞操作。

完全披露:以上代码仅用于演示目的。在实际代码中,我建议使用 fs.readFile,它会触发一个可以围绕 Promise 包装的回调。总体意图仍然有效,因为这将阻塞 I/O 从主循环中移除。

更进一步

如果我告诉你事件循环不仅仅是调用栈和回调队列呢?如果事件循环不仅仅是一个循环,而是多个循环呢?如果它可以在底层拥有多个线程呢?

现在,我想带你深入 Node.js 内部。

事件循环阶段

这些是事件循环阶段:

The Node.js Event Loop: A Developer's Guide to Concepts & Code

图片源:libuv 文档

  1. 更新时间戳。事件循环在循环开始时缓存当前时间,以避免频繁进行与时间相关的系统调用。这些系统调用是 libuv 的内部调用。
  2. 循环是否处于活动状态?如果循环具有活动句柄、活动请求或正在关闭的句柄,则它处于活动状态。如所示,队列中的待处理回调使循环保持活动状态。
  3. 执行到期的定时器。这是 setTimeout 或 setInterval 回调运行的地方。循环检查缓存的now 以使到期的活动回调执行。
  4. 执行队列中的待处理回调。如果之前的迭代延迟了任何回调,则这些回调会在此时运行。轮询通常会立即运行 I/O 回调,但也有例外。此步骤处理来自上一次迭代的任何滞后回调。
  5. 执行空闲处理程序——主要是因为命名不当,因为这些处理程序在每次迭代中都会运行,并且是 libuv 的内部处理程序。
  6. 准备在循环迭代中执行 setImmediate 回调的句柄。这些句柄在循环阻塞 I/O 之前运行,并为这种回调类型准备队列。
  7. 计算轮询超时。循环必须知道它阻塞 I/O 的时间。这就是它如何计算超时的:
    • 如果循环即将退出,则超时为 0。
    • 如果没有活动句柄或请求,则超时为 0。
    • 如果有任何空闲句柄,则超时为 0。
    • 如果队列中有任何待处理的句柄,则超时为 0。
    • 如果有任何正在关闭的句柄,则超时为 0。
    • 如果以上都不是,则超时设置为最接近的定时器,如果没有任何活动定时器,则为无限
  8. 循环使用上一个阶段的持续时间阻塞 I/O。队列中的与 I/O 相关的回调在此处执行。
  9. 执行检查句柄回调。此阶段是 setImmediate 运行的阶段,它是准备句柄的对应阶段。在 I/O 回调执行过程中排队的任何 setImmediate 回调都会在此处运行。
  10. 执行关闭回调。这些是从已关闭连接中释放的活动句柄。
  11. 迭代结束。

您可能想知道为什么轮询在应该是非阻塞的情况下会阻塞 I/O?只有当队列中没有待处理的回调并且调用栈为空时,循环才会阻塞。在 Node.js 中,最接近的定时器可以通过 setTimeout 设置,例如。如果设置为无限大,则循环将等待传入连接以进行更多工作。这是一个半无限循环,因为当没有剩余工作并且存在活动连接时,轮询会使循环保持活动状态。

以下是此超时计算的 Unix 版本,以其全部 C 代码形式:

<code class="language-javascript">setTimeout(
  () => console.log('Hi from the callback queue'),
  5000); // 保持循环活动这么长时间

const stopTime = Date.now() + 2000;
while (Date.now() < stopTime) {}</code>

您可能不太熟悉 C 语言,但这读起来像英语,并且完全按照第七阶段所述执行。

逐阶段演示

为了用纯 JavaScript 显示每个阶段:

<code class="language-javascript">const stopTime = Date.now() + 2000;
while (Date.now() < stopTime) {}
// 这需要 7 秒才能执行
setTimeout(() => console.log('Ran callback A'), 5000);</code>

因为文件 I/O 回调在阶段四和阶段九之前运行,所以预计 setImmediate() 会先触发:

<code class="language-javascript">const fs = require('fs');
const readFileSync = async (path) => await fs.readFileSync(path);

readFileSync('readme.md').then((data) => console.log(data));
console.log('The event loop continues without blocking...');</code>

没有 DNS 查询的网络 I/O 比文件 I/O 成本更低,因为它在主事件循环中执行。文件 I/O 通过线程池排队。DNS 查询也使用线程池,因此这使得网络 I/O 与文件 I/O 一样昂贵。

线程池

Node.js 内部有两个主要部分:V8 JavaScript 引擎和 libuv。文件 I/O、DNS 查询和网络 I/O 通过 libuv 进行。

这是整体架构:

The Node.js Event Loop: A Developer's Guide to Concepts & Code

图片源:libuv 文档

对于网络 I/O,事件循环在主线程内进行轮询。此线程不是线程安全的,因为它不会与另一个线程进行上下文切换。文件 I/O 和 DNS 查询是特定于平台的,因此方法是在线程池中运行它们。一个想法是自己进行 DNS 查询以避免进入线程池,如上面的代码所示。例如,输入 IP 地址而不是 localhost 会将查找从池中移除。线程池中可用的线程数量有限,可以通过 UV_THREADPOOL_SIZE 环境变量进行设置。默认线程池大小约为四个。

V8 在单独的循环中执行,清空调用栈,然后将控制权返回给事件循环。V8 可以使用多个线程进行其自身循环之外的垃圾回收。可以将 V8 视为一个引擎,它接收原始 JavaScript 并将其在硬件上运行。

对于普通程序员来说,JavaScript 保持单线程,因为没有线程安全问题。V8 和 libuv 内部会启动它们自己单独的线程以满足它们自己的需求。

如果 Node.js 中存在吞吐量问题,请从主事件循环开始。检查应用程序完成单个迭代需要多长时间。它不应超过一百毫秒。然后,检查线程池饥饿以及可以从池中驱逐的内容。也可以通过环境变量增加池的大小。最后一步是在同步执行的 V8 中对 JavaScript 代码进行微基准测试。

总结

事件循环继续迭代每个阶段,因为回调被排队。但是,在每个阶段内,都有方法可以排队另一种类型的回调。

process.nextTick() 与 setImmediate()

在每个阶段结束时,循环执行 process.nextTick() 回调。请注意,此回调类型不是事件循环的一部分,因为它在每个阶段结束时运行。setImmediate() 回调是整个事件循环的一部分,因此它并不像名称暗示的那样立即执行。由于 process.nextTick() 需要了解事件循环的内部机制,因此我通常建议使用 setImmediate()。

您可能需要 process.nextTick() 的几个原因:

  1. 允许网络 I/O 在循环继续之前处理错误、清理或重试请求。
  2. 可能需要在调用栈展开后但在循环继续之前运行回调。

例如,事件发射器希望在其自身构造函数中触发事件。调用栈必须先展开才能调用事件。

<code class="language-javascript">setTimeout(
  () => console.log('Hi from the callback queue'),
  5000); // 保持循环活动这么长时间

const stopTime = Date.now() + 2000;
while (Date.now() < stopTime) {}</code>

允许调用栈展开可以防止诸如 RangeError: Maximum call stack size exceeded 之类的错误。一个需要注意的是确保 process.nextTick() 不会阻塞事件循环。在同一阶段内递归回调调用可能会导致阻塞问题。

结论

事件循环在其终极复杂性中体现了简单性。它解决了一个难题,例如异步性、线程安全性和并发性。它删除了无用或不需要的部分,并以最有效的方式最大限度地提高了吞吐量。因此,Node.js 程序员可以减少追逐异步错误的时间,而将更多时间用于交付新功能。

关于 Node.js 事件循环的常见问题

什么是 Node.js 事件循环?Node.js 事件循环是允许 Node.js 执行非阻塞异步操作的核心机制。它负责在单线程事件驱动环境中处理 I/O 操作、定时器和回调。

Node 事件循环是如何工作的?事件循环不断检查事件队列中是否有待处理的事件或回调,并按添加顺序执行它们。它在一个循环中运行,根据事件的可用性处理事件,这使得 Node.js 中的异步编程成为可能。

事件循环在 Node.js 应用程序中的作用是什么?事件循环是 Node.js 的核心,它确保应用程序保持响应能力,并且可以处理许多同时连接,而无需多线程。

Node.js 事件循环的阶段有哪些?Node.js 中的事件循环有几个阶段,包括定时器、待处理回调、空闲、轮询、检查和关闭。这些阶段决定了事件的处理方式和顺序。

事件循环处理的最常见事件类型有哪些?常见的事件包括 I/O 操作(例如,从文件读取或发出网络请求)、定时器(例如,setTimeout 和 setInterval)和回调函数(例如,来自异步操作的回调)。

Node 如何在事件循环中处理长时间运行的操作?长时间运行的 CPU 密集型操作可能会阻塞事件循环,应使用 child_process 或 worker_threads 模块等模块将其卸载到子进程或工作线程中。

调用栈和事件循环有什么区别?调用栈是一个数据结构,用于跟踪当前执行上下文中的函数调用,而事件循环负责管理异步和非阻塞操作。它们协同工作,因为事件循环调度回调和 I/O 操作的执行,然后将它们推送到调用栈中。

事件循环中的“tick”是什么?“tick”指的是事件循环的单个迭代。在每次 tick 中,事件循环都会检查是否有待处理的事件,并执行任何准备运行的回调。Ticks 是 Node.js 应用程序中的基本工作单元。

以上是Node.js事件循环:开发人员的概念和代码指南的详细内容。更多信息请关注PHP中文网其他相关文章!

声明:
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn