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

40次阅读

共计 10580 个字符,预计需要花费 27 分钟才能阅读完成。

不要阻塞事件循环(或工作池)
你应该阅读这本指南吗?
如果你编写的内容比简短的命令行脚本更复杂,那么阅读本文应该可以帮助你编写性能更高、更安全的应用程序。
本文档是在考虑 Node 服务器的情况下编写的,但这些概念也适用于复杂的 Node 应用程序,在特定于操作系统的细节有所不同,本文档以 Linux 为中心。
TL; DR
Node.js 在事件循环(初始化和回调)中运行 JavaScript 代码,并提供一个工作池来处理如文件 I / O 之类昂贵的任务,Node 可以很好地扩展,有时比 Apache 等更重量级的方法更好,Node 可扩展性的秘诀在于它使用少量线程来处理许多客户端。如果 Node 可以使用更少的线程,那么它可以将更多的系统时间和内存用于客户端,而不是为线程支付空间和时间开销(内存,上下文切换),但由于 Node 只有几个线程,因此你必须明智地使用它们来构建应用程序。
这是保持 Node 服务器快速的一个很好的经验法则:当在任何给定时间与每个客户端相关的工作“很小”时,Node 很快。
这适用于事件循环上的回调和工作池上的任务。
为什么要避免阻塞事件循环和工作池?
Node 使用少量线程来处理许多客户端,在 Node 中有两种类型的线程:一个事件循环(又称主循环、主线程、事件线程等),以及一个工作池(也称为线程池)中的 k 个 Worker 的池。
如果一个线程需要很长时间来执行回调(时间循环)或任务(Worker),我们称之为“阻塞”,虽然线程被阻塞代表一个客户端工作,但它无法处理来自任何其他客户端的请求,这提供了阻塞事件循环和工作池的两个动机:

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

快速回顾一下 Node
Node 使用事件驱动架构:它有一个用于协调的事件循环和一个用于昂贵任务的工作池。
什么代码在事件循环上运行?
当它们开始时,Node 应用程序首先完成初始化阶段,require 模块并注册事件的回调,然后,Node 应用程序进入事件循环,通过执行适当的回调来响应传入的客户端请求,此回调同步执行,并可以注册异步请求以在完成后继续处理,这些异步请求的回调也将在事件循环上执行。
事件循环还将完成其回调(例如,网络 I /O)所产生的非阻塞异步请求。
总之,事件循环执行为事件注册的 JavaScript 回调,并且还负责完成非阻塞异步请求,如网络 I /O。
什么代码在工作池上运行?
Node 的工作池在 libuv(docs)中实现,它公开了通用任务提交 API。
Node 使用工作池来处理“昂贵”的任务,这包括操作系统不提供非阻塞版本的 I /O,以及特别是 CPU 密集型任务。
这些是使用此工作池的 Node 模块 API:

I/ O 密集型

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

CPU 密集型

Crypto:crypto.pbkdf2()、crypto.randomBytes()、crypto.randomFill()。
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 等,但是这里有一些适用于所有这些语言的经验法则:

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

如果你不确定你的正则表达式是否容易受到攻击,请记住,Node 通常不会遇到报告匹配的问题,即使是易受攻击的正则表达式和长输入字符串,当存在不匹配时触发指数行为,但是在尝试通过输入字符串的许多路径之前,Node 无法确定。
一个 REDOS 的例子
以下是将其服务器暴露给 REDOS 的易受攻击的正则表达式示例:
app.get(‘/redos-me’, (req, res) => {
let filePath = req.query.filePath;

// REDOS
if (filePath.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.parse 和 JSON.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 所示。
举一个简单的例子,假设你想要计算数字 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);
});
你可以将此原则应用于数组迭代等。
卸载
如果你需要做一些更复杂的事情,分区不是一个好选择,这是因为分区仅使用事件循环,你几乎无法在计算机上使用多个核心,请记住,事件循环应该协调客户端请求,而不是自己完成它们,对于复杂的任务,将工作循环的工作移到工​​作池上。
如何卸载
对于要卸载工作的目标工作线池,你有两个选项。

你可以通过开发 C ++ 插件来使用内置的 Node 工作池,在旧版本的 Node 上,使用 NAN 构建 C ++ 插件,在较新版本上使用 N -API,node-webworker-threads 提供了一种访问 Node 的工作池的 JavaScript 方法。
你可以创建和管理专用于计算的工作池,而不是 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 模块的开发人员应该关注两件事,尽管后者经常被遗忘。

它是否遵循其 API?
它的 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()

正文完
 0