Node.js 指南(不要阻塞事件循环或工做池)

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

你应该阅读这本指南吗?

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

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

TL; DR

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

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

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

为何要避免阻塞事件循环和工做池?

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

若是一个线程须要很长时间来执行回调(事件循环)或任务(Worker),咱们称之为“阻塞”,虽然线程被阻塞表明一个客户端工做,但它没法处理来自任何其余客户端的请求,这提供了阻塞事件循环和工做池的两个动机:web

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

快速回顾一下Node

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

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

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

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

总之,事件循环执行为事件注册的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中的定时器

相关文章
相关标签/搜索