不要阻塞事件循环(或工作线程池)

您应该阅读本指南吗?

如果您正在编写比简单的命令行脚本更复杂的东西,阅读本文应该有助于您编写更高性能、更安全的应用程序。

本文档是针对 Node.js 服务器编写的,但这些概念也适用于复杂的 Node.js 应用程序。如果特定于操作系统的详细信息有所不同,本文档以 Linux 为中心。

摘要

Node.js 在事件循环(初始化和回调)中运行 JavaScript 代码,并提供一个工作线程池来处理昂贵的任务,例如文件 I/O。 Node.js 具有良好的可伸缩性,有时甚至比 Apache 等更重量级的方法更好。 Node.js 可伸缩性的秘诀在于它使用少量的线程来处理大量的客户端。 如果 Node.js 可以使用更少的线程,那么它可以花费更多的系统时间和内存来处理客户端,而不是为线程支付空间和时间开销(内存、上下文切换)。 但是由于 Node.js 只有几个线程,因此您必须构建您的应用程序才能明智地使用它们。

以下是保持您的 Node.js 服务器快速运行的经验法则:当任何给定时间与每个客户端相关联的工作“很小”时,Node.js 速度很快

这适用于事件循环上的回调和工作线程池上的任务。

为什么我应该避免阻塞事件循环和工作线程池?

Node.js 使用少量的线程来处理大量的客户端。 在 Node.js 中有两种类型的线程:一个事件循环(也称为主循环、主线程、事件线程等),以及工作线程池中的 k 个工作线程(也称为线程池)。

如果一个线程花费很长时间来执行回调(事件循环)或任务(工作线程),我们称其为“阻塞”。 当一个线程被阻塞并代表一个客户端工作时,它无法处理来自任何其他客户端的请求。 这为不阻塞事件循环和工作线程池提供了两个动机

  1. 性能:如果您经常在任何一种类型的线程上执行重量级活动,那么服务器的吞吐量(请求/秒)将会受到影响。
  2. 安全性:如果某些输入可能会导致您的一个线程阻塞,则恶意客户端可能会提交此“恶意输入”,使您的线程阻塞,并阻止它们处理其他客户端。 这将是拒绝服务攻击。

Node.js 的快速回顾

Node.js 使用事件驱动架构:它有一个事件循环用于编排,一个工作线程池用于昂贵的任务。

哪些代码在事件循环上运行?

开始时,Node.js 应用程序首先完成一个初始化阶段,require 模块并为事件注册回调。 然后,Node.js 应用程序进入事件循环,通过执行适当的回调来响应传入的客户端请求。 此回调同步执行,并且可以注册异步请求以在完成后继续处理。 这些异步请求的回调也将在事件循环上执行。

事件循环还将满足其回调发出的非阻塞异步请求,例如网络 I/O。

总而言之,事件循环执行为事件注册的 JavaScript 回调,并且还负责满足非阻塞异步请求,例如网络 I/O。

哪些代码在工作线程池上运行?

Node.js 的工作线程池在 libuv 中实现(文档),它公开了一个通用的任务提交 API。

Node.js 使用工作线程池来处理“昂贵”的任务。 这包括操作系统未提供非阻塞版本的 I/O,以及特别占用 CPU 的任务。

以下是使用此工作线程池的 Node.js 模块 API

  1. I/O 密集型
    1. DNSdns.lookup()dns.lookupService()
    2. 文件系统:除了 fs.FSWatcher() 之外的所有文件系统 API 以及那些显式同步的 API 都使用 libuv 的线程池。
  2. CPU 密集型
    1. 加密crypto.pbkdf2()crypto.scrypt()crypto.randomBytes()crypto.randomFill()crypto.generateKeyPair()
    2. Zlib:除了显式同步的 API 之外的所有 zlib API 都使用 libuv 的线程池。

在许多 Node.js 应用程序中,这些 API 是工作线程池任务的唯一来源。 使用 C++ 插件 的应用程序和模块可以将其他任务提交到工作线程池。

为了完整起见,我们注意到,当您从事件循环上的回调调用这些 API 之一时,事件循环会在进入该 API 的 Node.js C++ 绑定并将任务提交到工作线程池时支付一些小的设置成本。 与任务的总体成本相比,这些成本可以忽略不计,这就是事件循环卸载它的原因。 当将这些任务之一提交到工作线程池时,Node.js 会提供指向 Node.js C++ 绑定中相应 C++ 函数的指针。

Node.js 如何决定接下来运行哪些代码?

