Node.js的概述:架构、API、事件循环、并发性

178 阅读17分钟

这篇博文概述了Node.js的工作原理:

  • 它的架构是什么样子的。
  • 它的API是如何结构化的。
    • 它的全局变量和内置模块的一些亮点。
  • 它如何通过事件循环在单线程中运行JavaScript。
  • 这个平台上的并发JavaScript的选项。

Node.js平台

下图概述了Node.js的结构:

Node.js应用程序可用的API包括:

  • ECMAScript标准库(是该语言的一部分)
  • Node.js的API(这不是语言本身的一部分)。
    • 一些API是通过全局变量提供的。
      • 特别是跨平台的网络API,如fetchCompressionStream ,都属于这一类。
      • 但也有少数仅有Node.js的API是全球性的--例如process
    • 其余的Node.js API是通过内置模块提供的--例如,'node:path' (处理文件系统路径的函数和常量)和'node:fs' (与文件系统有关的功能)。

Node.js的API部分用JavaScript实现,部分用C++实现。后者是需要与操作系统接口的。

Node.js通过一个嵌入式V8 JavaScript引擎(与谷歌Chrome浏览器使用的引擎相同)运行JavaScript。

全局Node.js变量

这些是Node全局变量的几个亮点:

  • crypto 让我们可以访问一个与网络兼容的加密API

  • console 与浏览器中相同的全局变量有很多重合( 等)。console.log()

  • fetch() 让我们使用Fetch浏览器API

  • process 包含一个 Process类的实例,让我们可以访问命令行参数、标准输入、标准输出等。

  • structuredClone() 是一个与浏览器兼容的函数,用于克隆对象。

  • URL 是一个与浏览器兼容的类,用于处理URLs。

本博文中还提到了更多的全局变量。

内置的Node.js模块

Node的大部分API都是通过模块提供的。这些是一些经常使用的模块(按字母顺序排列)。

模块'node:module' 包含函数buildinModules() ,它返回一个包含所有内置模块的指定器的数组。

Node.js函数的不同风格

在本节中,我们使用以下导入方式:

import * as fs from 'node:fs';

Node的函数有三种不同的风格。让我们以内置模块'node:fs' 为例来看看:

  • 带有普通函数的同步风格--比如说。
  • 两种异步风格:
    • 带有基于回调的函数的异步风格--例如。
    • 基于承诺的函数的异步风格--例如。

我们刚才看到的三个例子,展示了具有类似功能的函数的命名规则:

  • 一个基于回调的函数有一个基本名称。fs.readFile()
  • 它的基于承诺的版本有同样的名字,但在不同的模块中。fsPromises.readFile()
  • 它的同步版本的名称是基本名称加上后缀 "Sync"。fs.readFileSync()

让我们仔细看看这三种风格是如何工作的。

同步函数

同步函数是最简单的--它们立即返回值并将错误作为异常抛出:

try {
  const result = fs.readFileSync('/etc/passwd', {encoding: 'utf-8'});
  console.log(result);
} catch (err) {
  console.error(err);
}

基于承诺的函数

基于承诺的函数返回承诺,这些承诺以结果实现,以错误拒绝。

注意A行的模块说明:基于承诺的API位于一个不同的模块中。

"给没有耐心的程序员的JavaScript "中对承诺有更详细的解释

基于回调的函数

基于回调的函数将结果和错误传递给作为其最后参数的回调。

fs.readFile('/etc/passwd', {encoding: 'utf-8'},
  (err, result) => {
    if (err) {
      console.error(err);
      return;
    }
    console.log(result);
  }
);

这种风格在Node.js文档中有更详细的解释。

Node.js的事件循环

默认情况下,Node.js在一个单线程中执行所有的JavaScript,即主线程。主线程持续运行事件循环--一个执行JavaScript块的循环。每个块是一个回调,可以被认为是一个合作安排的任务。第一个任务包含代码(来自一个模块或标准输入),我们用它来启动Node.js。其他任务通常是后来添加的,由于:

  • 代码手动添加任务
  • 与文件系统的I/O(输入或输出),与网络套接字,等等
  • 等等

事件循环的第一个近似值是这样的:

就是说,主线程运行的代码类似于。

事件循环从任务队列中取出回调,并在主线程中执行它们。如果任务队列是空的,去排队就会阻塞(暂停主线程)。

我们将在后面探讨两个话题:

  • 如何从事件循环中退出。
  • 如何绕过JavaScript在单线程中运行的限制。

为什么这个循环被称为事件循环?许多任务是为了响应事件而添加的,例如,当输入数据准备好被处理时,由操作系统发送的事件。

