不要阻塞事件循环(或工作池)
你应该阅读本指南吗?
如果你正在编写比简短的命令行脚本更复杂的内容,阅读本指南将有助于你编写更高性能、更安全的应用程序。
本文档针对 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 密集型
- DNS:
dns.lookup()
,dns.lookupService()
。 - [文件系统][/api/fs]:除
fs.FSWatcher()
和那些显式同步的 API 外,所有文件系统 API 都使用 libuv 的线程池。
- DNS:
- 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 次迭代
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 次迭代
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,对于许多常见操作而言速度很快。但正则表达式和 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 资源
有一些工具可以检查您的正则表达式的安全性,例如:
但是,这些工具都不能捕获所有易受攻击的正则表达式。
另一种方法是使用不同的正则表达式引擎。您可以使用 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 拒绝服务攻击
JSON.parse
和 JSON.stringify
也是潜在的耗时操作。虽然它们的复杂度是输入长度的 O(n),但对于较大的 n,它们可能需要 surprisingly long 的时间。
如果您的服务器操作 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 } // 大小每迭代加倍
}
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) {
// 在 JS 闭包中保存正在进行的总和。
let sum = 0
function help(i, cb) {
sum += i
if (i == n) {
cb(sum)
return
}
// “异步递归”。
// 异步调度下一个操作。
setImmediate(help.bind(null, i + 1, cb))
}
// 开始辅助函数,使用 CB 调用 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 主题工作池。最直接的方法是使用子进程或集群。
您不应该为每个客户端简单地创建一个子进程。您可以比创建和管理子进程更快地接收客户端请求,并且您的服务器可能会变成僵尸进程。
卸载的缺点 卸载方法的缺点是会产生通信成本的开销。只有事件循环才能看到应用程序的“命名空间”(JavaScript 状态)。从工作进程中,您无法操作事件循环命名空间中的 JavaScript 对象。相反,您必须序列化和反序列化任何想要共享的对象。然后工作进程可以在其自己的对象副本上进行操作,并将修改后的对象(或“补丁”)返回到事件循环。
关于序列化问题,请参阅有关 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 的“一个线程处理多个客户端”的理念,这是其可扩展性的秘诀。
如上所述,每个工作线程在继续处理工作池队列中的下一个任务之前,会完成其当前的任务。
现在,处理客户端请求所需的任务成本会有所不同。一些任务可以快速完成(例如读取短文件或缓存文件,或生成少量随机字节),而其他任务则需要更长时间(例如读取较大的文件或未缓存的文件,或生成更多随机字节)。您的目标应该是最大限度地减少任务时间的差异,并且您应该使用任务分区来实现这一点。
最小化任务时间的差异
如果一个 Worker 的当前任务比其他任务代价高得多,那么它将无法处理其他待处理的任务。换句话说,每个相对较长的任务都会有效地减少 Worker 池的大小,直到它完成。这是不可取的,因为在一定程度上,Worker 池中的 Worker 越多,Worker 池的吞吐量(任务/秒)就越大,从而服务器的吞吐量(客户端请求/秒)也越大。一个具有相对昂贵任务的客户端会降低 Worker 池的吞吐量,进而降低服务器的吞吐量。
为避免这种情况,您应该尝试最小化提交到 Worker 池的任务长度的差异。虽然将 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
示例可能不适合事件循环,但它非常适合工作池。
当您将任务划分为子任务时,较短的任务会扩展为少量子任务,而较长的任务会扩展为大量子任务。在较长任务的每个子任务之间,分配给它的工作器可以处理来自另一个较短任务的子任务,从而提高工作池的整体任务吞吐量。
请注意,已完成的子任务数量并不是衡量工作池吞吐量的有用指标。相反,您应该关注已完成的任务数量。
避免任务分区
回想一下,任务分区的目的是最小化任务时间的变化。如果您能够区分较短的任务和较长的任务(例如,对数组求和与对数组排序),您可以为每一类任务创建一个工作池。将较短的任务和较长的任务路由到不同的工作池是另一种最小化任务时间变化的方法。
支持这种方法的是,分区任务会产生开销(创建工作池任务表示以及操作工作池队列的成本),而避免分区则可以节省您额外访问工作池的成本。它还可以避免您在分区任务时犯错。
这种方法的缺点是,所有这些工作池中的工作器都会产生空间和时间开销,并且会相互竞争 CPU 时间。请记住,每个 CPU 密集型任务只有在调度时才能取得进展。因此,您应该仅在仔细分析后才考虑这种方法。
工作池:结论
无论你只使用 Node.js 工作池还是维护单独的工作池,都应该优化池的任务吞吐量。
为此,请使用任务分区来最小化任务时间的变化。
npm 模块的风险
虽然 Node.js 核心模块为各种各样的应用程序提供了构建块,但有时还需要更多。Node.js 开发人员从 npm 生态系统中受益匪浅,数十万个模块提供了可以加快开发进程的功能。
但是,请记住,这些模块中的大多数是由第三方开发者编写的,并且通常仅以尽力而为的保证发布。使用 npm 模块的开发者应该关注两件事,尽管后者经常被遗忘。
- 它是否遵守其 API?
- 它的 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 攻击的 Web 服务器,您必须确保在良性输入和恶意输入下,事件循环和工作线程都不会被阻塞。