1

你应该阅读本指南吗?

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

在编写本文档时,主要是基于Node服务器。但里面的原则也适用于其它复杂的Node应用程序。在没有特别说明操作系统的情况下,默认为Linux。

TL; DR

Node.js在事件循环(初始化和回调)中运行JavaScript代码,并提供工作池来处理成本比较高的任务,如文件I/O。 Node服务节点有很强的扩展能力,有时能提供比相对较重的Apache更好的解决方案。关键点就在于它使用少量线程来处理多客户端连接。如果Node可以使用更少的线程,那么它可以将更多的系统时间和内存用于客户端,而不是为线程(内存,上下文切换)占用额外空间和时间。但也因为Node只有少量的线程,因此在构建应用程序时,必须明智地使用它们。

这里有一些保持Node服务器快速稳健运行的经验法则: 当在任何给定时间与每个客户端关联的工作“很小”时,Node服务会很快。

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

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

Node使用少量的线程来处理多个客户端连接。在Node中有两种类型的线程:

  • 一个事件循环(又称主循环,主线程,事件线程等);
  • k工作池(也称为线程池)中的工作池

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

  1. 性能:如果经常在任一类型的线程上执行重量级活动,则服务器的吞吐量(请求/秒)将受到影响;
  2. 安全性:如果某个输入可能会阻塞某个线程,则恶意客户端可能会提交此“恶意输入”,使线程阻塞,从而阻塞其它客户端上的处理。这就很方便地的造成了 拒绝服务攻击

快速回顾一下Node

Node使用事件驱动架构:它有一个事件循环用于调度 和 一个处理阻塞任务的工作池。

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

在开始时,Node应用程序首先完成初始化阶段,即require模块和注册事件的回调。然后,Node应用程序进入事件循环,通过执行相应的回调来响应传入的客户端请求。此回调同步执行,并在完成后又有可能注册新的异步请求。这些新异步请求的回调也将在事件循环上执行。

事件循环中还包含其它一些非阻塞异步请求(例如,网络I/O)产生的回调。

总之,Event Loop执行这些注册为某些事件的JavaScript回调,并且还负责完成非阻塞异步请求,如网络I/O.

什么代码在线程池(Worker Pool)中运行

Node的线程池通过libuv(docs)实现。libuv暴露出一组任务提交的API。

Node使用线程池(Worker Pool)处理比较费时的任务。例操作系统没有提供非阻塞版本的I/O, CPU密集型任务等。

会用到线程池的Node模块:

  • I/O密集型

    • DNS: dns.lookup(), dns.lookupService()
    • fs: 除了fs.FSWatcher()和所有明确同步调用的文件API,剩下的都会用到libuv实现的线程池
  • CPU密集型

    • Crypto: crypto.pbkdf2(), crypto.randomBytes(), crypto.randomFill()
    • Zlib: 除了明确声明使用同步调用的API,剩下的都会用到libuv的线程池

在大多数Node应用程序中,这些API是Worker Pool的唯一任务源。实际上,使用C++插件的应用程序和模块也可以提交任务给工作池。

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

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

理论上,Event Loop 和 Worker Pool 分别操作待处理的事件 和 待完成的任务。

实际上,Event Loop并不真正维护队列。相应的,它有一组文件描述符,这些文件描述符被操作系统使用epoll(Linux),kqueue(OSX),事件端口(Solaris)或IOCP(Windows)等机制进行监视。这些文件描述符对应于网络套接字,它正在观看的任何文件,等等。当操作系统说其中一个文件描述符准备就绪时,Event Loop会将其转换为相应的事件并调用与该事件关联的回调。您可以在此处详细了解此过程。

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

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

在像Apache这样的一个线程对应一个客户端连接的系统中,每个挂起的客户端都被分配了自己的线程。如果处理一个客户端的线程阻塞时,操作系统会中断它并切换到另一个处理客户端请求的线程。因此操作系统确保需要少量工作的客户不会受到需要更多工作的客户的影响。

因为Node用很少的线程数量处理许多客户端连接,如果一个线程处理一个客户端的请求时被阻塞,那么其它被挂起的客户端请求会一直得不到执行机会,直到该线程完成其回调或任务。 因此,保证客户端的连接都受到公平对待是你编写程序的工作内容。 这也就是说,在Node 程序中,不应该在任何单个回调或任务中为任何客户端做太多比较耗时的工作。

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