抽象地说,事件循环和工作线程池分别维护挂起的事件和挂起的任务的队列。

实际上,事件循环实际上并不维护队列。 相反,它有一组文件描述符,它要求操作系统使用类似 epoll(Linux)、kqueue(OSX)、事件端口(Solaris)或 IOCP(Windows)的机制来监视它们。 这些文件描述符对应于网络套接字、它正在监视的任何文件等等。 当操作系统表示这些文件描述符之一已准备就绪时,事件循环会将其转换为适当的事件并调用与该事件关联的回调。 您可以在 此处 了解有关此过程的更多信息。

相比之下,工作线程池使用一个真实的队列,其条目是要处理的任务。 工作线程从该队列中弹出一个任务并对其进行处理,并且在完成后,工作线程会为事件循环引发“至少一个任务已完成”事件。

这对应用程序设计意味着什么?

在像 Apache 这样的一线程服务一个客户端的系统中,每个挂起的客户端都分配有自己的线程。 如果处理一个客户端的线程阻塞,操作系统将中断它并给另一个客户端一个机会。 因此,操作系统确保需要少量工作的客户端不会因需要更多工作的客户端而受到惩罚。

由于 Node.js 使用很少的线程处理许多客户端,如果一个线程阻塞处理一个客户端的请求,那么挂起的客户端请求可能不会有机会,直到线程完成其回调或任务。 因此,公平对待客户端是您的应用程序的责任。 这意味着您不应该在任何单个回调或任务中为任何客户端做太多的工作。

这是 Node.js 可以很好地扩展的部分原因,但这也意味着您有责任确保公平的调度。 下面的部分讨论了如何确保事件循环和工作线程池的公平调度。

不要阻塞事件循环

事件循环会注意到每个新的客户端连接,并编排响应的生成。 所有传入的请求和传出的响应都通过事件循环。 这意味着如果事件循环在任何时候花费的时间过长,所有当前和新的客户端都将没有机会。

您应该确保永远不要阻塞事件循环。 换句话说,您的每个 JavaScript 回调都应该快速完成。 当然,这也适用于您的 await、您的 Promise.then 等。

确保做到这一点的一个好方法是推断您的回调的 “计算复杂度”。 如果您的回调无论其参数如何都采用恒定数量的步骤,那么您将始终为每个挂起的客户端提供公平的机会。 如果您的回调根据其参数采用不同数量的步骤,那么您应该考虑参数的长度。

示例 1:恒定时间回调。

app.get('/constant-time', (req, res) => {
  res.sendStatus(200);
});

示例 2:一个 O(n) 回调。 对于小的 n,此回调将快速运行,而对于大的 n,此回调将运行得更慢。

app.get('/countToN', (req, res) => {
  let n = req.query.n;

  // n iterations before giving someone else a turn
  for (let i = 0; i < n; i++) {
    console.log(`Iter ${i}`);
  }

  res.sendStatus(200);
});

示例 3:一个 O(n^2) 回调。 对于小的 n,此回调仍然会快速运行,但对于大的 n,它将比前面的 O(n) 示例运行得慢得多。

app.get('/countToN2', (req, res) => {
  let n = req.query.n;

  // n^2 iterations before giving someone else a turn
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      console.log(`Iter ${i}.${j}`);
    }
  }

  res.sendStatus(200);
});

您应该有多小心?

Node.js 使用 Google V8 引擎进行 JavaScript,对于许多常见操作来说,它非常快。 此规则的例外情况是正则表达式和 JSON 操作,如下所述。

但是,对于复杂的任务,您应该考虑限制输入并拒绝过长的输入。 这样,即使您的回调具有很大的复杂性,通过限制输入,您也可以确保回调不能超过最长可接受输入的最坏情况时间。 然后,您可以评估此回调的最坏情况成本,并确定其运行时间在您的上下文中是否可以接受。

阻塞事件循环:REDOS

阻塞事件循环的一种常见方法是使用“易受攻击的”正则表达式

避免易受攻击的正则表达式

正则表达式 (regexp) 用于将输入字符串与模式进行匹配。我们通常认为正则表达式的匹配需要对输入字符串进行一次遍历 --- O(n) 时间复杂度,其中 n 是输入字符串的长度。在许多情况下,一次遍历确实就足够了。不幸的是,在某些情况下,正则表达式的匹配可能需要对输入字符串进行指数级的遍历 --- O(2^n) 时间复杂度。指数级的遍历意味着如果引擎需要 x 次遍历来确定是否匹配,那么如果我们向输入字符串添加一个字符,它将需要 2*x 次遍历。由于遍历次数与所需时间成线性关系,因此这种评估的影响将是阻塞事件循环。

