不要阻塞事件循环(或工作池)
您应该阅读本指南吗?
如果您编写的代码比简短的命令行脚本更复杂,阅读本指南将有助于您编写更高性能、更安全的应用程序。
本文档是针对 Node.js 服务器编写的,但这些概念也适用于复杂的 Node.js 应用程序。在操作系统特定细节不同的情况下,本文档以 Linux 为中心。
摘要
Node.js 在事件循环(初始化和回调)中运行 JavaScript 代码,并提供一个工作池来处理诸如文件 I/O 之类的昂贵任务。Node.js 可扩展性很好,有时比 Apache 等更重量级的方案更好。Node.js 可扩展性的秘诀在于它使用少量线程来处理许多客户端。如果 Node.js 可以使用更少的线程,那么它就可以将更多系统的时间和内存用于处理客户端,而不是将空间和时间开销用于线程(内存、上下文切换)。但由于 Node.js 只有几个线程,因此必须合理地构建应用程序以使用它们。
以下是一个保持 Node.js 服务器快速运行的经验法则:当与每个客户端在任何给定时间相关的任务“很小”时,Node.js 速度很快。
这适用于事件循环上的回调和工作池上的任务。
为什么我应该避免阻塞事件循环和工作池?
Node.js 使用少量线程来处理许多客户端。在 Node.js 中,有两种类型的线程:一个事件循环(也称为主循环、主线程、事件线程等),以及工作池中 k
个工作线程的池(也称为线程池)。
如果一个线程花费很长时间来执行回调(事件循环)或任务(工作线程),我们称之为“阻塞”。当一个线程被阻塞以代表一个客户端工作时,它无法处理来自任何其他客户端的请求。这提供了两个避免阻塞事件循环和工作池的动机。
- 性能:如果您定期在任何一种线程上执行重量级活动,服务器的吞吐量(请求/秒)将会下降。
- 安全性:如果可能的话,对于某些输入,您的线程之一可能会阻塞,恶意客户端可以提交此“恶意输入”,使您的线程阻塞,并阻止它们处理其他客户端。这将是一种拒绝服务攻击。
快速回顾 Node
Node.js 使用事件驱动架构:它有一个用于协调的事件循环和一个用于昂贵任务的工作池。
什么代码在事件循环上运行?
当 Node.js 应用程序启动时,它们首先完成一个初始化阶段,require
模块并为事件注册回调。然后,Node.js 应用程序进入事件循环,通过执行相应的回调来响应传入的客户端请求。此回调同步执行,并且可以注册异步请求以在完成之后继续处理。这些异步请求的回调也将执行在事件循环上。
事件循环还将完成其回调发出的非阻塞异步请求,例如网络 I/O。
总之,事件循环执行为事件注册的 JavaScript 回调函数,并且还负责完成非阻塞异步请求,例如网络 I/O。
哪些代码在工作池中运行?
Node.js 的工作池在 libuv 中实现 (docs),它公开了一个通用的任务提交 API。
Node.js 使用工作池来处理“昂贵”的任务。这包括操作系统没有提供非阻塞版本的 I/O,以及特别 CPU 密集型任务。
以下是使用此工作池的 Node.js 模块 API
- I/O 密集型
- CPU 密集型
在许多 Node.js 应用程序中,这些 API 是工作池任务的唯一来源。使用 C++ 附加模块 的应用程序和模块可以向工作池提交其他任务。
为了完整起见,我们注意到,当您从事件循环上的回调函数调用其中一个 API 时,事件循环会在进入该 API 的 Node.js C++ 绑定并向工作池提交任务时支付一些微不足道的设置成本。这些成本与任务的总成本相比微不足道,这就是事件循环卸载它的原因。在向工作池提交这些任务之一时,Node.js 提供指向 Node.js C++ 绑定中相应 C++ 函数的指针。
Node.js 如何决定接下来运行哪些代码?
抽象地说,事件循环和工作池分别维护着待处理事件和待处理任务的队列。
实际上,事件循环并没有真正维护队列。相反,它有一组文件描述符,它使用类似 epoll (Linux)、kqueue (OSX)、事件端口 (Solaris) 或 IOCP (Windows) 的机制来请求操作系统监控这些文件描述符。这些文件描述符对应于网络套接字、它正在监视的任何文件等等。当操作系统说这些文件描述符之一已准备好时,事件循环会将其转换为相应的事件,并调用与该事件关联的回调函数。您可以在 这里 了解有关此过程的更多信息。
相比之下,工作池使用一个真正的队列,其条目是要处理的任务。一个工作进程从该队列中弹出任务并进行处理,完成后,工作进程会为事件循环触发“至少完成一项任务”事件。
这对应用程序设计意味着什么?
在像 Apache 这样的每个客户端一个线程的系统中,每个待处理的客户端都被分配了自己的线程。如果处理一个客户端的线程阻塞,操作系统将中断它并让另一个客户端轮流处理。因此,操作系统确保需要少量工作的客户端不会因需要更多工作的客户端而受到惩罚。
由于 Node.js 使用少量线程处理许多客户端,如果一个线程阻塞处理一个客户端的请求,那么待处理的客户端请求可能无法轮流处理,直到该线程完成其回调或任务。因此,客户端的公平处理是您应用程序的责任。这意味着您不应该在任何单个回调或任务中为任何客户端做太多工作。
这就是 Node.js 可以很好地扩展的部分原因,但这也意味着您有责任确保公平调度。接下来的部分将讨论如何确保事件循环和工作池的公平调度。
不要阻塞事件循环
事件循环会注意到每个新的客户端连接并协调响应的生成。所有传入请求和传出响应都通过事件循环。这意味着如果事件循环在任何时候花费的时间过长,所有当前和新的客户端都将无法轮流处理。
您应该确保永远不要阻塞事件循环。换句话说,您的每个 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.js 使用 Google V8 引擎来执行 JavaScript,对于许多常见的操作来说,V8 引擎非常快。这个规则的例外是正则表达式和 JSON 操作,将在下面讨论。
但是,对于复杂的任务,你应该考虑限制输入并拒绝过长的输入。这样,即使你的回调函数的复杂度很高,通过限制输入,你也能确保回调函数在最长可接受输入上不会花费超过最坏情况的时间。然后,你可以评估这个回调函数的最坏情况成本,并确定其运行时间在你的环境中是否可以接受。
阻塞事件循环:REDOS
一种常见的灾难性阻塞事件循环的方法是使用“易受攻击的”正则表达式.
避免易受攻击的正则表达式
正则表达式 (regexp) 将输入字符串与模式进行匹配。我们通常认为正则表达式匹配需要对输入字符串进行一次遍历 --- O(n)
时间,其中 n
是输入字符串的长度。在许多情况下,一次遍历确实就足够了。不幸的是,在某些情况下,正则表达式匹配可能需要对输入字符串进行指数级的遍历 --- O(2^n)
时间。指数级的遍历意味着,如果引擎需要 x
次遍历来确定匹配,那么如果我们在输入字符串中只添加一个字符,它将需要 2*x
次遍历。由于遍历次数与所需时间呈线性关系,因此这种评估的影响将是阻塞事件循环。
易受攻击的正则表达式是指你的正则表达式引擎可能在上面花费指数级时间,使你容易受到REDOS攻击的“恶意输入”。你的正则表达式模式是否易受攻击(即正则表达式引擎可能在上面花费指数级时间)实际上是一个很难回答的问题,并且取决于你使用的是 Perl、Python、Ruby、Java、JavaScript 等,但以下是一些适用于所有这些语言的经验法则
- 避免使用嵌套量词,例如
(a+)*
。V8 的正则表达式引擎可以快速处理其中的一些,但其他一些则容易受到攻击。 - 避免使用带有重叠子句的 OR,例如
(a|a)*
。同样,这些有时很快。 - 避免使用反向引用,例如
(a.*) \1
。没有正则表达式引擎可以保证以线性时间评估这些。 - 如果你正在进行简单的字符串匹配,请使用
indexOf
或本地等效项。它会更便宜,并且永远不会花费超过O(n)
时间。
如果你不确定你的正则表达式是否易受攻击,请记住,即使对于易受攻击的正则表达式和长输入字符串,Node.js 通常也能够报告匹配。当存在不匹配但 Node.js 无法确定直到它尝试遍历输入字符串的许多路径时,就会触发指数级行为。
一个 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 攻击会导致所有其他客户端无法获得处理机会,直到正则表达式匹配完成。
因此,您应该谨慎使用复杂的正则表达式来验证用户输入。
反 REDOS 资源
有一些工具可以检查您的正则表达式是否安全,例如
- safe-regex
- rxxr2。但是,这些工具都无法捕获所有易受攻击的正则表达式。
另一种方法是使用不同的正则表达式引擎。您可以使用 node-re2 模块,它使用 Google 的超高速 RE2 正则表达式引擎。但请注意,RE2 与 V8 的正则表达式并不完全兼容,因此如果您使用 node-re2 模块来处理您的正则表达式,请检查是否有回归。而且,node-re2 不支持特别复杂的正则表达式。
如果您试图匹配“明显”的东西,例如 URL 或文件路径,请在 正则表达式库 中查找示例,或使用 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
- 文件系统
- 子进程
child_process.spawnSync
child_process.execSync
child_process.execFileSync
截至 Node.js 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 秒。
let obj = { a: 1 };
let niter = 20;
let before, str, pos, res, took;
for (let i = 0; i < niter; i++) {
obj = { obj1: obj, obj2: obj }; // Doubles in size each iter
}
before = process.hrtime();
str = JSON.stringify(obj);
took = process.hrtime(before);
console.log('JSON.stringify took ' + took);
before = process.hrtime();
pos = 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.
let 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) {
let avg = sum / n;
avgCB(avg);
});
}
asyncAvg(n, function (avg) {
console.log('avg of 1-n: ' + avg);
});
您可以将此原则应用于数组迭代等等。
卸载
如果您需要做更复杂的事情,划分不是一个好的选择。这是因为划分只使用事件循环,而您几乎无法从机器上可用的多个内核中获益。请记住,事件循环应该协调客户端请求,而不是自己完成它们。对于复杂的任务,将工作从事件循环卸载到工作池中。
如何卸载
您有两个选择,可以将工作卸载到目标工作池中。
- 您可以通过开发 C++ 附加模块 来使用内置的 Node.js 工作池。在旧版本的 Node 上,使用 NAN 构建您的 C++ 附加模块,在较新版本上使用 N-API。 node-webworker-threads 提供了一种仅使用 JavaScript 的方法来访问 Node.js 工作池。
- 您可以创建和管理自己的工作池,专门用于计算,而不是 Node.js 的 I/O 主题工作池。最直接的方法是使用 子进程 或 集群。
您不应该仅仅为每个客户端创建一个 子进程。您可以比创建和管理子进程更快地接收客户端请求,并且您的服务器可能会变成 fork 炸弹。
卸载的缺点
卸载方法的缺点是会产生通信成本形式的开销。只有事件循环才能看到应用程序的“命名空间”(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 密集型任务也会取得进展。数据库和文件系统等外部服务提供商经过高度优化,可以同时处理许多挂起的请求。例如,文件系统将检查大量挂起的写入和读取请求,以合并冲突的更新并以最佳顺序检索文件(例如,请参阅这些幻灯片)。
如果您只依赖一个 Worker 池,例如 Node.js Worker 池,那么 CPU 密集型工作和 I/O 密集型工作的不同特征可能会损害应用程序的性能。
因此,您可能希望维护一个单独的计算 Worker 池。
卸载:结论
对于简单的任务,例如遍历任意长度数组的元素,分区可能是一个不错的选择。如果您的计算更复杂,卸载是一个更好的方法:通信成本,即在事件循环和工作池之间传递序列化对象带来的开销,会被使用多个内核带来的好处抵消。
但是,如果您的服务器严重依赖复杂计算,您应该考虑 Node.js 是否真的适合。Node.js 擅长 I/O 密集型工作,但对于昂贵的计算,它可能不是最佳选择。
如果您采用卸载方法,请参阅关于不阻塞工作池的部分。
不要阻塞工作池
Node.js 拥有一个由 k
个工作线程组成的工作池。如果您使用上面讨论的卸载范式,您可能拥有一个单独的计算工作池,同样的原则也适用于它。无论哪种情况,我们假设 k
远小于您可能同时处理的客户端数量。这符合 Node.js 的“一个线程处理多个客户端”理念,这是其可扩展性的秘诀。
如上所述,每个工作线程在继续处理工作池队列中的下一个任务之前,会完成其当前的任务。
现在,处理客户端请求所需的各个任务的成本会有所不同。一些任务可以快速完成(例如,读取短文件或缓存文件,或生成少量随机字节),而另一些任务则需要更长时间(例如,读取较大的文件或未缓存的文件,或生成更多随机字节)。您的目标应该是 *最小化任务时间差异*,并且您应该使用 *任务分区* 来实现这一点。
最小化任务时间差异
如果一个工作线程的当前任务比其他任务贵得多,那么它将无法处理其他待处理的任务。换句话说,*每个相对较长的任务实际上都会减少工作池的大小,直到它完成为止*。这是不可取的,因为在一定程度上,工作池中的工作线程越多,工作池的吞吐量(任务/秒)就越高,因此服务器的吞吐量(客户端请求/秒)就越高。一个具有相对昂贵任务的客户端会降低工作池的吞吐量,进而降低服务器的吞吐量。
为了避免这种情况,您应该尝试最小化提交到工作池的任务长度的差异。虽然将您的 I/O 请求访问的外部系统(数据库、文件系统等)视为黑盒是合适的,但您应该了解这些 I/O 请求的相对成本,并应避免提交您预计会特别长的请求。
以下两个例子说明了任务时间可能出现的变化。
变化示例:长时间运行的文件系统读取
假设您的服务器必须读取文件才能处理一些客户端请求。在咨询了 Node.js 文件系统 API 后,您选择使用 fs.readFile()
来简化操作。但是,fs.readFile()
(目前) 并没有进行分区:它提交了一个跨越整个文件的单个 fs.read()
任务。如果您为某些用户读取较短的文件,而为其他用户读取较长的文件,fs.readFile()
可能会导致任务长度出现显著变化,从而降低工作池的吞吐量。
在最坏的情况下,假设攻击者可以诱使您的服务器读取一个任意文件(这是一种 目录遍历漏洞)。如果您的服务器运行的是 Linux,攻击者可以指定一个极其缓慢的文件:/dev/random
。实际上,/dev/random
的速度无限慢,每个被要求从 /dev/random
读取数据的 Worker 都无法完成该任务。然后,攻击者提交 k
个请求,每个 Worker 一个,其他使用工作池的客户端请求将无法取得进展。
变化示例:长时间运行的加密操作
假设您的服务器使用 crypto.randomBytes()
生成密码学安全的随机字节。crypto.randomBytes()
并没有进行分区:它创建一个单个 randomBytes()
任务来生成您请求的字节数。如果您为某些用户创建较少的字节,而为其他用户创建更多的字节,crypto.randomBytes()
则是任务长度变化的另一个来源。
任务分区
具有可变时间成本的任务会损害工作池的吞吐量。为了最大程度地减少任务时间变化,您应该尽可能地将每个任务分区为具有可比成本的子任务。每个子任务完成后,应该提交下一个子任务,当最后一个子任务完成后,应该通知提交者。
继续 fs.readFile()
的例子,您应该使用 fs.read()
(手动分区)或 ReadStream
(自动分区)。
同样的原则也适用于 CPU 密集型任务;asyncAvg
示例可能不适合事件循环,但非常适合工作池。
当您将任务划分为子任务时,较短的任务会扩展为少量子任务,而较长的任务会扩展为大量子任务。在较长任务的每个子任务之间,分配给它的 Worker 可以处理来自另一个较短任务的子任务,从而提高工作池的整体任务吞吐量。
请注意,完成的子任务数量不是衡量工作池吞吐量的有用指标。相反,您应该关注完成的任务数量。
避免任务划分
回想一下,任务划分的目的是最小化任务时间的差异。如果你能区分较短的任务和较长的任务(例如,对数组求和与对数组排序),你可以为每类任务创建一个工作池。将较短的任务和较长的任务路由到不同的工作池是另一种最小化任务时间差异的方法。
支持这种方法,划分任务会产生开销(创建工作池任务表示和操作工作池队列的成本),而避免划分可以节省你额外的访问工作池的成本。它还可以防止你在划分任务时犯错误。
这种方法的缺点是,所有这些工作池中的工作者都会产生空间和时间开销,并且会相互竞争 CPU 时间。请记住,每个 CPU 密集型任务只有在调度时才会取得进展。因此,你应该在仔细分析后才考虑这种方法。
工作池:结论
无论你只使用 Node.js 工作池还是维护单独的工作池,你都应该优化池的任务吞吐量。
为此,通过使用任务划分来最小化任务时间的差异。
npm 模块的风险
虽然 Node.js 核心模块为各种应用程序提供了构建块,但有时还需要更多功能。Node.js 开发人员从 npm 生态系统 中获益匪浅,该系统拥有数十万个模块,提供功能来加速你的开发过程。
但是,请记住,这些模块中的大多数是由第三方开发者编写的,通常只提供尽力而为的保证。使用 npm 模块的开发者应该关注两件事,尽管后者经常被遗忘。
- 它是否遵守其 API?
- 它的 API 是否会阻塞事件循环或工作者?许多模块没有努力表明其 API 的成本,这对社区不利。
对于简单的 API,你可以估计 API 的成本;字符串操作的成本并不难理解。但在许多情况下,不清楚 API 的成本可能有多高。
如果您要调用可能执行昂贵操作的 API,请仔细检查成本。请开发人员记录成本,或者自己检查源代码(并提交一个记录成本的 PR)。
请记住,即使 API 是异步的,您也不知道它在每个分区中的 Worker 或 Event Loop 上可能花费多少时间。例如,假设在上面给出的 asyncAvg
示例中,对辅助函数的每次调用都对一半的数字求和,而不是对其中一个数字求和。那么这个函数仍然是异步的,但每个分区的成本将是 O(n)
,而不是 O(1)
,这使得它在 n
的任意值情况下使用起来不那么安全。
结论
Node.js 有两种类型的线程:一个 Event Loop 和 k
个 Worker。Event Loop 负责 JavaScript 回调和非阻塞 I/O,而 Worker 执行与完成异步请求的 C++ 代码相对应的任务,包括阻塞 I/O 和 CPU 密集型工作。两种类型的线程一次最多只能处理一项活动。如果任何回调或任务需要很长时间,运行它的线程就会阻塞。如果您的应用程序进行阻塞回调或任务,这会导致吞吐量(每秒客户端)下降,最坏情况下会导致完全拒绝服务。
要编写一个高吞吐量、更防 DoS 的 Web 服务器,您必须确保在良性和恶意输入的情况下,您的 Event Loop 和 Worker 都不会阻塞。