不要阻塞事件循环

事件循环通知每个新客户端连接并协调对客户端的响应。也就是说,所有传入请求和传出响应都通过事件循环处理。这意味着如果事件循环在任何时候花费的时间太长,所有当前的 以及新进来的客户端连接都不会获得响应机会。

所以,要确保在任何时候都不应该阻塞事件循环。换句话说,每一个JavaScript回调应当能够快速完成。这当然也适用于你awaitPromise.then等。

确保这一点的一个好方法是推断回调的“计算复杂度”。如果你的回调需要一定数量的步骤,无论它的参数是什么,总是会给每个连接的客户段提供一个合理的响应。如果回调根据其参数采用不同的步骤数,那么就应该考虑不同参数可能导致的计算复杂度。

例子1: 恒定时间的回调

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

例子2: 时间复杂度O(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)而言,会特别的慢。而且n+1 对 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,这对于许多常见操作来说非常快。但是有例外:regexp和JSON操作。

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

阻止事件循环: REDOS(Regular expression Denial of Service - ReDoS)

一种比较常见的阻塞事件循环的方式是使用比较“脆弱”的正则表达式。

正则表达式(regexp)将输入字符串与特定的模式匹配。通常我们认为正则表达式只需要匹配一次输入的字符串----时间复杂度是O(n),n是输入字符串的长度。在许多情况下,确实只需要一次便可完成匹配。但在某些情况下,正则表达式可能需要对传入的字符串进行多次匹配----时间复杂度是O(2^n)。指数级增长意味着如果引擎需要x次回溯来确定匹配,那么如果我们在输入字符串中再添加一个字符,则至少需要2*x次回溯。由于回溯次数与所需时间成线性关系,因此这种情况会阻塞事件循环。

一个“脆弱”的正则表达式在你的正则匹配引擎上运行可能需要指数时间,导致你可能遭受REDOS(Regular expression Denial of Service - ReDoS)的“邪恶输入”。但是正则表达式模式是否易受攻击(即正则表达式引擎可能需要指数时间)实际上是一个难以回答的问题,并且取决于您使用的是Perl,Python,Ruby,Java,JavaScript等。但有一些经验法则是适用于所有语言的:

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

如果您不确定您的正则表达式是否容易受到攻击,但你需要明确的是即使易受攻击的正则表达式和长输入字符串,Node通常无法报告匹配项。当不匹配时, Node在尝试匹配的输入字符串的许多路径之前,是无法确定是否会触发指数级的时间长度。

一个REDOS(Regular expression Denial of Service - 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 / s后跟换行符“。”将不匹配的换行符),那么事件循环将永远有效,阻塞事件循环。此客户端的REDOS攻击导致所有其他客户端在regexp匹配完成之前不会响应。

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

反REDOS资源

有一些工具可以检查你的regexp是否安全,比如

但是,它们并不能保证识别所有易受攻击的正则表达式。

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

如果您正在尝试匹配一些特别常见的内容,例如URL或文件路径,请在regexp库中查找示例或使用npm模块,例如ip-regex

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

Node里有一些核心模块,包含一些比较耗时的同步API:

这些模块中的一些API比较耗时,主要是因为需要大量的计算(encryption, compression),I/O操作(file I/O)或者两者都有(child process)。 这些API旨在方便编写脚本,但是在服务端也许并不适用。如果在事件循环中调用这些API,将会花费更多的时间,从而导致事件循环阻塞。

在服务端程序中,注意一下同步API的使用。

  • 加密:

    • crypto.randomBytes (同步版)
    • crypto.randomFillSync
    • crypto.pbkdf2Sync
    • 您还应该注意为加密和解密例程提供大量输入。
  • 压缩:

    • zlib.inflateSync
    • zlib.deflateSync
  • 文件系统

    • 不要使用同步文件系统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对象的大小 或者 字符串的长度。

JSON 阻塞示例:我们创建一个大小为2 ^ 21 的obj对象,然后在字符串上JSON.stringify运行indexOf,然后运行JSON.parse。该JSON.stringify“d字符串为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 < len; i++) {
  obj = { obj1: obj, obj2: obj }; // Doubles in size each iter
}

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

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

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

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

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

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

假设您想在JavaScript中执行复杂计算而不阻塞事件循环。您有两种选择:partitioning切割或offloading转嫁。