易受攻击的正则表达式是指您的正则表达式引擎可能需要指数级时间来处理的正则表达式,这会使您在处理 "恶意输入" 时面临 REDOS(正则表达式拒绝服务) 攻击。您的正则表达式模式是否易受攻击(即正则表达式引擎是否可能需要指数级时间来处理它)实际上是一个难以回答的问题,并且因您使用的是 Perl、Python、Ruby、Java、JavaScript 等而异,但以下是一些适用于所有这些语言的经验法则:

  1. 避免嵌套量词,例如 (a+)*。V8 的正则表达式引擎可以快速处理其中的一些,但另一些则容易受到攻击。
  2. 避免使用具有重叠子句的 OR,例如 (a|a)*。同样,这些有时会很快。
  3. 避免使用反向引用,例如 (a.*) \1。没有正则表达式引擎可以保证在 线性时间内评估这些内容。
  4. 如果您正在进行简单的字符串匹配,请使用 indexOf 或本地等效项。它会更便宜,并且永远不会超过 O(n)

如果您不确定您的正则表达式是否易受攻击,请记住,即使对于易受攻击的正则表达式和较长的输入字符串,Node.js 通常也可以毫无问题地报告匹配。指数行为是在不匹配时触发的,但 Node.js 必须尝试通过输入字符串的许多路径才能确定。

REDOS 示例

这是一个易受攻击的正则表达式示例,它将服务器暴露于 REDOS 攻击

app.get('/redos-me', (req, res) => {
  let filePath = req.query.filePath;

  // REDOS
  if (filePath.match(/(\/.+)+$/)) {
    console.log('valid path');
  } else {
    console.log('invalid path');
  }

  res.sendStatus(200);
});

此示例中的易受攻击的正则表达式是一种(糟糕的!)检查 Linux 上有效路径的方法。它匹配一系列以 "/" 分隔的名称的字符串,例如 "/a/b/c"。它很危险,因为它违反了规则 1:它具有双重嵌套量词。

如果客户端使用 filePath ///.../\n 进行查询(100 个 / 后跟一个换行符,正则表达式的 "." 不会匹配),则事件循环实际上会永远运行下去,从而阻塞事件循环。此客户端的 REDOS 攻击会导致所有其他客户端在正则表达式匹配完成之前都无法获得处理机会。

因此,您应该对使用复杂的正则表达式来验证用户输入保持警惕。

反 REDOS 资源

有一些工具可以检查您的正则表达式的安全性,例如

但是,这些工具都无法捕获所有易受攻击的正则表达式。

另一种方法是使用不同的正则表达式引擎。您可以使用 node-re2 模块,该模块使用 Google 的极速 RE2 正则表达式引擎。但请注意,RE2 与 V8 的正则表达式并不 100% 兼容,因此如果您换入 node-re2 模块来处理您的正则表达式,请检查是否存在回归。并且 node-re2 不支持特别复杂的正则表达式。

如果您尝试匹配一些“显而易见”的内容,例如 URL 或文件路径,请在 正则表达式库中查找示例或使用 npm 模块,例如 ip-regex

阻塞事件循环:Node.js 核心模块

几个 Node.js 核心模块具有同步的、开销很大的 API,包括

这些 API 的开销很大,因为它们涉及大量的计算(加密、压缩)、需要 I/O(文件 I/O)或可能两者都需要(子进程)。这些 API 旨在方便脚本编写,但不适合在服务器上下文中使用。如果您在事件循环上执行它们,它们将比典型的 JavaScript 指令花费更长的时间才能完成,从而阻塞事件循环。

在服务器中,您不应使用来自这些模块的以下同步 API

  • 加密
    • crypto.randomBytes(同步版本)
    • crypto.randomFillSync
    • crypto.pbkdf2Sync
    • 您还应该小心向加密和解密例程提供大型输入。
  • 压缩
    • zlib.inflateSync
    • zlib.deflateSync
  • 文件系统
    • 不要使用同步文件系统 API。例如,如果您访问的文件位于 分布式文件系统(例如 NFS)中,则访问时间可能会有很大差异。
  • 子进程
    • child_process.spawnSync
    • child_process.execSync
    • child_process.execFileSync

截至 Node.js v9,此列表已相当完整。

阻塞事件循环:JSON DOS

