多处理网络服务器模型指南
已发表: 2022-03-11作为一个多年来一直在编写高性能网络代码的人(我的博士论文是关于适用于多核系统的分布式应用程序的缓存服务器的主题),我看到很多关于这个主题的教程完全错过或忽略了任何讨论网络服务器模型的基础知识。 因此,本文旨在作为网络服务器模型的有用概述和比较,目的是解开编写高性能网络代码的一些奥秘。
本文面向“系统程序员”,即后端开发人员,他们将处理其应用程序的低级细节,实现网络服务器代码。 这通常在 C++ 或 C 中完成,尽管现在大多数现代语言和框架都提供了不错的低级功能,并具有不同级别的效率。
我将把常识当作常识,因为通过添加内核来扩展 CPU 更容易,因此调整软件以尽可能地使用这些内核是很自然的。 因此,问题变成了如何在可以在多个 CPU 上并行执行的线程(或进程)之间划分软件。
我还理所当然地认为读者知道“并发”基本上意味着“多任务处理”,即同时处于活动状态的多个代码实例(无论是相同的代码还是不同的代码,都没有关系)。 并发可以在单个 CPU 上实现,在现代之前,通常是这样。 具体而言,可以通过在单个CPU上的多个进程或线程之间快速切换来实现并发。 这就是旧的单 CPU 系统如何设法同时运行许多应用程序,以一种用户会认为应用程序同时执行的方式,尽管实际上并非如此。 另一方面,并行性具体意味着代码正在同时执行,从字面上看,是由多个 CPU 或 CPU 内核执行的。
将应用程序分区(分成多个进程或线程)
出于讨论的目的,如果我们谈论线程或完整进程,这在很大程度上是不相关的。 现代操作系统(Windows 除外)几乎将进程视为轻量级线程(或者在某些情况下,反之亦然,线程获得了使其与进程一样重的特性)。 如今,进程和线程之间的主要区别在于跨进程或跨线程通信和数据共享的能力。 在进程和线程之间的区别很重要的地方,我会做一个适当的说明,否则,可以安全地认为本节中的“线程”和“进程”这两个词是可以互换的。
常见的网络应用程序任务和网络服务器模型
本文专门处理网络服务器代码,它必然实现以下三个任务:
- 任务 #1:建立(和拆除)网络连接
- 任务 #2:网络通信 (IO)
- 任务#3:有用的工作; 即有效载荷或应用程序存在的原因
有几种通用的网络服务器模型可以跨进程划分这些任务; 即:
- MP:多进程
- SPED:单进程,事件驱动
- SEDA:分阶段的事件驱动架构
- AMPED:非对称多进程事件驱动
- SYMPED:对称多进程事件驱动
这些是学术界使用的网络服务器模型名称,我记得至少找到了其中一些的“in the wild”同义词。 (当然,名称本身并不重要——真正的价值在于如何推断代码中发生的事情。)
这些网络服务器模型中的每一个都将在下面的部分中进一步描述。
多进程 (MP) 模型
MP网络服务器模型是大家习惯首先学习的模型,尤其是在学习多线程的时候。 在 MP 模型中,有一个接受连接的“主”进程(任务 #1)。 一旦建立连接,主进程就会创建一个新进程并将连接套接字传递给它,因此每个连接都有一个进程。 然后,这个新进程通常以一种简单的、顺序的、锁步的方式与连接一起工作:它从中读取一些东西(任务#2),然后进行一些计算(任务#3),然后向它写入一些东西(任务#2)再次)。
MP 模型实现起来非常简单,只要进程总数保持在相当低的水平,它实际上工作得非常好。 有多低? 答案实际上取决于任务 #2 和 #3 的内容。 根据经验,假设进程或线程的数量不应超过 CPU 内核数量的两倍左右。 一旦有太多进程同时处于活动状态,操作系统往往会花费太多时间来颠簸(即,在可用的 CPU 内核上处理进程或线程),这样的应用程序通常最终会花费几乎所有的 CPU时间在“sys”(或内核)代码中,几乎没有做真正有用的工作。
优点:实现起来非常简单,只要连接数量很少,就可以很好地工作。
缺点:如果进程数量增长过大,往往会使操作系统负担过重,并且在网络 IO 等待负载(计算)阶段结束时可能会出现延迟抖动。
单进程事件驱动 (SPED) 模型
SPED 网络服务器模型因一些相对较新的备受瞩目的网络服务器应用程序而闻名,例如 Nginx。 基本上,它在同一个进程中完成所有三个任务,在它们之间进行多路复用。 为了提高效率,它需要一些相当高级的内核功能,例如 epoll 和 kqueue。 在这个模型中,代码由传入的连接和数据“事件”驱动,并实现了一个“事件循环”,如下所示:
- 询问操作系统是否有任何新的网络“事件”(例如新连接或传入数据)
- 如果有新的连接可用,建立它们(任务 #1)
- 如果有可用数据,请阅读它(任务 #2)并对其采取行动(任务 #3)
- 重复直到服务器退出
所有这些都在单个进程中完成,并且可以非常高效地完成,因为它完全避免了进程之间的上下文切换,这通常会扼杀 MP 模型中的性能。 这里唯一的上下文切换来自系统调用,并且通过仅对附加了一些事件的特定连接进行操作来最小化这些切换。 只要有效负载工作(任务#3)不是过于复杂或资源密集,这个模型就可以同时处理数万个连接。
但是,这种方法有两个主要缺点:
- 由于所有三个任务都在一个循环迭代中按顺序完成,因此有效负载工作(任务#3)与其他所有任务同步完成,这意味着如果计算对客户端接收到的数据的响应需要很长时间,其他所有任务在此过程中停止,从而引入潜在的巨大延迟波动。
- 仅使用单个 CPU 内核。 再次,这具有绝对限制操作系统所需的上下文切换数量的好处,这提高了整体性能,但具有任何其他可用 CPU 内核根本不做任何事情的显着缺点。
正是由于这些原因,需要更高级的模型。

优点:可以在操作系统上实现高性能和简单(即,需要最少的操作系统干预)。 只需要一个 CPU 内核。
缺点:仅使用单个 CPU(无论可用的数量如何)。 如果有效载荷工作不统一,则会导致响应延迟不统一。
分阶段事件驱动架构 (SEDA) 模型
SEDA 网络服务器模型有点复杂。 它将复杂的、事件驱动的应用程序分解为一组由队列连接的阶段。 但是,如果不仔细实施,它的性能可能会遇到与 MP 案例相同的问题。 它是这样工作的:
- 有效负载工作(任务#3)被划分为尽可能多的阶段或模块。 每个模块都实现了一个特定的功能(想想“微服务”或“微内核”),它驻留在自己的独立进程中,这些模块通过消息队列相互通信。 这种架构可以表示为节点图,其中每个节点都是一个进程,边是消息队列。
- 单个进程执行任务 #1(通常遵循 SPED 模型),它将新连接卸载到特定入口点节点。 这些节点可以是纯网络节点(任务#2),将数据传递给其他节点进行计算,也可以实现有效负载处理(任务#3)。 通常没有“主”进程(例如,收集和聚合响应并通过连接将它们发送回的进程),因为每个节点都可以自己响应。
理论上,该模型可以任意复杂,节点图可能具有循环、与其他类似应用程序的连接,或者节点实际在远程系统上执行的位置。 然而,在实践中,即使有明确定义的消息和高效的队列,思考和推理整个系统的行为也会变得笨拙。 与 SPED 模型相比,如果在每个节点上完成的工作很短,则消息传递开销会破坏该模型的性能。 该模型的效率明显低于SPED模型,因此通常用于有效载荷工作复杂且耗时的情况。
优点:终极软件架构师的梦想:一切都被隔离成整洁的独立模块。
缺点:复杂性会因模块的数量而爆炸式增长,而且消息队列仍然比直接内存共享慢得多。
非对称多进程事件驱动 (AMPED) 模型
AMPED 网络服务器是一种更温和、更易于建模的 SEDA 版本。 没有那么多不同的模块和流程,也没有那么多的消息队列。 以下是它的工作原理:
- 以 SPED 样式在单个“主”流程中实施任务 #1 和 #2。 这是唯一进行网络 IO 的进程。
- 在单独的“工作”进程(可能在多个实例中启动)中实施任务 #3,通过队列(每个进程一个队列)连接到主进程。
- 当在“主”进程中接收到数据时,找到一个未充分利用(或空闲)的工作进程并将数据传递到其消息队列。 当响应准备好时,进程会向主进程发送消息,此时它将响应传递给连接。
这里重要的是,有效负载工作是在固定(通常可配置)数量的进程中执行的,这与连接数无关。 这里的好处是有效载荷可以任意复杂,并且不会影响网络 IO(这对延迟有好处)。 也有可能提高安全性,因为只有一个进程在做网络 IO。
优点:网络 IO 和有效负载工作的非常清晰的分离。
缺点:利用消息队列在进程之间来回传递数据,这取决于协议的性质,可能会成为瓶颈。
对称多进程事件驱动 (SYMPED) 模型
SYMPED 网络服务器模型在很多方面都是网络服务器模型的“圣杯”,因为它就像拥有多个独立的 SPED“工作”进程实例。 它是通过在一个循环中让一个进程接受连接,然后将它们传递给工作进程来实现的,每个工作进程都有一个类似于 SPED 的事件循环。 这有一些非常有利的后果:
- CPU 会根据产生的进程数进行加载,这些进程在每个时间点都在进行网络 IO 或负载处理。 没有办法进一步提高 CPU 利用率。
- 如果连接是独立的(例如使用 HTTP),则工作进程之间没有进程间通信。
事实上,这是新版本的 Nginx 所做的; 它们产生少量的工作进程,每个工作进程都运行一个事件循环。 为了使事情变得更好,大多数操作系统都提供了一个功能,通过该功能,多个进程可以独立地侦听 TCP 端口上的传入连接,从而无需专门处理网络连接的特定进程。 如果您正在处理的应用程序可以通过这种方式实现,我建议您这样做。
优点:严格的 CPU 使用上限,具有可控数量的类似 SPED 的循环。
缺点:由于每个进程都有一个类似 SPED 的循环,如果负载工作不均匀,延迟可能会再次发生变化,就像正常的 SPED 模型一样。
一些低级技巧
除了为您的应用程序选择最佳架构模型之外,还有一些可用于进一步提高网络代码性能的低级技巧。 以下是一些更有效的简短列表:
- 避免动态内存分配。 作为解释,只需查看流行的内存分配器的代码——它们使用复杂的数据结构、互斥体,而且其中的代码非常多(例如,jemalloc 大约有 450 KiB 的 C 代码!)。 上面的大多数模型都可以使用完全静态(或预分配)的网络和/或缓冲区来实现,它们只在需要时更改线程之间的所有权。
- 使用操作系统可以提供的最大值。 大多数操作系统允许多个进程在单个套接字上侦听,并实现在套接字上接收到第一个字节(甚至是第一个完整请求!)之前不会接受连接的功能。 如果可以,请使用 sendfile()。
- 了解您正在使用的网络协议! 例如,禁用 Nagle 算法通常是有意义的,如果(重新)连接率很高,则禁用延迟也是有意义的。 了解 TCP 拥塞控制算法,看看尝试一种较新的算法是否有意义。
在以后的博文中,我可能会更多地讨论这些,以及使用的其他技术和技巧。 但就目前而言,这有望为编写高性能网络代码的架构选择及其相对优缺点提供有用且信息丰富的基础。