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

你是否应该读这篇指南?

如果你写出的代码并不是一行命令调用那么简单,那么阅读本篇指南可以帮助你写出高性能、更安全的程序。

此文档是从 Node.js 服务器开发的角度编写的,但这些概念也同样适用于复杂的 Node.js 应用程序。 文章中如有涉及到不同操作系统的细节,仅以 Linux 系统为代表。

概述

Node.js 通过事件循环机制(初始化和回调)的方式运行 JavaScript 代码,并且提供了一个线程池处理诸如文件 I/O 等高成本的任务。 Node 的伸缩性非常好,某些场景下它甚至比类似 Apache 等更重量级的解决方案表现更优异。 Node 可伸缩性的秘诀在于它仅使用了极少数的线程就可以处理大量客户端连接。 如果 Node.js 只需占用很少的线程,那么它就可以将更多的系统 CPU 时间和内存花费在客户端任务而不是线程的空间和时间消耗上(内存,上下文切换)。 但是同样由于 Node.js 只有少量线程,你必须非常小心的组织你的应用程序以便合理的使用它们。

这里有一个很好的经验法则,能使您的 Node.js 服务器变快: 在任何时候,当分配到每个客户端的任务是“少量”的情况下,Node.js 是非常快的。

这条法则可以应用于事件轮询中的回调任务,以及在工作线程池上的任务。

为什么不要阻塞你的事件轮询(或是工作线程池)?

Node.js 是用很少量的线程来处理大量客户端请求的。 在 Node.js 中,有两种类型的线程:一个事件循环线程(也被称为主循环,主线程,事件线程等)。另外一个是在工作线程池里的 k 个工作线程(也被称为线程池)。

如果一个线程执行一个回调函数(事件轮询线程)或者任务(工作线程)需要耗费很长时间,我们称之为“阻塞”。 当一个线程在处理某一个客户端请求时被阻塞了,它就无法处理其它客户端的请求了。 这里给出两个不能阻塞事件轮询线程和工作线程的理由:

  1. 性能:如果你在任意类型的线程上频繁处理繁重的任务,那么你的服务器的 吞吐量(请求/秒)将面临严峻考验。
  2. 安全性:如果对于特定的输入,你的某种类型的线程可能会被阻塞,那么恶意攻击者可以通过构造类似这样的“恶意输入”,故意让你的线程阻塞,然后使其它客户端请求得不到处理。这就是拒绝服务攻击

对 Node.js 的快速回顾

Node.js 使用事件驱动机制:它有一个事件轮询线程负责任务编排,和一个专门处理繁重任务的工作线程池。

哪种代码运行在事件轮询线程上?

当 Node.js 程序运行时,程序首先完成初始化部分,即处理 require 加载的模块和注册事件回调。 然后,Node.js 应用程序进入事件循环阶段,通过执行对应回调函数来对客户端请求做出回应。 此回调将同步执行,并且可能在完成之后继续注册新的异步请求。 这些异步请求的回调也会在事件轮询线程中被处理。

事件循环中同样也包含很多非阻塞异步请求的回调,如网络 I/O。

总体来说,事件轮询线程执行事件的回调函数,并且负责对处理类似网络 I/O 的非阻塞异步请求。

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

Node.js 的工作线程池是通过 libuv(相关文档)来实现的,它对外提供了一个通用的任务处理 API。

Node.js 使用工作线程池来处理“高成本”的任务。 这包括一些操作系统并没有提供非阻塞版本的 I/O 操作,以及一些 CPU 密集型的任务。

Node.js 模块中有如下这些 API 用到了工作线程池:

  1. I/O 密集型任务:
    1. DNSdns.lookup()dns.lookupService()
    2. 文件系统:所有的文件系统 API。除 fs.FSWatcher() 和那些显式同步调用的 API 之外,都使用 libuv 的线程池。
  2. CPU 密集型任务:
    1. Cryptocrypto.pbkdf2()crypto.scrypt()crypto.randomBytes()crypto.randomFill()crypto.generateKeyPair()
    2. Zlib:所有 Zlib 相关函数,除那些显式同步调用的 API 之外,都适用 libuv 的线程池。

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