回调是如何被添加到任务队列中的?这些都是常见的可能性:

  • JavaScript代码可以将任务添加到队列中,以便以后执行。
  • 当一个事件发射器(事件源)发射事件时,事件监听器的调用会被添加到任务队列中。
  • Node.js API中基于回调的异步操作遵循这种模式。
    • 我们要求什么,并给Node.js一个回调函数,它可以用它向我们报告结果。
    • 最终,该操作要么在主线程中运行,要么在外部线程中运行(后面会详细介绍)。
    • 当它完成后,回调的调用被添加到任务队列中。

下面的代码显示了一个基于回调的异步操作的运行情况。它从文件系统中读取一个文本文件。

这就是输出结果:

AFTER
Don’t forget!

fs.readFile() 在另一个线程中执行读取该文件的代码。在这种情况下,代码成功并将这个回调添加到任务队列中:

() => handleResult(null, 'Don’t forget!')

运行到完成使代码更简单

Node.js如何运行JavaScript代码的一个重要规则是。每个任务在其他任务运行之前完成("运行至完成")。我们可以在前面的例子中看到:B行的'AFTER' 在A行的结果被记录之前被记录下来,因为初始任务在调用handleResult() 的任务运行之前完成。

运行到完成意味着任务的生命周期不会重叠,我们不必担心共享数据在后台被改变。这简化了Node.js的代码。下一个例子演示了这一点。它实现了一个简单的HTTP服务器。

我们通过node server.mjs 来运行这段代码。之后,代码启动并等待HTTP请求。我们可以通过使用网络浏览器去http://localhost:8080 。每次我们重新加载该HTTP资源时,Node.js都会调用A行开始的回调。它用变量requestCount (B行)的当前值提供一个消息,并增加它(C行)。

回调的每一次调用都是一个新的任务,变量requestCount 在任务之间共享。由于运行到完成,它很容易被读取和更新。不需要与其他同时运行的任务同步,因为没有任何任务。

为什么Node.js代码会在单线程中运行?

为什么Node.js代码默认在单线程中运行(有一个事件循环)?这有两个好处:

  • 正如我们已经看到的,如果只有一个单线程,任务之间的数据共享会更简单。

  • 在传统的多线程代码中,一个需要较长时间才能完成的操作会阻塞当前线程,直到该操作完成。这种操作的例子是读取文件或处理HTTP请求。执行许多这样的操作是昂贵的,因为我们每次都要创建一个新的线程。有了事件循环,每个操作的成本就会降低,尤其是在每个操作都不怎么做的时候。这就是为什么基于事件循环的网络服务器可以比基于线程的服务器处理更高的负载。

鉴于Node的一些异步操作是在主线程以外的线程中运行的(很快会有更多的介绍),并通过任务队列向JavaScript报告,Node.js并不是真正的单线程。相反,我们使用一个单线程来协调那些并发和异步运行的操作(在主线程中)。

至此,我们对事件循环的第一次考察结束。如果表面上的解释对你来说已经足够了,可以随意跳过本节的其余部分。继续阅读以了解更多细节。

真正的事件循环有多个阶段