JSON.parseJSON.stringify 是其他可能开销很大的操作。虽然这些操作在输入长度上是 O(n) 的,但对于较大的 n 而言,它们可能会花费相当长的时间。

如果您的服务器操纵 JSON 对象,尤其是来自客户端的 JSON 对象,您应该注意在事件循环上处理的对象或字符串的大小。

示例:JSON 阻塞。我们创建一个大小为 2^21 的对象 obj 并对其进行 JSON.stringify,在字符串上运行 indexOf,然后 JSON.parse 它。JSON.stringify 后的字符串大小为 50MB。字符串化对象需要 0.7 秒,在 50MB 字符串上运行 indexOf 需要 0.03 秒,解析字符串需要 1.3 秒。

let obj = { a: 1 };
let niter = 20;

let before, str, pos, res, took;

for (let i = 0; i < niter; i++) {
  obj = { obj1: obj, obj2: obj }; // Doubles in size each iter
}

before = process.hrtime();
str = JSON.stringify(obj);
took = process.hrtime(before);
console.log('JSON.stringify took ' + took);

before = process.hrtime();
pos = str.indexOf('nomatch');
took = process.hrtime(before);
console.log('Pure indexof took ' + took);

before = process.hrtime();
res = JSON.parse(str);
took = process.hrtime(before);
console.log('JSON.parse took ' + took);

有一些 npm 模块提供异步 JSON API。例如,请参见

  • JSONStream,它具有流 API。
  • Big-Friendly JSON,它具有流 API 以及使用下面概述的事件循环上的分区范例的标准 JSON API 的异步版本。

不阻塞事件循环的复杂计算

假设您想在 JavaScript 中进行复杂计算而不阻塞事件循环。您有两种选择:分区或卸载。

分区

您可以分区您的计算,以便每个计算都在事件循环上运行,但定期让步(将处理机会交给)其他挂起的事件。在 JavaScript 中,可以很容易地将正在进行的任务的状态保存在闭包中,如下面的示例 2 所示。

对于一个简单的示例,假设您要计算数字 1n 的平均值。

示例 1:未分区的平均值,成本为 O(n)

for (let i = 0; i < n; i++) sum += i;
let avg = sum / n;
console.log('avg: ' + avg);

示例 2:分区的平均值,n 个异步步骤中的每一个步骤的成本为 O(1)

function asyncAvg(n, avgCB) {
  // Save ongoing sum in JS closure.
  let sum = 0;
  function help(i, cb) {
    sum += i;
    if (i == n) {
      cb(sum);
      return;
    }

    // "Asynchronous recursion".
    // Schedule next operation asynchronously.
    setImmediate(help.bind(null, i + 1, cb));
  }

  // Start the helper, with CB to call avgCB.
  help(1, function (sum) {
    let avg = sum / n;
    avgCB(avg);
  });
}

asyncAvg(n, function (avg) {
  console.log('avg of 1-n: ' + avg);
});

您可以将此原则应用于数组迭代等等。

卸载

如果您需要做一些更复杂的事情,分区不是一个好的选择。这是因为分区仅使用事件循环,并且您将无法从您的机器上几乎肯定可用的多个内核中受益。请记住,事件循环应该协调客户端请求,而不是自己满足它们。对于复杂的任务,请将工作从事件循环移到工作线程池上。

如何卸载

您有两种选择可以卸载工作到目标工作线程池。

  1. 您可以通过开发 C++ 插件来使用内置的 Node.js 工作线程池。在旧版本的 Node 上,使用 NAN 构建您的 C++ 插件,在新版本上使用 N-APInode-webworker-threads 提供了一种仅 JavaScript 的方式来访问 Node.js 工作线程池。
  2. 您可以创建和管理您自己的专用于计算而不是 Node.js 面向 I/O 的工作线程池。最直接的方法是使用 子进程集群

不应仅为每个客户端创建一个 子进程。您可以比创建和管理子进程更快地接收客户端请求,并且您的服务器可能会成为 fork 炸弹

卸载的缺点

卸载方法的缺点是它会产生通信成本形式的开销。只有事件循环才能看到应用程序的“命名空间”(JavaScript 状态)。从工作线程中,您无法操纵事件循环的命名空间中的 JavaScript 对象。相反,您必须序列化和反序列化您希望共享的任何对象。然后,工作线程可以对其自己的这些对象副本进行操作,并将修改后的对象(或“补丁”)返回给事件循环。

有关序列化问题,请参见 JSON DOS 部分。

一些卸载建议