为了完整性考虑,我们必须要说明,当你在事件轮询线程的一个回调中调用这些 API 时,事件轮询线程将不得不为此花费少量的额外开销,因为它必须要进入对应 API 与 C++ 桥接通讯的 Node.js C++ binding 中,从而向工作线程池提交一个任务。 和整个任务的成本相比,这些开销微不足道。这就是为什么事件循环线程总是将这些任务转交给工作线程池。 当向工作线程池中提交了某个任务,Node.js 会在 C++ binding 中为对应的 C++ 函数提供一个指针。

Node 怎么决定下一步该运行哪些代码?

抽象来说,事件轮询线程和工作池线程分别为等待中的事件回调和等待中的任务维护一个队列。

而事实上,事件轮询线程本身并不维护队列,它持有一堆要求操作系统使用诸如 epoll (Linux),kqueue (OSX),event ports (Solaris) 或者 IOCP (Windows) 等机制去监听的文件描述符。 这些文件描述符可能代表一个网络套接字,一个监听的文件等等。 当操作系统确定某个文件的描述符发生变化,事件轮询线程将把它转换成合适的事件,然后触发与该事件对应的回调函数。 你可以通过这里学习到更多有关这个过程的知识。

相对而言,工作线程池则使用一个真实的队列,里边装的都是要被处理的任务。 一个工作线程从这个队列中取出一个任务,开始处理它。当完成之后这个工作线程向事件循环线程中发出一个“至少有一个任务完成了”的消息。

对于应用设计而言,这意味着什么?

在类似 Apache 这种“一个客户端连接一个线程”的系统中,每个处理中的客户端都被分配了一个独立的线程。 如果处理某个客户端的线程阻塞了,操作系统会中断它,并给予下一个客户端请求执行的机会。 操作系统必须确保一个只需要少量开销的客户端请求不会被其他需要大量开销的客户端请求影响。

因为 Node.js 用少量的线程处理许多客户端连接,如果在处理某个客户端的时候阻塞了,在该客户端请求的回调或任务完成之前,其他等待中的任务可能都不会得到执行机会。 因此,保证每个客户端请求得到公平的执行机会变成了应用程序的责任。 这意味着,对于任意一个客户端,你不应该在一个回调或任务中做太多的事情。

这既是 Node.js 服务能够保持良好伸缩性的原因,同时也意味应用程序必须自己确保公平调度。 下一部分将探讨如何确保事件循环线程和工作线程池的公平调度。

不要阻塞你的事件轮询线程

事件轮询线程关注着每个新的客户端连接,协调产生一个回应。 所有这些进入的请求和输出的应答都要通过事件轮询线程。 这意味着如果你的事件轮询线程在某个地方花费太多的时间,所有当前和未来新的客户端请求都得不到处理机会了。

因此,你应该保证永远不要阻塞事件轮询线程。 换句话说,每个 JavaScript 回调应该快速完成。 这些当然对于 awaitPromise.then 也同样适用。

一个能确保做到这一点的方法是分析关于你回调代码的 “计算复杂度”。 如果你的回调函数在任意的参数输入下执行步骤数量都相同,那么你总能保证每个等待中的请求得到一个公平的执行机会。 如果回调根据其参数不同所需要的执行步骤数量也不同, 则应深入考虑参数复杂度增长的情况下请求的可能执行时间增长情况。

例子 1:固定执行时间的回调。

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

例子 2:一个 O(n) 回调。该回调对于小的输入 n 执行很快,但是 n 如果很大,会执行得很慢。

  1. app.get('/countToN', (req, res) => {
  2. let n = req.query.n;
  3. // n iterations before giving someone else a turn
  4. for (let i = 0; i < n; i++) {
  5. console.log(`Iter ${i}`);
  6. }
  7. res.sendStatus(200);
  8. });

例子 3:一个 O(n^2) 函数回调。 该回调对于小的输入 n 同样执行很快, 但是 n 如果很大,会比之前 O(n) 那个例子慢得多。

  1. app.get('/countToN2', (req, res) => {
  2. let n = req.query.n;
  3. // n^2 iterations before giving someone else a turn
  4. for (let i = 0; i < n; i++) {
  5. for (let j = 0; j < n; j++) {
  6. console.log(`Iter ${i}.${j}`);
  7. }
  8. }
  9. res.sendStatus(200);
  10. });

你应当注意些什么呢?

Node.js 使用谷歌的 V8 引擎处理 JavaScript,对于大部分操作确实很快。 但有个例外是正则表达式以及 JSON 的处理,下面会讨论。

