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

Yourtion 创作于:2021-02-26     全文约 5538 字, 预计阅读时间为 16 分钟

概述

Node.js 通过事件循环机制(初始化和回调)的方式运行 JavaScript 代码,并且提供了一个线程池处理诸如文件 I/O 等高成本的任务。

Node 的伸缩性非常好,某些场景下它甚至比类似 Apache 等更重量级的解决方案表现更优异。Node 可伸缩性的秘诀在于它仅使用了极少数的线程就可以处理大量客户端连接。如果 Node.js 只需占用很少的线程,那么它就可以将更多的系统 CPU 时间和内存花费在客户端任务而不是线程的空间和时间消耗上(内存,上下文切换)。但是同样由于 Node.js 只有少量线程,你必须非常小心的组织你的应用程序以便合理的使用它们。

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

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

为什么不要阻塞你的事件轮询

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

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

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

关于 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 密集型任务:
    • DNS:dns.lookup()dns.lookupService()
    • 文件系统:所有的文件系统 API。除 fs.FSWatcher() 和那些显式同步调用的 API 之外,都使用 libuv 的线程池
  2. CPU 密集型任务:
    • Crypto:crypto.pbkdf2()crypto.scrypt()crypto.randomBytes()crypto.randomFill()crypto.generateKeyPair()
    • Zlib:所有 Zlib 相关函数,除那些显式同步调用的 API 之外,都适用 libuv 的线程池

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

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

下一步该运行哪些代码?

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

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

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

这意味着什么?

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

这既是 Node.js 服务能够保持良好伸缩性的原因,同时也意味应用程序必须自己确保公平调度。

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

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

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

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

你应当注意些什么呢?

Node.js 使用谷歌的 V8 引擎处理 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 请求的相对成本,并应避免提交您预估可能特别耗时的任务。

任务拆分

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

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

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

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

避免任务拆分

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

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

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

工作线程池:总结

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

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

总结

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

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

参考

  • https://nodejs.org/en/docs/guides/dont-block-the-event-loop/
  • https://nodejs.org/zh-cn/docs/guides/dont-block-the-event-loop/

原文链接:https://blog.yourtion.com/nodejs-not-block-eventloop.html