您可能希望区分 CPU 密集型和 I/O 密集型任务,因为它们具有显着不同的特征。

CPU 密集型任务只有在其工作线程被调度时才能取得进展,并且必须将该工作线程调度到您的机器的 逻辑核心之一上。如果您有 4 个逻辑核心和 5 个工作线程,则其中一个工作线程无法取得进展。因此,您要为该工作线程支付开销(内存和调度成本),而没有获得任何回报。

I/O 密集型任务涉及查询外部服务提供商(DNS、文件系统等)并等待其响应。当具有 I/O 密集型任务的工作线程正在等待其响应时,它没有其他任何事情可做,并且可以被操作系统取消调度,从而使另一个工作线程有机会提交其请求。因此,即使在关联线程未运行时,I/O 密集型任务也将取得进展。诸如数据库和文件系统之类的外部服务提供商已经过高度优化,可以同时处理许多挂起的请求。例如,文件系统将检查大量挂起的写入和读取请求,以合并冲突的更新并以最佳顺序检索文件。

如果您仅依赖一个工作线程池,例如 Node.js 工作线程池,则 CPU 密集型和 I/O 密集型工作的不同特征可能会损害应用程序的性能。

因此,您可能希望维护一个单独的计算工作线程池。

卸载:结论

对于简单的任务,例如迭代任意长度的数组的元素,分区可能是一个不错的选择。如果您的计算更复杂,则卸载是一种更好的方法:通信成本(即在事件循环和工作线程池之间传递序列化对象的开销)被使用多个内核的好处所抵消。

但是,如果您的服务器严重依赖复杂计算,则您应该考虑 Node.js 是否真的是一个好的选择。Node.js 在 I/O 密集型工作中表现出色,但对于开销很大的计算,它可能不是最佳选择。

如果您采用卸载方法,请参见有关不阻塞工作线程池的部分。

不要阻塞工作线程池

Node.js 有一个由 k 个工作线程组成的工作线程池。如果您使用上面讨论的卸载范例,您可能有一个单独的计算工作线程池,相同的原则适用于该线程池。在任何一种情况下,让我们假设 k 远小于您可能同时处理的客户端数量。这符合 Node.js 的“一个线程服务于多个客户端”的理念,这是其可伸缩性的秘诀。

如上所述,每个工作线程在继续处理工作线程池队列中的下一个任务之前,都会完成其当前任务。

现在,处理客户请求所需的任务的成本会有所不同。有些任务可以很快完成(例如,读取短文件或缓存文件,或生成少量随机字节),而另一些任务则需要更长的时间(例如,读取较大的或未缓存的文件,或生成更多的随机字节)。你的目标应该是最小化任务时间的变化,你应该使用任务分区来实现这一点。

最小化任务时间的变化

如果一个 Worker 当前的任务比其他任务昂贵得多,那么它将无法处理其他待处理的任务。换句话说,每个相对较长的任务实际上会将 Worker 池的大小减少一个,直到它完成。这是不可取的,因为在一定程度上,Worker 池中的 Worker 越多,Worker 池的吞吐量(任务/秒)就越大,从而服务器的吞吐量(客户端请求/秒)就越大。一个具有相对昂贵任务的客户端会降低 Worker 池的吞吐量,进而降低服务器的吞吐量。

为了避免这种情况,你应该尽量减少提交给 Worker 池的任务长度的变化。虽然将 I/O 请求访问的外部系统(DB、FS 等)视为黑盒是合适的,但你应该意识到这些 I/O 请求的相对成本,并应避免提交你预期会特别长的请求。

两个例子应该可以说明任务时间的可能变化。

变化示例:长时间运行的文件系统读取

假设你的服务器必须读取文件才能处理某些客户端请求。在查阅 Node.js 文件系统 API 后,你选择使用 fs.readFile() 以简化操作。但是,v10 之前的 fs.readFile() 没有分区:它提交了一个跨越整个文件的 fs.read() 任务。如果为某些用户读取较短的文件,为其他用户读取较长的文件,则 fs.readFile() 可能会导致任务长度的显着变化,从而损害 Worker 池的吞吐量。

对于最坏的情况,假设攻击者可以说服你的服务器读取一个任意文件(这是一个 目录遍历漏洞)。如果你的服务器正在运行 Linux,则攻击者可以命名一个非常慢的文件:/dev/random。实际上,/dev/random 无限慢,每个被要求从 /dev/random 读取的 Worker 将永远无法完成该任务。然后,攻击者提交 k 个请求,每个 Worker 一个,并且没有其他使用 Worker 池的客户端请求会取得进展。