但是,对于复杂的任务你应当考虑限定输入范围,拒绝会导致太长执行时间的输入。 那样的话,即便你的输入相当长而且复杂,因为你限定了输入范围,你也可以确保回调函数的执行时间在你预估的最差情况范围之内。 然后你可以评估此回调函数的最糟糕执行时间,根据你的业务场景决定此运行时间是否可以接受。

阻塞事件轮询:REDOS

一个灾难性地阻塞事件轮询的常见错误是使用“有漏洞”的正则表达式

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

一个正则表达式是一定的规则去尝试匹配一个输入的字符串。 我们通常认为正则表达式的匹配需要扫描一次输入字符串—— O(n) 时间,其中 n 是输入字符串的长度。 在大部分情况下,一次扫描的确足够。 不幸的是,在某些情况下,正则表达式匹配扫描随着输入字符串呈指数增长——时间是 O(2^n)。 指数级的扫描时间消耗意味着如果引擎需要 x 时间来确定匹配;我们的输入仅仅只增加一个字符,它将需要 2 * x 的时间。 由于扫描的消耗与所需时间呈线性关系,因此,这种正则匹配将阻塞事件循环。

易受攻击的正则表达式 是指执行时间随输入指数级增长的情况, 它使您的应用程序在“恶意输入”的情况下面临 REDOS (正则表达式拒绝服务攻击)的风险。 一个正则表达式是否易受攻击的(例如,正则表达式引擎需要指数级的时间复杂度来执行),这实际上是一个很难回答的问题。并且根据您是用 Perl、Python、Ruby、Java、JavaScript 等不同的语言情况也有所不同,但这里有在一些在所有语言里都适用的一些经验法则:

  1. 避免嵌套量词,如 (a+)*。Node.js 的正则表达式引擎可能可以快速处理某些例子,但某些则可能是易受攻击的。
  2. 避免带有“或”的重叠情况,如 (a|a)*。同样,Node.js 只能保证某些场景下可以快速匹配。
  3. 避免使用回溯,如 (a.*) \1。没有正则表达式引擎可以保证在线性时间内执行这种匹配。
  4. 如果您只需要做简单的字符串匹配,请使用 indexOf 或其他等价 API。这些是更好的选择,它们永远不会超过 O(n) 的时间。

如果您不确定正则表达式是否易受攻击,请记住:即使对于一个易受攻击的正则表达式和长输入字符串,Node.js 通常也仍然可以确保结果正确匹配。 而指数爆炸的场景是出现在用户输入并不匹配正则特征,但是 Node.js 必须要尝试去执行非常多次的扫描才能最终得出结论。

一个 REDOS 例子

下面是一个给服务器带来 REDOS 风险的示例示例:

  1. app.get('/redos-me', (req, res) => {
  2. let filePath = req.query.filePath;
  3. // REDOS
  4. if (filePath.match(/(\/.+)+$/)) {
  5. console.log('valid path');
  6. }
  7. else {
  8. console.log('invalid path');
  9. }
  10. res.sendStatus(200);
  11. });

这个有漏洞的正则例子是一个(糟糕的!)检查 Linux 上合法路径的例子。 它匹配的字符串是以 “/“ 分隔的名称序列,如 “/a/b/c”。 这是非常危险的,因为它违反了规则 1:它有一个双重嵌套的量词。

假设客户端查询的是路径 ///.../\n (100个“/”后跟一个正则表达式的“.”不匹配的换行符),则事件循环线程将持续执行且无法停止,从而阻止事件循环。 这类客户端发起的 REDOS 攻击会导致所有其它客户端在此正则表达式匹配完成之前得不到任何执行机会。

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

关于如何抵制 REDOS 的资源

这里提供了你一些工具帮助你检查你的正则表达式是否安全,像:

但是上述模块都无法保证能够捕获全部的正则表达式漏洞。

另一个方案是使用一个不同的正则表达式引擎。 你可以使用 node-re2 模块,它使用谷歌的超快正则表达式引擎 RE2。 但注意,RE2 对 Node.js 正则表达式不是 100% 兼容,所以如果你想用 node-re2 模块来处理你的正则表达式的话,请检仔细查你的表达式。 这里尤其值得提醒的是,一些特殊的复杂正则表达式不被 node-re2 支持。

如果你想匹配一些较为“明显”的东西,如网络路径或者是文件路径,请在正则表达式库中寻找到对应例子,或者使用一个 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 9 都是有效的。

阻塞事件循环:JSON DOS

