不要阻塞事件循环(或工作线程池)
你应该阅读本指南吗?
如果你正在编写比简短命令行脚本更复杂的任何东西,阅读本文将帮助你编写更高性能、更安全的应用程序。
本文档是为 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 中实现(文档),它公开了一个通用的任务提交 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', (, ) => {
.sendStatus(200);
});
示例 2:一个 O(n)
的回调。这个回调对于小的 n
会运行得很快,而对于大的 n
会运行得更慢。
app.get('/countToN', (, ) => {
const = .query.n;
// n iterations before giving someone else a turn
for (let = 0; < ; ++) {
.(`Iter ${}`);
}
.sendStatus(200);
});
示例 3:一个 O(n^2)
的回调。这个回调对于小的 n
仍然会运行得很快,但对于大的 n
,它会比之前的 O(n)
示例运行得慢得多。
app.get('/countToN2', (, ) => {
const = .query.n;
// n^2 iterations before giving someone else a turn
for (let = 0; < ; ++) {
for (let = 0; < ; ++) {
.(`Iter ${}.${}`);
}
}
.sendStatus(200);
});
你应该多小心?
Node.js 使用 Google V8 引擎来处理 JavaScript,它对于许多常见操作来说都非常快。这个规则的例外是正则表达式和 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', (, ) => {
const = .query.filePath;
// REDOS
if (.match(/(\/.+)+$/)) {
.('valid path');
} else {
.('invalid path');
}
.sendStatus(200);
});
这个例子中易受攻击的正则表达式是检查 Linux 上有效路径的一种(糟糕的!)方法。它匹配由“/”分隔的名称序列组成的字符串,例如“/a/b/c”。它很危险,因为它违反了规则 1:它有一个双重嵌套的量词。
如果一个客户端用 filePath `///.../\n`(100 个 / 后面跟着一个正则表达式的 "." 无法匹配的换行符)进行查询,那么事件循环将花费几乎无限长的时间,从而阻塞了事件循环。这个客户端的 REDOS 攻击导致所有其他客户端在正则表达式匹配完成之前都得不到处理机会。
因此,你应该谨慎使用复杂的正则表达式来验证用户输入。
反 REDOS 资源
有一些工具可以检查你的正则表达式的安全性,比如:
但是,这些都不能捕获所有易受攻击的正则表达式。
另一种方法是使用不同的正则表达式引擎。你可以使用 node-re2 模块,它使用了谷歌的超快 RE2 正则表达式引擎。但请注意,RE2 与 V8 的正则表达式不是 100% 兼容的,所以如果你换用 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)
child_process.spawnSync
child_process.execSync
child_process.execFileSync
截至 Node.js v9,此列表相当完整。
阻塞事件循环:JSON DOS
JSON.parse
和 JSON.stringify
是其他潜在的昂贵操作。虽然它们在输入长度上是 O(n)
,但对于大的 n
,它们可能需要出乎意料的长的时间。
如果你的服务器处理 JSON 对象,特别是来自客户端的对象,你应该对你在事件循环上处理的对象或字符串的大小保持谨慎。
示例:JSON 阻塞。我们创建一个大小为 2^21 的对象 obj
,并对其进行 JSON.stringify
,然后对该字符串运行 indexOf
,最后对其进行 JSON.parse。JSON.stringify
后的字符串大小为 50MB。对对象进行字符串化需要 0.7 秒,对 50MB 的字符串进行 indexOf 操作需要 0.03 秒,解析该字符串需要 1.3 秒。
let = { : 1 };
const = 20;
// Expand the object exponentially by nesting it
for (let = 0; < ; ++) {
= { : , : };
}
// Measure time to stringify the object
let = .();
const = .();
let = .();
.('JSON.stringify took', );
// Measure time to search a string within the JSON
= .();
const = .('nomatch'); // Always -1
= .();
.('String.indexOf took', );
// Measure time to parse the JSON back to an object
= .();
const = .();
= .();
.('JSON.parse took', );
有一些 npm 模块提供了异步 JSON API。例如:
- JSONStream,它有流式 API。
- Big-Friendly JSON,它既有流式 API,也有使用下面概述的在事件循环上分区的范式的标准 JSON API 的异步版本。
在不阻塞事件循环的情况下进行复杂计算
假设你想在不阻塞事件循环的情况下在 JavaScript 中进行复杂的计算。你有两个选择:分区或卸载。
分区
你可以分区你的计算,这样每个计算都在事件循环上运行,但会定期让步(给其他待处理的事件机会)。在 JavaScript 中,很容易将正在进行的任务的状态保存在一个闭包中,如下面的示例 2 所示。
举个简单的例子,假设你想计算从 1
到 n
的数字的平均值。
示例 1:未分区的平均值,成本为 O(n)
for (let = 0; < n; ++) {
sum += ;
}
const = sum / n;
.('avg: ' + );
示例 2:分区平均值,n
个异步步骤中的每一个成本都是 `O(1)`。
function (, ) {
// Save ongoing sum in JS closure.
let = 0;
function (, ) {
+= ;
if ( == ) {
();
return;
}
// "Asynchronous recursion".
// Schedule next operation asynchronously.
(.(null, + 1, ));
}
// Start the helper, with CB to call avgCB.
(1, function () {
const = / ;
();
});
}
(n, function () {
.('avg of 1-n: ' + );
});
你可以将这个原则应用于数组迭代等等。
卸载
如果你需要做更复杂的事情,分区不是一个好选择。这是因为分区只使用事件循环,你将无法从你机器上几乎肯定可用的多个核心中受益。请记住,事件循环应该协调客户端请求,而不是自己完成它们。对于复杂的任务,将工作从事件循环转移到工作线程池上。
如何卸载
你有两个选择来决定将工作卸载到哪个工作线程池。
- 你可以通过开发一个 C++ 插件来使用内置的 Node.js 工作线程池。在旧版本的 Node 上,使用 NAN 构建你的 C++ 插件,而在新版本上使用 N-API。node-webworker-threads 提供了一种仅使用 JavaScript 的方式来访问 Node.js 工作线程池。
- 你可以创建和管理自己的专门用于计算的工作线程池,而不是 Node.js 以 I/O 为主题的工作线程池。最直接的方法是使用 子进程 或 集群。
你不应该简单地为每个客户端创建一个子进程。你接收客户端请求的速度可能比你创建和管理子进程的速度快,你的服务器可能会变成一个fork 炸弹。
卸载的缺点
卸载方法的缺点是它会产生通信成本的开销。只有事件循环被允许看到你的应用程序的“命名空间”(JavaScript 状态)。从一个工作线程中,你无法操作事件循环命名空间中的 JavaScript 对象。相反,你必须序列化和反序列化任何你希望共享的对象。然后工作线程可以在它自己的这些对象的副本上操作,并将修改后的对象(或一个“补丁”)返回给事件循环。
有关序列化问题,请参阅关于 JSON DOS 的部分。
一些卸载的建议
你可能希望区分 CPU 密集型和 I/O 密集型任务,因为它们具有显著不同的特征。
一个 CPU 密集型任务只有在它的工作线程被调度时才会取得进展,并且该工作线程必须被调度到你机器的逻辑核心之一上。如果你有 4 个逻辑核心和 5 个工作线程,其中一个工作线程将无法取得进展。结果是,你为这个工作线程支付了开销(内存和调度成本),却没有得到任何回报。
I/O 密集型任务涉及查询外部服务提供商(DNS、文件系统等)并等待其响应。当一个带有 I/O 密集型任务的工作线程正在等待其响应时,它没有其他事情可做,可以被操作系统取消调度,从而给另一个工作线程提交请求的机会。因此,即使关联的线程没有在运行,I/O 密集型任务也将在进行中。像数据库和文件系统这样的外部服务提供商已经被高度优化,可以并发处理许多待处理的请求。例如,文件系统会检查大量的待处理写入和读取请求,以合并冲突的更新并以最佳顺序检索文件。
如果你只依赖一个工作线程池,例如 Node.js 工作线程池,那么 CPU 密集型和 I/O 密集型工作的不同特性可能会损害你的应用程序性能。
因此,你可能希望维护一个独立的计算工作线程池。
卸载:结论
对于简单的任务,比如迭代任意长度数组的元素,分区可能是一个不错的选择。如果你的计算更复杂,卸载是更好的方法:通信成本,即在事件循环和工作线程池之间传递序列化对象的开销,被使用多个核心的好处所抵消。
但是,如果你的服务器严重依赖复杂的计算,你应该考虑 Node.js 是否真的是一个好的选择。Node.js 在 I/O 密集型工作方面表现出色,但对于昂贵的计算,它可能不是最佳选择。
如果你采用卸载方法,请参阅关于不阻塞工作线程池的部分。
不要阻塞工作线程池
Node.js 有一个由 k
个工作线程组成的工作线程池。如果你正在使用上面讨论的卸载范式,你可能会有一个单独的计算工作线程池,同样的原则也适用于它。在任何一种情况下,我们都假设 k
远小于你可能同时处理的客户端数量。这与 Node.js 的“一个线程服务多个客户端”的理念相符,这也是其可扩展性的秘诀。
如上所述,每个工作线程在处理工作线程池队列中的下一个任务之前,会先完成其当前任务。
现在,处理您的客户端请求所需的任务成本会有所不同。有些任务可以很快完成(例如,读取短文件或缓存文件,或生成少量随机字节),而其他任务则需要更长的时间(例如,读取较大或未缓存的文件,或生成更多随机字节)。您的目标应该是最小化任务时间的差异,您应该使用任务分区来实现这一目标。
最小化任务时间的差异
如果一个工作线程的当前任务比其他任务昂贵得多,那么它将无法处理其他待处理的任务。换句话说,每个相对较长的任务在完成之前,实际上都会将工作线程池的大小减少一个。这是不可取的,因为在一定程度上,工作线程池中的工作线程越多,工作线程池的吞吐量(任务数/秒)就越大,从而服务器的吞吐量(客户端请求数/秒)也越大。一个具有相对昂贵任务的客户端会降低工作线程池的吞吐量,进而降低服务器的吞吐量。
为了避免这种情况,你应该尽量减少你提交给工作线程池的任务长度的差异。虽然将你的 I/O 请求所访问的外部系统(数据库、文件系统等)视为黑盒是合适的,但你应该意识到这些 I/O 请求的相对成本,并应避免提交那些你可以预料到会特别长的请求。
两个例子应该能说明任务时间的可能差异。
差异示例:长时间运行的文件系统读取
假设您的服务器必须读取文件以处理某些客户端请求。在查阅了 Node.js 文件系统 API 后,您为了简单起见选择了使用 fs.readFile()
。然而,v10 之前的 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 模块的开发者应该关心两件事,尽管后者经常被遗忘。
- 它是否遵守其 API?
- 它的 API 是否可能会阻塞事件循环或工作线程?许多模块没有努力说明其 API 的成本,这对社区是不利的。
对于简单的 API,你可以估算其成本;字符串操作的成本不难理解。但在许多情况下,一个 API 可能需要多少成本是不清楚的。
如果你正在调用一个可能做一些昂贵事情的 API,请仔细检查成本。要求开发者将其文档化,或者自己检查源代码(并提交一个记录成本的 PR)。
请记住,即使 API 是异步的,你也不知道它在其每个分区中可能在工作线程或事件循环上花费多少时间。例如,假设在上面给出的 `asyncAvg` 示例中,每次调用辅助函数都对一半的数字求和,而不是其中一个。那么这个函数仍然是异步的,但每个分区的成本将是 `O(n)`,而不是 `O(1)`,这使得它对于任意 `n` 值的使用远不安全。
结论
Node.js 有两种类型的线程:一个事件循环和 k
个工作线程。事件循环负责 JavaScript 回调和非阻塞 I/O,而工作线程执行与完成异步请求的 C++ 代码相对应的任务,包括阻塞 I/O 和 CPU 密集型工作。两种类型的线程一次只处理不超过一项活动。如果任何回调或任务花费很长时间,运行它的线程就会被阻塞。如果你的应用程序发出阻塞的回调或任务,这可能导致吞吐量(客户端/秒)下降,最坏的情况下可能导致完全的拒绝服务。
为了编写一个高吞吐量、更具 DoS 防护能力的网络服务器,你必须确保无论是在良性还是恶意输入下,你的事件循环和工作线程都不会阻塞。