变化示例:长时间运行的加密操作

假设你的服务器使用 crypto.randomBytes() 生成加密安全的随机字节。crypto.randomBytes() 未分区:它创建一个 randomBytes() 任务以生成你请求的尽可能多的字节。如果为某些用户创建的字节更少,为其他用户创建的字节更多,则 crypto.randomBytes() 是任务长度变化的另一个来源。

任务分区

时间成本可变的Task会损害Worker Pool的吞吐量。 为了尽可能地减少 Task 时间的变化,你应该将每个 Task 分区成成本相当的子 Task。 当每个子 Task 完成时,它应该提交下一个子 Task,当最终的子 Task 完成时,它应该通知提交者。

要继续 fs.readFile() 示例,你应该改用 fs.read()(手动分区)或 ReadStream(自动分区)。

相同的原则适用于 CPU 密集型任务; asyncAvg 示例可能不适合事件循环,但它非常适合 Worker 池。

当你将一个任务分成子任务时,较短的任务会扩展成少量子任务,而较长的任务会扩展成大量子任务。在较长任务的每个子任务之间,分配给它的 Worker 可以处理来自另一个较短任务的子任务,从而提高 Worker 池的整体任务吞吐量。

请注意,已完成的子任务数量不是 Worker 池吞吐量的有用指标。相反,请关注已完成的任务数量。

避免任务分区

回想一下,任务分区的目的是最小化任务时间的变化。如果你可以区分较短的任务和较长的任务(例如,对数组求和与对数组排序),你可以为每类任务创建一个 Worker 池。将较短的任务和较长的任务路由到单独的 Worker 池是最小化任务时间变化的另一种方法。

赞成这种方法,分区任务会产生开销(创建 Worker 池任务表示和操作 Worker 池队列的成本),避免分区可以节省你访问 Worker 池的额外成本。它还可以防止你在分区任务时犯错误。

这种方法的缺点是,所有这些 Worker 池中的 Worker 都会产生空间和时间开销,并且会相互竞争 CPU 时间。请记住,每个 CPU 密集型任务只有在被调度时才会取得进展。因此,你只有在经过仔细分析后才应考虑这种方法。

Worker 池:结论

无论你只使用 Node.js Worker 池还是维护单独的 Worker 池,你都应该优化池的任务吞吐量。

为此,请使用任务分区来最小化任务时间的变化。

npm 模块的风险

虽然 Node.js 核心模块为各种应用程序提供了构建块,但有时还需要更多。 Node.js 开发人员从 npm 生态系统 中受益匪浅,其中数十万个模块提供功能来加速你的开发过程。

但是请记住,这些模块中的大多数是由第三方开发人员编写的,并且通常仅以尽力而为的保证发布。 使用 npm 模块的开发人员应该关心两件事,尽管后者经常被遗忘。

  1. 它是否遵守其 API?
  2. 它的 API 是否可能阻塞事件循环或 Worker? 许多模块没有努力表明其 API 的成本,这对社区不利。

对于简单的 API,你可以估计 API 的成本; 字符串操作的成本并不难理解。 但在许多情况下,不清楚 API 可能花费多少。

如果你正在调用可能做一些昂贵事情的 API,请仔细检查成本。 要求开发人员记录它,或者自己检查源代码(并提交 PR 记录成本)。

请记住,即使 API 是异步的,你也不知道它可能在每个分区中的 Worker 或事件循环上花费多少时间。 例如,假设在上面给出的 asyncAvg 示例中,每次调用辅助函数都会对一半的数字求和,而不是对其中一个求和。 那么这个函数仍然是异步的,但是每个分区的成本将是 O(n),而不是 O(1),这使得它对于任意值的 n 来说不太安全。

结论

Node.js 有两种类型的线程:一个事件循环和 k 个 Worker。 事件循环负责 JavaScript 回调和非阻塞 I/O,Worker 执行与完成异步请求的 C++ 代码相对应的任务,包括阻塞 I/O 和 CPU 密集型工作。 两种类型的线程一次最多处理一项活动。 如果任何回调或任务花费很长时间,运行它的线程就会被阻塞。 如果你的应用程序进行阻塞回调或任务,这可能会导致吞吐量(客户端/秒)降低,在最坏的情况下,完全拒绝服务。

要编写高吞吐量、更防范 DoS 的 Web 服务器,你必须确保在良性和恶意输入上,你的事件循环和 Worker 都不会阻塞。