JSON.parse 以及 JSON.stringify 是其它潜在高开销的操作。 这些操作的复杂度是 O(n) ,对于大型的 n 输入,消耗的时间可能惊人的长。

如果您在服务器上操作 JSON 对象(特别是来自客户端的输入),则应谨慎处理在事件循环线程上消费的对象或字符串的大小。

关于 JSON 阻止事件循环的示例:我们创建一个大小为 2^21 的 JSON 的对象,然后用 JSON.stringify 序列化它;在此字符串上运行 indexOf 函数,然后使用 JSON.parse 解析它。 JSON.stringify 字符串为 50MB。字符串化对象耗时 0.7 秒,对这个 50MB 的字符串使用 indexOf 函数耗时 0.03 秒,用了 1.3 秒解析字符串。

  1. var obj = { a: 1 };
  2. var niter = 20;
  3. var before, str, pos, res, took;
  4. for (var i = 0; i < niter; i++) {
  5. obj = { obj1: obj, obj2: obj }; // Doubles in size each iter
  6. }
  7. before = process.hrtime();
  8. str = JSON.stringify(obj);
  9. took = process.hrtime(before);
  10. console.log('JSON.stringify took ' + took);
  11. before = process.hrtime();
  12. pos = str.indexOf('nomatch');
  13. took = process.hrtime(before);
  14. console.log('Pure indexof took ' + took);
  15. before = process.hrtime();
  16. res = JSON.parse(str);
  17. took = process.hrtime(before);
  18. console.log('JSON.parse took ' + took);

有一些 npm 的模块提供了异步的 JSON API 函数,参考:

  • JSONStream,有流式操作的 API。
  • Big-Friendly JSON,有流式 API 和使用下文所概述的任务拆分思想的异步 JSON 标准 API。

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

假设你想在 JavaScript 处理一个复杂的计算,而又不想阻塞事件循环。 你有两种选择:任务拆分或任务分流。

任务拆分

你可以把你的复杂计算 拆分开,然后让每个计算分别运行在事件循环中,不过你要定期地让其它一些等待的事件执行就会。 在 JavaScript 中,用闭包很容易实现保存执行的上下文,请看如下的 2 个例子。

举个例子,假设你想计算 1n 的平均值。

例子1:不分区算平均数,开销是 O(n)

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

例子2:分区算平均值,每个 n 的异步步骤开销为 O(1)

  1. function asyncAvg(n, avgCB) {
  2. // Save ongoing sum in JS closure.
  3. var sum = 0;
  4. function help(i, cb) {
  5. sum += i;
  6. if (i == n) {
  7. cb(sum);
  8. return;
  9. }
  10. // "Asynchronous recursion".
  11. // Schedule next operation asynchronously.
  12. setImmediate(help.bind(null, i+1, cb));
  13. }
  14. // Start the helper, with CB to call avgCB.
  15. help(1, function(sum){
  16. var avg = sum/n;
  17. avgCB(avg);
  18. });
  19. }
  20. asyncAvg(n, function(avg){
  21. console.log('avg of 1-n: ' + avg);
  22. });

这个原则也可以应用到数组迭代和其它类似场景。

任务分流

如果你需要做更复杂的任务,拆分可能也不是一个好选项。这是因为拆分之后任务仍然在事件循环线程中执行,并且你无法利用机器的多核硬件能力。 请记住,事件循环线程只负责协调客户端的请求,而不是独自执行完所有任务。 对一个复杂的任务,最好把它从事件循环线程转移到工作线程池上。

如何进行任务分流?

你有两种方式将任务转移到工作线程池执行。

  1. 你可以通过开发 C++ 插件 的方式使用内置的 Node.js 工作池。稍早之前的 Node.js 版本,通过使用 NAN 的方式编译你的 C++ 插件,在新版的 Node.js 上使用 N-APInode-webworker-threads 提供了一个仅用 JavaScript 就可以访问 Node.js 的工作池的方式。
  2. 您可以创建和管理自己专用于计算的工作线程池,而不是使用 Node.js 自带的负责的 I/O 的工作线程池。最直接的方法就是使用 Child Process 或者是 cluster

应该直接为每个请求都创建一个子进程。 因为客户端请求的频率可能远远高于你的服务器能创建和管理子进程的频率,这种情况你的服务器就变成了一个 Fork 炸弹

转移到工作线程池的缺陷

