1

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

你应该阅读这本指南吗?

如果你编写的内容比简短的命令行脚本更复杂,那么阅读本文应该可以帮助你编写性能更高、更安全的应用程序。

本文档是在考虑Node服务器的情况下编写的,但这些概念也适用于复杂的Node应用程序,在特定于操作系统的细节有所不同,本文档以Linux为中心。

TL; DR

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

这是保持Node服务器快速的一个很好的经验法则:当在任何给定时间与每个客户端相关的工作“很小”时,Node很快。

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

为什么要避免阻塞事件循环和工作池?

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

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

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

快速回顾一下Node

Node使用事件驱动架构:它有一个用于协调的事件循环和一个用于昂贵任务的工作池。

什么代码在事件循环上运行?

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

事件循环还将完成其回调(例如,网络I/O)所产生的非阻塞异步请求。

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

什么代码在工作池上运行?

Node的工作池在libuv(docs)中实现,它公开了通用任务提交API。

Node使用工作池来处理“昂贵”的任务,这包括操作系统不提供非阻塞版本的I/O,以及特别是CPU密集型任务。

这些是使用此工作池的Node模块API:

  1. I/O密集型

    1. DNS:dns.lookup()dns.lookupService()
    2. 文件系统:除fs.FSWatcher()之外的所有文件系统API和明确同步的API都使用libuv的线程池。
  2. CPU密集型

    1. Crypto:crypto.pbkdf2()crypto.randomBytes()crypto.randomFill()
    2. Zlib:除明确同步的那些之外的所有zlib API都使用libuv的线程池。

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

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

Node如何确定接下来要运行的代码?

抽象地说,事件循环和工作池分别维护待处理事件和待处理任务的队列。

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

相反,工作池使用一个真正的队列,其条目是要处理的任务,一个Worker从此队列中弹出一个任务并对其进行处理,完成后,Worker会为事件循环引发“至少一个任务已完成”事件。

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

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

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

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

不要阻塞事件循环

事件循环通知每个新客户端连接并协调响应的生成,所有传入请求和传出响应都通过事件循环传递,这意味着如果事件循环在任何时候花费的时间太长,所有当前和新客户端都不会获得机会。

你应该确保永远不会阻塞事件循环,换句话说,每个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将Google V8引擎用于JavaScript,这对于许多常见操作来说非常快,此规则的例外是正则表达式和JSON操作,如下所述。

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

阻塞事件循环:REDOS

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

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

正则表达式(regexp)将输入字符串与模式匹配,我们通常认为正则表达式匹配需要单次通过输入字符串 — O(n)时间,其中n是输入字符串的长度,在许多情况下,确实单次通过。

不幸的是,在某些情况下,正则表达式匹配可能需要通过输入字符串的指数次数 — O(2^n)时间,指数次数意味着如果引擎需要x次以确定匹配,如果我们只在输入字符串中添加一个字符,它将需要2*x次,由于次数与所需时间成线性关系,因此该评估的效果将是阻塞事件循环。

一个易受攻击的正则表达式可能会使你的正则表达式引擎花费指数级的时间,使你暴露在“恶意输入”上的REDOS中。你的正则表达式模式是否易受攻击(即正则表达式引擎可能需要指数时间)实际上是一个难以回答的问题,并取决于你使用的是Perl、Python、Ruby、Java、JavaScript等,但是这里有一些适用于所有这些语言的经验法则:

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

如果你不确定你的正则表达式是否容易受到攻击,请记住,Node通常不会遇到报告匹配的问题,即使是易受攻击的正则表达式和长输入字符串,当存在不匹配时触发指数行为,但是在尝试通过输入字符串的许多路径之前,Node无法确定。

一个REDOS的例子

以下是将其服务器暴露给REDOS的易受攻击的正则表达式示例:

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

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

  res.sendStatus(200);
});

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

如果客户端使用filePath ///.../\n查询(100个/后跟换行符,正则表达式的“.”不会匹配),那么事件循环将永远有效,阻塞事件循环,此客户端的REDOS攻击导致所有其他客户端在正则表达式匹配完成之前不会轮到。

因此,你应该谨慎使用复杂的正则表达式来验证用户输入。

Anti-REDOS资源