真正的事件循环有多个任务队列,它分多个阶段从这些队列中读取数据(你可以在GitHub仓库中查看一些JavaScript代码nodejs/node。下图显示了这些阶段中最重要的几个:

图中所示的事件循环阶段是做什么的?

  • 阶段 "timer "调用被添加到队列中的定时任务

  • 阶段 "poll "检索和处理I/O事件,并从其队列中运行I/O相关的任务。

  • 阶段 "检查"("即时阶段")执行通过以下方式安排的任务。

每个阶段都运行,直到其队列为空或直到处理了最大数量的任务。除了 "poll",每个阶段都要等到下一次轮到它的时候,才会处理在它运行期间增加的任务。

阶段 "投票"

  • 如果轮询队列不是空的,轮询阶段将通过它并运行其任务。
  • 一旦轮询队列是空的。
    • 如果有setImmediate() 任务,则处理推进到 "检查 "阶段。
    • 如果有准备好的定时器任务,处理将推进到 "定时器 "阶段。
    • 否则,这个阶段会阻塞整个主线程,并等待新的任务被添加到轮询队列中(或直到这个阶段结束,见下文)。这些任务会被立即处理。

如果这个阶段花费的时间超过了与系统相关的时间限制,它就会结束,并运行下一个阶段。

下一个轮询任务和微任务

在每个被调用的任务之后,运行一个 "子循环",由两个阶段组成:

子阶段处理:

  • 下一周期的任务,通过process.nextTick() 排队。
  • 微任务,如通过queueMicrotask() 、Promise反应等排队。

Next-tick任务是Node.js特有的,Microtasks是一个跨平台的Web标准(见MDN的支持表)。

这个子循环一直运行到两个队列都是空的。在其运行过程中添加的任务会被立即处理--该子循环不会等到下一次轮到它。

比较直接调度任务的不同方法

我们可以使用下面的函数和方法来向其中一个任务队列添加回调:

  • 定时任务(阶段 "定时器")。
    • setTimeout() (网络标准)
    • setInterval() (网络标准)
  • 非定时任务(阶段 "检查")。
    • setImmediate() (Node.js专用)
  • 紧接着当前任务运行的任务。
    • process.nextTick() (Node.js专用)
    • queueMicrotask(): (web标准)

需要注意的是,当通过延迟为一个任务计时时,我们是在指定任务运行的最早时间。Node.js不可能总是在预定的时间准确地运行它们,因为它只能在任务之间检查是否有定时的任务到期。因此,一个长期运行的任务可能会导致定时任务的迟到。

下一秒任务和微任务与普通任务的对比

考虑一下下面的代码。

我们使用setImmediate() ,以避免ESM模块的一个特殊性。它们是在微任务中执行的,这意味着如果我们在ESM模块的顶层queue微任务,它们会在下一个tick任务之前运行。正如我们接下来要看到的,这在大多数其他情况下是不同的。

这是前面代码的输出:

nextTick 1
nextTick 2
Promise reaction 1
queueMicrotask 1
Promise reaction 2
queueMicrotask 2
setTimeout 1
setTimeout 2
setImmediate 1
setImmediate 2

观察:

  • 所有的next-tick任务都是在enqueueTasks() 之后立即执行。

  • 它们的后面是所有的微任务,包括Promise反应。

  • 阶段 "定时器 "是在立即阶段之后。这就是定时任务被执行的时候。

  • 我们在立即("检查")阶段添加了立即任务(A行和B行)。它们在输出中最后出现,这意味着它们不是在当前阶段执行的,而是在下一个立即阶段执行的。

在下一阶段排队的任务和微任务

接下来的代码检查了如果我们在下一阶段排队一个下一周期的任务和在微任务阶段排队一个微任务会发生什么:

setImmediate(() => {
  setImmediate(() => console.log('setImmediate 1'));
  setTimeout(() => console.log('setTimeout 1'), 0);

  process.nextTick(() => {
    console.log('nextTick 1');
    process.nextTick(() => console.log('nextTick 2'));
  });

  queueMicrotask(() => {
    console.log('queueMicrotask 1');
    queueMicrotask(() => console.log('queueMicrotask 2'));
    process.nextTick(() => console.log('nextTick 3'));
  });
});

这就是输出结果:

nextTick 1
nextTick 2
queueMicrotask 1
queueMicrotask 2
nextTick 3
setTimeout 1
setImmediate 1

观察到的情况:

  • 下一个ick任务首先被执行。

  • "nextTick 2 "在next-tick阶段被排队并立即执行。只有当next-tick队列为空时才继续执行。

  • 对于微任务也是如此。

  • 我们在微任务阶段排队 "nextTick 3",执行循环回到next-tick阶段。这些子阶段不断重复,直到它们的队列都是空的。只有到那时,执行才会进入下一个全局阶段。首先是 "定时器 "阶段("setTimeout 1")。然后是即时阶段("setImmediate 1")。

饿死事件循环阶段

下面的代码探讨了哪些类型的任务可以饿死事件循环阶段(防止它们通过无限递归运行)。

计时器 "阶段和即时阶段不会执行在其阶段中被排队的任务。这就是为什么timers()immediate() 不会饿死在 "poll "阶段报告的fs.readFile() (也有一个Promise反应,但我们在此忽略)。

由于next-tick任务和微任务的调度方式,nextTick()microtasks() 都会阻止最后一行中的输出。

Node.js应用程序何时退出?

在事件循环的每个迭代结束时,Node.js会检查是否是时候退出。它保留了一个待定超时的参考计数(对于定时任务):

  • 通过setImmediate(),setInterval(), 或setTimeout() 安排一个定时任务会增加参考计数。
  • 运行一个定时任务则会减少参考计数。

如果在事件循环迭代结束时引用计数为零,Node.js就会退出。

我们可以在下面的例子中看到这一点。

Node.js一直在等待,直到timeout() 返回的Promise被履行。为什么呢?因为我们在A行安排的任务使事件循环保持活力。

相比之下,创建Promise并不增加引用计数。

在这种情况下,在A行的await ,执行暂时离开了这个(主)任务。在事件循环结束时,引用计数为零,Node.js退出。然而,退出并不成功。也就是说,退出代码不是0,是13("未完成的顶层等待")

我们可以手动控制超时是否保持事件循环的活力。默认情况下,通过setImmediate(),setInterval(), 和setTimeout() 安排的任务,只要它们处于等待状态,就会保持事件循环的活力。这些函数返回Timeout的实例,该类的方法.unref() 改变了默认值,这样超时激活就不会阻止Node.js退出。方法.ref() 恢复了默认值。

Tim Perry提到了一个关于.unref() 的用例。他的库使用setInterval() 来重复运行一个后台任务。该任务阻止了应用程序的退出。他通过.unref() 修正了这个问题。

libuv:为Node.js处理异步I/O(以及更多)的跨平台库

libuv是一个用C语言编写的库,支持许多平台(Windows、macOS、Linux等)。Node.js使用它来处理I/O和其他问题。

libuv如何处理异步I/O

网络I/O是异步的,不会阻塞当前线程。这样的I/O包括:

  • TCP
  • UDP
  • 终端I/O
  • 管道(Unix域套接字、Windows命名的管道等)。

为了处理异步I/O,libuv使用了本地的内核API,并订阅了I/O事件(Linux上的epoll;BSD Unix包括macOS上的kqueue;SunOS上的事件端口;Windows上的IOCP)。当事件发生时,它就会得到通知。所有这些活动,包括I/O本身,都发生在主线程上。

libuv如何处理阻塞式I/O

有些本地I/O API是阻塞的(不是异步的)--例如,文件I/O和一些DNS服务。libuv从线程池(所谓的 "工作池")中的线程调用这些API。这使得主线程可以异步地使用这些API。

libuv超越I/O的功能

libuv对Node.js的帮助不仅仅是在I/O方面。其他功能包括

  • 在线程池中运行任务
  • 信号处理
  • 高分辨率时钟
  • 线程和同步原语

顺便提一下,libuv有自己的事件循环,其源代码可以在GitHub仓库libuv/libuv (函数uv_run()中查看。

用用户代码逃离主线程

如果我们想保持Node.js对I/O的响应,我们应该避免在主线程任务中进行长期运行的计算。有两种方法可以做到这一点。

  • 分区:我们可以将计算分割成小块,并通过setImmediate() 。这样,事件循环就可以在各片断之间执行I/O:

    • 好处是我们可以在每个片断中执行I/O。
    • 缺点是,我们仍然会降低事件循环的速度。
  • 卸载:我们可以在不同的线程或进程中执行我们的计算:

    • 缺点是我们不能从主线程以外的线程执行I/O,而且与外部代码的通信变得更加复杂。
    • 好处是,我们不会减慢事件循环的速度,我们可以更好地利用多个处理器内核,而且其他线程的错误不会影响到主线程。

接下来的几个小节涵盖了一些卸载的选项。

工作者线程

工作线程实现了跨平台的Web Workers API,但有一些区别--例如:

  • 工作线程必须从一个模块中导入,Web Worker 通过全局变量进行访问。

  • 在Worker内部,监听消息和发布消息是通过浏览器中全局对象的方法完成的。在Node.js上,我们导入parentPort ,而不是。

  • 我们可以从工作者中使用大多数Node.js API。在浏览器中,我们的选择是比较有限的(我们不能使用DOM等)。

  • 在Node.js上,与浏览器相比,更多的对象是可以转移的(所有对象的类都扩展了内部类JSTransferable

一方面,Worker Threads真的是线程。它们比进程更轻量级,并与主线程在同一进程中运行。

另一方面:

  • 每个工作者都运行自己的事件循环。
  • 每个工作者都有自己的JavaScript引擎实例和自己的Node.js实例--包括独立的全局变量。
    • (具体来说,每个工作者是一个 V8隔离它有自己的JavaScript堆,但与其他线程共享其操作系统堆)。
  • 线程之间的数据共享是有限的:
    • 我们可以通过SharedArrayBuffers共享二进制数据/数字。
    • Atomics提供了原子操作和同步原语,在使用SharedArrayBuffers时有帮助。
    • 通道消息传递API让我们通过双向通道发送数据("消息")。数据要么被克隆(复制),要么被转移(移动)。后者更有效,而且只被少数数据结构支持。

欲了解更多信息,请参阅Node.js关于工人线程的文档

集群

Cluster是一个针对Node.js的API。它让我们运行Node.js进程的集群,我们可以用它来分配工作负载。这些进程是完全隔离的,但共享服务器端口。它们可以通过通道传递JSON数据进行通信。

如果我们不需要进程隔离,我们可以使用更轻量级的Worker Threads。

子进程

子进程是另一个针对Node.js的API。它让我们产生新的进程,运行本地命令(通常通过本地shells)。这个API在博文 "从Node.js执行shell命令 "中有所介绍

这篇博文的来源

Node.js事件循环。

关于事件循环的视频(刷新了本博文所需的一些背景知识):

  • "Node的事件循环由内而外"(作者Sam Roberts)解释了为什么操作系统增加了对异步I/O的支持;哪些操作是异步的,哪些不是(必须在线程池中运行);等等。
  • "Node.js的事件循环。Not So Single Threaded"(作者:Bryan Hughes)包含了多任务的简要历史(合作多任务、抢占式多任务、对称多线程、异步多任务);进程与线程;同步运行I/O与线程池;等等。