这种方法的缺点是它增大了 通信开销 。 因为 Node.js 仅允许事件循环线程去查访问应用程序的“命名空间”(保存着 JavaScript 状态)。 在工作线程中是无法操作事件循环线程的命名空间中的 JavaScript 对象的。 因此,您必须序列化和反序列化任何要在线程间共享的对象。 然后,工作线程可以对属于自己的这些对象的副本进行操作,并将修改后的对象(或“补丁”) 返回到事件循环线程。

有关序列化问题,请参阅 JSON 文档部分。

一些关于分流的建议

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

CPU 密集型任务只有在该 Worker 线程被调度到时候才得到执行机会,并且必须将该任务分配到机器的某一个逻辑核心中。

如果你的机器有 4 个逻辑核心和 5 个工作线程,那这些工作线程中的某一个则无法得到执行。 因此,您实质上只是在为该工作线程白白支付开销(内存和调度开销),却无法得到任何返回。

I/O 密集型任务通常包括查询外部服务提供程序(DNS、文件系统等)并等待其响应。 当 I/O 密集型任务的工作线程正在等待其响应时,它没有其它工作可做,并且可以被操作系统重新调度,从而使另一个 Worker 有机会提交它的任务。 因此,即使关联的线程并没有被保持,I/O 密集型任务也可以持续运行。 像数据库和文件系统这样的外部服务提供程序已经经过高度优化,可以同时处理许多并发的请求。 例如,文件系统会检查一大组并发等待的写入和读取请求,以合并冲突更新并以最佳顺序读取文件(请参阅这些幻灯片)。

如果只依赖一个工作线程池(例如 Node.js 工作池),则 CPU 密集和 I/O 密集的任务的不同特效性可能会损害应用程序的性能。

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

分流:总结

对于简单的任务:比如遍历任意长数组的元素,拆分可能是一个很好的选择。 如果计算更加复杂,则分流是一种更好的方法:通信成本(即在事件循环线程和工作线程之间传递序列化对象的开销)被使用多个物理内核的好处抵消。 但是,如果你的服务器严重依赖复杂的计算,则应该重新考虑 Node.js 是否真的很适合该场景?Node.js 擅长于 I/O 密集型任务,但对于昂贵的计算,它可能不是最好的选择。

如果采用分流方法,请参阅“不阻塞工作线程池”一节。

不要阻塞你的工作线程池

Node.js 由 k 个工作线程组成了工作线程池。 如果您使用上面讨论过的任务分流思想,则可能有一个单独的计算工作池,它也适用于该原则。 在这两种情况下,让我们假设 k 比您可能需要同时处理的客户端请求数量要小得多。 这与 Node.js 的“一个线程处理许多客户端连接”的哲学是一致的,这也是它的可伸缩性秘诀。

正如在上文中讨论的,每个工作线程必须完成其当前任务,才能继续执行工作线程池队列中的下一项。

那么,处理客户请求所需的任务成本将会在不同的客户端输入场景下发生很大变化。 有些任务可以快速完成(例如读取小文件或缓存文档,或者生成少量的随机字节),而另一些则需要更长的时间(例如读取较大或未缓存的文件,或生成更多的随机字节)。 您的目标应该是使用 任务拆分尽量缩小不同请求任务执行时间的动态变化

最小化任务时间的变化

如果工作线程的当前任务比其它任务开销大很多,则他无法处理其它等待中任务。 换言之,每个相对长的任务会直接减少了工作线程池的可用线程数量,直到它的任务完成。 这是不可取的。因为从某种程度上说,工作池中的工作线程越多,工作池吞吐量(任务/秒)就越大,因此服务器吞吐量(客户端请求/秒)就越大。 一个具有相对昂贵开销任务的客户端请求将减少工作线程池整体的吞吐量,从而降低服务器的吞吐量。

为避免这种情况,应尽量减少提交给工作池的不同任务在执行时间上的变化。 虽然将 I/O 请求(DB、FS 等)访问的外部系统视为黑盒在某种角度是适当的;但您应该知道这些 I/O 请求的相对成本,并应避免提交您预估可能特别耗时的任务。

下面的两个示例应该能说明任务时间可能发生的变化。

动态执行时间示例: 长时间运行的文件系统读取

假设您的服务器必须读取文件来处理某些客户端请求。 在了解 Node.js 的文件系统的 API 之后,您选择使用 fs.readFile() 进行简单操作。 但是,fs.readFile() 是(当前)未拆分任务的:它提交一个 fs.read() 任务来读取整个文件。 如果您为某些用户读取较短的文件,并为其它人读取较长的文件,fs.readFile() 可能会在任务长度上引入显著的变化,从而损害工作线程池吞吐量。