有一些工具可以检查你的正则表达式是否安全,比如

  • safe-regex
  • rxxr2,然而,这些都不能捕获所有易受攻击的正则表达式。

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

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

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

几个Node核心模块具有同步昂贵的API,包括:

  • Encryption
  • Compression
  • File system
  • Child process

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

在服务器中,你不应使用以下模块中的以下同步API:

  • Encryption:

    • crypto.randomBytes(同步版本)
    • crypto.randomFillSync
    • crypto.pbkdf2Sync
    • 你还应该小心为加密和解密例程提供大量输入。
  • Compression:

    • zlib.inflateSync
    • zlib.deflateSync
  • File system:

    • 不要使用同步文件系统API,例如,如果你访问的文件位于NFS分布式文件系统中,则访问时间可能会有很大差异。
  • Child process:

    • child_process.spawnSync
    • child_process.execSync
    • child_process.execFileSync

从Node 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秒。

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

var before, res, took;

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

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

before = process.hrtime();
res = 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.
  var 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){
      var avg = sum/n;
      avgCB(avg);
  });
}

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

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

卸载

如果你需要做一些更复杂的事情,分区不是一个好选择,这是因为分区仅使用事件循环,你几乎无法在计算机上使用多个核心,请记住,事件循环应该协调客户端请求,而不是自己完成它们,对于复杂的任务,将工作循环的工作移到工​​作池上。

如何卸载

对于要卸载工作的目标工作线池,你有两个选项。

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

你不应该只是为每个客户创建一个子进程,你可以比创建和管理子进程更快地接收客户机请求,你的服务器可能会成为一个fork炸弹

卸载的缺点

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

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

一些卸载的建议

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

CPU密集型任务仅在调度其Worker时进行,并且必须将Worker调度到计算机的一个逻辑核心上,如果你有4个逻辑核心和5个Worker,则其中一个Worker无法进行,因此,你为此Worker支付了开销(内存和调度成本),并且没有获得任何回报。

I/O密集型任务涉及查询外部服务提供者(DNS,文件系统等)并等待其响应,虽然具有I/O密集型任务的Worker正在等待其响应,但它没有其他任何操作可以由操作系统取消调度,从而使另一个Worker有机会提交其请求,因此,即使关联的线程未运行,I/O密集型任务也将进行。数据库和文件系统等外部服务提供者已经过高度优化,可以同时处理许多待处理的请求,例如,文件系统将检查大量待处理的写入和读取请求,以合并冲突的更新并以最佳顺序检索文件(例如,参见这些幻灯片)。

如果你只依赖一个工作池,例如Node工作器池,然后CPU绑定和I/O绑定工作的不同特性可能会损害你的应用程序的性能。

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

卸载:结论

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

如果你采用卸载方法,请参阅有关不阻塞工作池的部分。

不要阻塞工作池

Node有一个由k个Worker组成的工作池,如果你使用上面讨论的卸载范例,你可能会有一个单独的计算工作池,相同的原则适用于此。在任何一种情况下,我们假设k远小于你可能同时处理的客户端数量,这与Node的“一个线程用于许多客户端”理念保持一致,这是其可扩展性的秘诀。

如上所述,每个Worker在继续执行工作池队列中的下一个任务之前完成其当前任务。

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

最小化任务时间的变化

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

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

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

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

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

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

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

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

任务分区

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

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

同样的原则适用于CPU绑定任务,asyncAvg示例可能不适合事件循环,但它非常适合工作池。

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

请注意,已完成的子任务数量对于工作池的吞吐量而言并不是一个有用的指标,相反,请关注完成的任务数量。

避免任务分区

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

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

这种方法的缺点是,所有这些工作池中的Worker都会产生空间和时间开销,并且会相互竞争CPU时间,请记住,每个受CPU限制的任务仅在调度时才进行,因此,你应该在仔细分析后才考虑这种方法。

工作池:结论

无论你是仅使用Node工作池还是维护单独的工作池,你都应该优化池的任务吞吐量,为此,请使用任务分区最小化任务时间的变化。

npm模块的风险

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

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

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

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

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

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

结论

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

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


上一篇:Node.js事件循环、定时器和process.nextTick()
下一篇:Node.js中的定时器

博弈
2.5k 声望1.5k 粉丝

态度决定一切