多线程网络服务模型


/**
 * 谨献给Yoyo
 *
 * 原文出处:https://www.toptal.com/software/guide-to-multi-processing-network-server-models
 * @author dogstar.huang <chanzonghuang@gmail.com> 2016-04-02
 */

作为多年来一直在编写高性能网络代码的人(我的博士论文主题是适配多核系统分布式应用的高速缓存服务),现在我看到了很多完全不知道或忽略讨论网络服务模型基本原理的教程。因此,本文旨在希望能为大家提供有用的概览以及网络服务模型的比较,以揭开编写高性能网络代码的神秘面纱。

本文主要针对“系统程序员”,即与他们的应用程序的底层细节工作、实现网络服务代码的后端开发。这通常是在C++C来完成,虽然时下大部分现代语言和框架通过各种级别的效率提供了体面的底层功能。

既然通过增加内核更容易扩展CPU,我会把这作为常识,而本质却是调整软件以便最大化使用这些内核。因此,问题就变成如何在能在多个CPU上并行执行的线程(或进程)中分区软件。

我也将理所当然地认为读者意识到,“并发”基本上意味着“多任务处理”,即一些代码实例(无论是相同或不同的代码,这并不重要),在同一时间是活跃的。并发可以在单个CPU上实现,并且通常前现代时期是这样的。具体地,并发可以通过在单个CPU上的多个进程或线程之间快速切换来实现。这是老式的、单CPU系统如何管理在同一时间运行众多应用程序的方式,在某种程度上,用户会觉得应用程序是在同时执行,尽管实际上并没有。另一方面,平行度,从字面上看具体意味着代码通过多个CPU或CPU内核在同一时间执行。

分区应用程序(到多个进程或线程)

出于这个讨论的目的,假如我们谈论线程或全过程,它基本上是不相关的。现代操作系统(而Windows显然是个例外)把进程看待像线程一样轻量级(或在某些情况下,反之亦然,线程都获得了功能,这使得它们像进程一样重量级)。如今,进程和线程之间的主要区别是在跨进程或跨线程通信和数据共享的功能。其中,进程和线程之间的区别是很重要的,我会进行适当的备注,否则,在这些部分可以安全地考虑“线程”和“过程”是可以互换的。

通用网络应用任务与网络服务模型

这篇文章具体处理网络服务代码,这部分需要实现以下三个任务:

  • 任务#1:建立(和拆除)的网络连接
  • 任务#2:网络通信(IO)
  • 任务#3:有用的工作;例如负载或者应用程序为什么存在的原因

关于跨进程分区分区这三个任务,这里有几个普遍的网络服务模型,即:

  • MP:多进程
  • SPED:单进程,事件驱动
  • SEDA:分阶段的事件驱动架构
  • AMPED:非对称多进程事件驱动
  • SYMPED:对称多处理事件驱动

这些都是在学术界使用的网络服务模型的名字,我记得“在野外”的同义词发现至少其中的一些。(名字本身,当然,并不是那么重要的 -- 真正的价值是如何洞悉代码是怎么回事。)

这些网络服务模型,每一个都会在下面的部分中进一步说明。

多进程(MP)模型