对于最坏的情况,假设攻击者可以促使您的服务器读取 任意 文件(这是一个目录遍历漏洞)。 如果您的服务器运行的是 Linux,攻击者可以命名一个非常慢的文件:/dev/random。 对于所有实际的目的,/dev/random 是无限缓慢的;每个工作线程都被要求读取 /dev/random,这样下去将永远不会完成这项任务。 然后,攻击者提交 k 个请求,每一个被分配给一个工作线程,则其它需要使用工作线程的客户端请求将得不到执行机会。

动态执行时间示例: 长时间运行的加密操作

假设您的服务器使用 crypto.randomBytes() 来生成密码学上安全的随机字节。crypto.randomBytes() 是不拆分任务的:它创建单个 randomBytes() 任务,以生成您请求的字节数。 如果为某些用户创建的字节数较少,并且其它请求创建字节数较多;则 crypto.randomBytes() 是任务长度变化的另一个来源。

任务拆分

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

继续使用 fs.readFile() 的示例,更好的方案是使用 fs.read()(手动拆分)或 ReadStream(自动拆分)。

同样的原理也适用于 CPU 密集型任务; asyncAvg 示例可能不适用于事件循环,但它非常适用于工作线程池。

将任务拆分为子任务时,较短的任务将拆分为少量的子任务,而更长的任务将拆分为更多的子任务。 在较长任务的每个子任务之间,分配给它的工作线程可以调度执行另一个更短的任务拆分出来的子任务,从而提高工作池的总体任务吞吐量。

请注意:完成的子任务数对于工作线程池的吞吐量不是一个有用的度量指标。 相反,请关注完成的 任务 数。

避免任务拆分

我们需要明确任务拆分的目的是尽量减少任务执行时间的动态变化。 但是如果你可以人工区分较短的任务和较长的任务(例如,对数组求和或排序),则可以手动为每个类型的任务创建一个工作池。 将较短的任务和更长的任务分别路由到各自的工作线程池,也是减少任务时间动态变化的另一种方法。

建议这种方案的原因是,做任务拆分会导致额外的开销(创建工作线程,表示和操作线程池任务队列),而避免拆分会为您节省这些外成本,同时也会避免你在拆分任务的时候犯错误。

这种方案的缺点是:所有这些工作池中的工作线程都将消耗空间和时间开销,并将相互竞争 CPU 时间片。 请记住:每个 CPU 密集任务只在它被调度到的时候才会得到执行。 因此,您应该再仔细分析后才考虑此方案。

工作线程池:总结

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

为此,请使用任务拆分最小化任务执行时间的动态变化范围。

使用 npm 模块的风险

虽然 Node.js 核心模块为各种需求提供了基础支持,但有时还需要更多的功能。Node.js 的开发人员从 npm 生态系统 中获益良多,有成百上千个模块可以为你的应用开发提效。

但是,请记住,这些模块中的大多数是由第三方开发人员编写的;它们通常只能保证尽力做到很好。使用 npm 模块的开发人员应该关注如下两件事,尽管后者经常被遗忘。

  1. 它是否拥有优秀的 API 设计?
  2. 它的 API 可能会阻塞事件循环线程或工作线程吗?

许多模块对它们 API 的开销没有任何考虑,这对社区使用者是不利的。

对于简单的 API,您可以估计 API 的成本;如字符串操作的成本并不难预估。 但在许多情况下却很难预估 API 可能的开销。

如果您调用的 API 可能会做一些昂贵的事情,请着重检查成本;或者请求开发人员给出相关文档,或者自己检查源代码(并提交一个 PR 说明开销)。

请记住:即使 API 是异步的,您也可能无法预估它的每个拆分的子任务需要在工作线程或事件循环线程上花费多少时间。 例如,假设在上面给出的 asyncAvg 示例中,每个对 helper 函数的调用都累加一半的数字而不只是其中的一个。 那么这个函数仍然是异步的,但每个子任务的成本将是 O(n) 而不是 O(1), 就使得对于任意的输入 n 不再那么安全。

总结

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

要编写高吞吐量、防 DoS 攻击的 web 服务,您必须确保不管在良性或恶意输入的情况下,您的事件循环线程和您的工作线程都不会阻塞。