Skip to content

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

您应该阅读本指南吗?

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

本文档主要针对 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 快速回顾

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. Cryptocrypto.pbkdf2()crypto.scrypt()crypto.randomBytes()crypto.randomFill()crypto.generateKeyPair()
    2. Zlib:除了那些显式同步的 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:一个常数时间的回调。

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

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

js
app.get('/countToN', (req, res) => {
  let n = req.query.n;
  // 在让其他人有机会之前,进行 n 次迭代
  for (let i = 0; i < n; i++) {
    console.log(`Iter ${i}`);
  }
  res.sendStatus(200);
});

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

js
app.get('/countToN2', (req, res) => {
  let n = req.query.n;
  // 在让其他人有机会之前,进行 n^2 次迭代
  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:

js
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。stringify 对象需要 0.7 秒,在 50MB 字符串上进行 indexOf 操作需要 0.03 秒,而解析字符串需要 1.3 秒。

js
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 }; // 每次迭代大小翻倍
}
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)

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

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

js
function asyncAvg(n, avgCB) {
  // 将正在进行的和保存在 JS 闭包中。
  let sum = 0;
  function help(i, cb) {
    sum += i;
    if (i == n) {
      cb(sum);
      return;
    }
    // “异步递归”。
    // 异步调度下一个操作。
    setImmediate(help.bind(null, i + 1, cb));
  }
  // 启动 helper,使用 CB 来调用 avgCB。
  help(1, function (sum) {
    let avg = sum / n;
    avgCB(avg);
  });
}
asyncAvg(n, function (avg) {
  console.log('avg of 1-n: ' + avg);
});

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

卸载

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

如何卸载

你有两种选择用于卸载工作的工作线程池目标。

  1. 你可以使用内置的 Node.js 工作线程池,通过开发 C++ 插件。 在旧版本的 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的“一个线程服务多个客户端”的理念,这是其可伸缩性的秘诀。

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

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

最小化任务时间的变化

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

为了避免这种情况,你应该尽量减少提交给工作线程池的任务长度的变化。虽然将 I/O 请求访问的外部系统(数据库、文件系统等)视为黑盒是合适的,但你应该了解这些 I/O 请求的相对成本,并且应该避免提交你预期会特别长的请求。

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

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

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

对于最坏的情况,假设攻击者可以诱使您的服务器读取任意文件(这是一种 目录遍历漏洞)。如果您的服务器正在运行 Linux,则攻击者可以指定一个非常慢的文件:/dev/random。在所有实际用途中,/dev/random 的速度非常慢,以至于每个被要求从 /dev/random 读取文件的 Worker 都永远无法完成该任务。然后,攻击者提交 k 个请求,每个 Worker 一个请求,并且使用 Worker Pool 的其他客户端请求将无法取得进展。

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

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

任务分区

具有可变时间成本的任务会损害 Worker Pool 的吞吐量。为了尽可能减少任务时间的变化,您应该将每个任务划分为成本相当的子任务。当每个子任务完成时,它应该提交下一个子任务,并且当最后一个子任务完成时,它应该通知提交者。

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

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

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

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

避免任务划分

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

鉴于此方法,划分任务会产生开销(创建工作池任务表示和操作工作池队列的成本),而避免划分可以节省您额外访问工作池的成本。它还可以避免您在划分任务时犯错。

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

工作池:结论

无论您仅使用 Node.js 工作池还是维护单独的工作池,都应优化池的任务吞吐量。

为此,请使用任务划分来最小化任务时间的差异。

npm 模块的风险

虽然 Node.js 核心模块为各种应用程序提供了构建块,但有时需要更多功能。Node.js 开发人员受益于 npm 生态系统,其中包含成千上万个模块,这些模块提供加速开发过程的功能。

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

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

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

如果您正在调用可能执行昂贵操作的 API,请仔细检查成本。要求开发人员记录它,或者自己检查源代码(并提交 PR 记录成本)。

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

结论

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

要编写一个高吞吐量、更能抵抗 DoS 攻击的 Web 服务器,您必须确保在良性和恶意输入下,您的事件循环和工作线程都不会被阻塞。