MP的网络服务模型是每个人都会首选用来学习的一个,特别是学习多线程的时候。在MP模型中,有一个“master”进程,接收连接(任务#1)。一旦建立了连接,主进程创建一个新的进程,并把连接的socket传给它,所以一个进程一个连接。这个新的进程然后通常和此连接以简单、连续、锁步的方式工作:进程从连接中读取一些东西(任务#2),然后做一些计算(任务#3),然后写一些东西给它(再次 任务#2)。

模型MP是很容易实现的,而且实际工作极为出色只要进程总数维持很低很低。有多低?答案取决于任务#2和任务#3蕴含了什么。经验法则,可以说进程数或线程数不应超过CPU内核的两倍。一旦有在同一时间激活太多进程,操作系统则趋于花费了太多在于时间抖动(即,围绕可用的CPU内核上平衡进程或线程)和这样的应用通常最终花费几乎所有的CPU的一次在“SYS”(或内核)代码,实际上却做了一点点真正有用的工作。

优点:实现很简单,只要连接数很少可以工作得非常好。

缺点:如果进程数增长太大则趋于使得操作系统过载过重,并且可能会有延迟抖动网络IO等待,直到有效载荷(计算)阶段结束。

单进程事件驱动(SPED)模型

该SPED网络服务器模型,因最近一些高调的网络服务应用程序,如Nginx而出名。基本上,它在同一个进程做了这三项任务,在它们之间之间复用。为了提高效率,它需要像epoll和kqueue的一些相当先进的核心功能。在这种模型下,代码是由传入的连接和数据“事件”驱动,并且实现了一个看起来像这样的“事件循环”:

  • 问操作系统是否有任何新的网络“事件”(如新的连接或输入数据)
  • 如果有新的可用连接,建立他们(任务#1)
  • 如果有可用的数据,读取它(任务#2)并对它采取行动(任务3#)
  • 重复,直到服务器退出

所有这一切都在一个单一的进程中完成,并且可以非常有效地完成,因为它完全避免了进程之间的上下文切换,这通常会造成MP模型严重的性能问题。这里唯一的上下文切换来自系统调用,而这些又通过仅作用于有某些事件绑定的具体连接而使得切换最小化。该模型可以同时处理数万的连接,只要有效载荷工作(任务#3)不是太复杂或是资源密集型的。

尽管这种方式有两大缺点:

  • 1、由于三个任务都在一个单一的循环迭代中顺序进行,有效载荷工作(任务#3)和所有东西都是同步完成的,也就是说,如果它需要很长的时间来计算到由客户端接收的数据的响应,当正在做这点时其他东西都会停止,而这会在延迟中引入潜在的巨大波动。

  • 2、只使用一个CPU内核。这样再次是有好处,绝对限制了来自操作系统要求的上下文切换数量,从而提高了整体性能,但有明显的不足就是其他任何可用的CPU内核都无事可做。

这是对于需要更先进的模型的理由。

优点:可以是具有高性能,在操作系统易于实现(即,需要最少量的OS干预)。只需要一个CPU内核。

缺点:仅利用单个CPU(不管可用的数量)。如果有效载荷工作不统一,会导致非均匀的响应延迟。

分阶段的事件驱动架构(SEDA)模型

该SEDA网络服务模型有点复杂。它把复杂的,事件驱动的应用程序分解到一组由队列连接的阶段。尽管如果不仔细实现,它的性能会跟MP情况中同一问题而受到影响。它的工作原理是这样的:

  • 有效载荷工作(任务#3)会尽可能地分成多个阶段,或模块。每个模块实现了驻留在其自己单独的进程中单个特定功能(可认为是“微服务”或“微内核”),并且这些模块经由消息队列相互通信。此架构可以表示为节点图,其中节点是进程,边是消息队列。

  • 一个单一进程执行任务#1(通常遵循SPED模型),它将新连接交付于特定的条目点节点。这些节点可以是传递数据给其他节点进行计算,或者也可以是实现有效载荷处理(任务3#)的纯网络节点(任务#2)。通常没有“master”进程(例如,一个收集并聚集响应,并将其通过连接发送返回),因为每一个节点都可以通过自身进行响应。

理论上,这种模式可以是任意复杂的,因为节点图可能具有循环,连接到其他类似的应用程序,或是连接到实际上是在远程系统上执行的节点。但在实践中,即使有定义良好的消息和高效的队列,它会变得笨拙难以思考,并且把系统的行为作为一个整体来推理。相比于SPED的模式,来往传递的消息可能会破坏该模型的性能,如果每个节点的工作都是很简短的话。该模型的效率显然比SPED模型的要低,所以它通常采用在有效载荷的工作复杂且耗时的情况。

优点:软件架构师最终的梦想:一切都分割成整齐而又独立的模块。

缺点:复杂度随模块数量而爆炸,并且消息队列仍然比直接内存共享慢得多。

非对称多进程事件驱动(AMPED)模型

该AMPED网络服务是SEDA驯服的,更易于模型的一个版本。没有过多不同的模块和进程,也没有多过的消息队列。下面是它如何工作的:

  • 以SPED风格在一个单一的“master”进程中实现任务#1和任务#2。这是网络IO的唯一进程。
  • 在一个单独的“worker”进程中实现任务#3(可能在多个实例中启动),通过一个队列连接到主进程(每个进程一个队列)。
  • 当“master”进程接收到数据,找到一个没有被充分利用(或空闲)的工作进程,并把数据传递给它的消息队列。当响应准备好时主进程由该进程发起消息通知,此时它通过连接传递响应。

这里最重要的是,有效负载工作是在一个固定的(通常配置的)数量的进程中进行,这独立于连接的数量。这样的好处是,有效负载可以是任意复杂,并且也不会影响网络IO(这是很好的等待时间)。而且还可能带来更高的安全性,因为只有一个进程在做网络IO。

优点:网络IO和有效载荷的工作分离非常清晰。

缺点:为在进程之间来回传递数据利用消息队列,而这根据不同协议的性质,可能成为瓶颈。

对称多处理事件驱动(SYMPED)模型

该SYMPED网络服务模型在许多方面是网络服务模型的“圣杯”,因为它就像有独立SPED“worker”进程的多个实例。它是通过由单一进程循环接收连接,然后将它们传递到工作进程得以实现,每一个都有一个像SPED的事件循环。这有一些非常有利的后果:

  • CPU都为生成的进程的准确数量而加载,这在每个时间点要么做网络IO或有效载荷处理。没有办法进一步提升CPU利用率。
  • 如果连接是独立的(例如使用HTTP),在工作进程之间则没有间通信。

事实上,这一点,也是最新版Nginx在做的;它们生产出少量工作进程,每个运行一个事件循环。为了使事情变得更好,大多数操作系统都提供了一个可由多个进程在一个独立的TCP端口侦听传入连接的功能,省去了为某个特定进程决定与网络连接工作的需要。如果你正在使用的应用程序可以通过这种方式来实现,我建议这样做。

优点:通过像SPED那样循环可控制的数量,严格提高CPU使用率天花板。

缺点:由于每个过程有一个像SPED那样的循环,如果有效载荷工作是不均匀的,等待时间可以再次变化,就像与正常SPED模型那样。

一些低级技巧

除了为您的应用选择最佳的构架模型外,这里还有可用于进一步提高网络代码性能的一些低级招数。下面简短列出了一些更有效的技巧:

  • 1、避免动态内存分配。作为一个解释,简单地看流行的内存分配代码 - 他们使用复杂的数据结构,互斥,并其中只是简单地这么多的代码(例如,jemalloc大概是450KiB左右的C代码!)。上面大部分的模型可用完全静态的(或预先分配)网络和/或仅在需要的地方改变线程之间所有权缓冲器来实现。

  • 2、使用操作系统可以提供最大值。大多数操作系统允许多个进程监听一个单一socket,并在套接字直到接收到第一个字节(或甚至是第一个完整的请求!)时连接将不被接受时那里实现功能收到。如果可以请使用sendfile()。

  • 3、了解您正在使用的网络协议!例如,禁用Nagle算法通常是有意义的,并且如果(再)连接率高禁止持续是有意义的。学习TCP拥塞控制算法,看看它是否有意义去尝试较一个新的。

在未来的博客文章,我可以更多地谈论这些,以及其他技术和实用的技巧。但现在,这里希望能为编写高性能网络代码提供关于的架构选择一个有用的信息基础,和它们的相对优势和劣势。

dogstar

一位喜欢翻译的开发人员,艾翻译创始人之一。

广州