partitioning切割
您可以对计算进行分区,以便每个计算都在事件循环上运行,但会定期产生(转向)其他待处理事件。在JavaScript中,很容易在闭包中保存正在进行的任务的状态,如下面的示例2所示。

举个简单的例子,假设你想要的数字的平均计算1到n。

示例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);
});

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

offloading
如果您需要做一些更复杂的事情,partitioning也许不是一个好选择。这是因为partitioning仅借助于事件循环。而您几乎无法使用多核系统。 请记住,事件循环应该是调度客户端请求,而不是自己完成它们。 对于复杂的任务,可将工作的转嫁到工​​作池上。

How to offloading
对于要卸载工作的目标工作线池,您有两个选项。

  • 您可以通过开发C++插件来使用内置的Node Worker Pool 。在旧版本的Node上,使用NAN构建C++插件,在较新版本上使用N-API。node-webworker-threads提供了一种访问Node的Worker Pool的JavaScript方法。
  • 您可以创建和管理专用于计算的工作池,而不是Node的I/O主题工作池。最直接的方法是使用子进程或群集。你应该不是简单地创建一个子进程为每个客户端。您可以比创建和管理子项更快地接收客户端请求,并且您的服务器可能会成为一个分叉炸弹。

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

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

一些卸载的建议
您需要区分CPU密集型和I/O密集型任务,因为它们具有明显不同的特征。

CPU密集型任务仅在调度其Worker时进行,并且必须将Worker调度到计算机的一个逻辑核心上。如果您有4个逻辑核心和5个工作线程,则其中一个工作线程会被挂起。所以,您需要为此Worker支付开销(内存和调度成本),并且没有获得任何回报。

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

如果您只依赖一个工作池,例如Node Worker Pool,那么CPU绑定和I/O绑定工作的不同特性可能会损害您的应用程序的性能。

因此,您可能希望维护一个单独的Computation Worker Pool。

offloadin结论
对于简单的任务,例如迭代任意长数组的元素,partitioning可能是一个不错的选择。如果您的计算更复杂,则offloading是一种更好的方法。虽然会有通信成本,但在事件循环和工作池之间传递序列化对象的开销,会被使用多个核心的好处所抵消。

但是,如果您的服务器在很大程度上依赖于复杂的计算,那么您应该考虑Node是否真的适合。Node擅长I/O操作相关的工作,但对于复杂的计算,它可能不是最好的选择。

如果您采用offloading方法,请参阅有关 不要阻塞工作池的部分。

不要阻塞工作池

Node有一个由kWorkers 组成的Worker Pool 。如果您使用上面讨论的Offloading范例,您可能有一个单独的计算工作池适用上述原则。在任何一种情况下,我们假设它k比可能同时处理的客户端数量小得多。这与Node的“一个线程对应多个客户端”的理念保持一致,这是其具有高可扩展性的关键点。

如上所述,每个Worker在继续执行Worker Pool队列中的下一个Task之前,会先完成当前Task。

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

最小化任务时间变化

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

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

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

时间变化示例一:长时间的文件读取

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

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

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

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

任务拆分

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

继续说上面fs.readFile()的例子,您应该使用fs.read()(手动分区)或ReadStream(自动分区)。

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

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

请注意,已完成的子任务数量对于工作线程池的吞吐量而言并不是一个有用的度量标准。相反,最终完成任务的数量才是关注点。

不需要做任务拆分的任务

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

之所以要支持这种方法,是因为切割的任务会产生额外开销(创建工作池任务表示和操作工作池队列的成本)。并且这样还可以避免不必要的任务拆分,从而节省额外的访问工作池的成本。它还可以防止您在分区任务时出错。

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

Worker Pool:结论

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

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

npm模块带来的风险

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

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

  1. Does it honor its APIs?
  2. 它的API可能会阻塞事件循环或工作者吗?许多模块都没有努力表明其API的成本,这对社区不利。

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

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

请记住,即使API是异步的,您也不知道它可能花费多少时间在Worker或每个分区的Event Loop上。例如,假设在asyncAvg上面给出的示例中,对助手函数的每次调用将一半的数字相加而不是其中一个。那么这个函数仍然是异步的,但每个拆分的任务时间复杂度仍然是O(n),而不是O(1)。所以在使用任意值的n时,会使安全性降低很多。

结论

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

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


胡斐
107 声望3 粉丝

前端大海里的小鱼