架构篇四 高性能单服务器
🚿

架构篇四 高性能单服务器

Created
Sep 9, 2021 02:32 PM
Tags
架构
高性能是每个程序员的追求,无论我们是做一个系统还是写一行代码,都希望能够达到高性能的效果,而高性能又是最复杂的一环,磁盘、操作系统、CPU、内存、缓存、网络、编 程语言、架构等,每个都有可能影响系统达到高性能,一行不恰当的debug日志,就可能将服务器的性能从TPS 30000降低到8000;一个tcp_nodelay参数,就可能将响应时间 从2毫秒延长到40毫秒。因此,要做到高性能计算是一件很复杂很有挑战的事情,软件系统开发过程中的不同阶段都关系着高性能最终是否能够实现。
单服务器高性能的关键之一就是服务器采取的并发模型,并发模型有如下两个关键设计点:
  • 服务器如何管理连接。
  • 服务器如何处理请求。
以上两个设计点最终都和操作系统的I/O模型及进程模型相关。
  • I/O模型:阻塞、非阻塞、同步、异步。
  • 进程模型:单进程、多进程、多线程。

PPC (Process Per Connection)

每次有新的连接就新建一个进程去专门处理这个连接的请求,这是传统的UNIX网络服务器所采用的模型。
notion image
  1. 父进程接受连接(图中accept)。
  1. 父进程“fork”子进程(图中fork)。
  1. 子进程处理连接的读写请求(图中子进程read、业务处理、write)。
  1. 子进程关闭连接(图中子进程中的close)。
注意,图中有一个小细节,父进程“fork”子进程后,直接调用了close,看起来好像是关闭了连接,其实只是将连接的文件描述符引用计数减一,真正的关闭连接是等子进程也调 用close后,连接对应的文件描述符引用计数变为0后,操作系统才会真正关闭连接,更多细节请参考《UNIX网络编程:卷一》。

TPC (Thread Per Connection)

每次有新的连接就新建一个线程去专门处理这个连接的请求。与进程相比,线程更轻量级,创建线程的消耗比进程要少得多;同时多线程是共享进程内存空间的,线程通信相比进程通信更简单。因此,TPC实际上是解决或者弱化了PPC fork代价高的问题和父子进程通信复杂的问题。
notion image
  1. 父进程接受连接(图中accept)。
  1. 父进程创建子线程(图中pthread)。
  1. 子线程处理连接的读写请求(图中子线程read、业务处理、write)。
  1. 子线程关闭连接(图中子线程中的close)。

Reactor

PPC模式最主要的问题就是每个连接都要创建进程(为了描述简洁,这里只以PPC和进程为例,实际上换成TPC和线程,原理是一样的),连接结束后进程就销毁了,这样做其实是 很大的浪费。为了解决这个问题,一个自然而然的想法就是资源复用,即不再单独为每个连接创建进程,而是创建一个进程池,将连接分配给进程,一个进程可以处理多个连接的业 务。
引入资源池的处理方式后,会引出一个新的问题:进程如何才能高效地处理多个连接的业务?当一个连接一个进程时,进程可以采用“read -> 业务处理 -> write”的处理流程,如果 当前连接没有数据可以读,则进程就阻塞在read操作上。这种阻塞的方式在一个连接一个进程的场景下没有问题,但如果一个进程处理多个连接,进程阻塞在某个连接的read操作 上,此时即使其他连接有数据可读,进程也无法去处理,很显然这样是无法做到高性能的。 解决这个问题的最简单的方式是将read操作改为非阻塞,然后进程不断地轮询多个连接。这种方式能够解决阻塞的问题,但解决的方式并不优雅。首先,轮询是要消耗CPU的;其 次,如果一个进程处理几千上万的连接,则轮询的效率是很低的。 为了能够更好地解决上述问题,很容易可以想到,只有当连接上有数据的时候进程才去处理,这就是I/O多路复用技术的来源。
I/O多路复用技术归纳起来有两个关键实现点:
  • 当多条连接共用一个阻塞对象后,进程只需要在一个阻塞对象上等待,而无须再轮询所有连接,常见的实现方式有select、epoll、kqueue等。
  • 当某条连接有新的数据可以处理时,操作系统会通知进程,进程从阻塞状态返回,开始进行业务处理。
I/O多路复用结合线程池,完美地解决了PPC和TPC的问题,而且“大神们”给它取了一个很牛的名字:Reactor,中文是“反应堆”。联想到“核反应堆”,听起来就很吓人,实际上这里的“反应”不是聚变、裂变反应的意思,而是“事件反应”的意思,可以通俗地理解为“来了一个事件我就有相应的反应”,这里的“我”就是Reactor,具体的反应就是我们写的代码,Reactor会根据事件类型来调用相应的代码进行处理。Reactor模式也叫Dispatcher模式(在很多开源的系统里面会看到这个名称的类,其实就是实现Reactor模式的),更加贴近模式本身的含义,即I/O多路复用统一监听事件,收到事件后分配(Dispatch)给某个进程。 Reactor模式的核心组成部分包括Reactor和处理资源池(进程池或线程池),其中Reactor负责监听和分配事件,处理资源池负责处理事件。初看Reactor的实现是比较简单的,但实际上结合不同的业务场景,Reactor模式的具体实现方案灵活多变,主要体现在:
Reactor的数量可以变化:可以是一个Reactor,也可以是多个Reactor。 资源池的数量可以变化:以进程为例,可以是单个进程,也可以是多个进程(线程类似)。
将上面两个因素排列组合一下,理论上可以有4种选择,但由于“多Reactor单进程”实现方案相比“单Reactor单进程”方案,既复杂又没有性能优势,因此“多Reactor单进程”方案仅仅是一个理论上的方案,实际没有应用。 最终Reactor模式有这三种典型的实现方案: 单Reactor单进程/线程。 单Reactor多线程。 多Reactor多进程/线程。 以上方案具体选择进程还是线程,更多地是和编程语言及平台相关。例如,Java语言一般使用线程(例如,Netty),C语言使用进程和线程都可以。例如,Nginx使用进程,Memcache使用线程。

Proactor

Reactor是非阻塞同步网络模型,因为真正的read和send操作都需要用户进程同步操作。这里的“同步”指用户进程在执行read和send这类I/O操作的时候是同步的,如果把I/O操作改为异步就能够进一步提升性能,这就是异步网络模型Proactor。 Proactor中文翻译为“前摄器”比较难理解,与其类似的单词是proactive,含义为“主动的”,因此我们照猫画虎翻译为“主动器”反而更好理解。Reactor可以理解为“来了事件我通知你,你来处理”,而Proactor可以理解为“来了事件我来处理,处理完了我通知你”。这里的“我”就是操作系统内核,“事件”就是有新连接、有数据可读、有数据可写的这些I/O事件,“你”就是我们的程序代码。
Proactor模型示意图:
notion image
  • Proactor Initiator负责创建Proactor和Handler,并将Proactor和Handler都通过Asynchronous Operation Processor注册到内核。
  • Asynchronous Operation Processor负责处理注册请求,并完成I/O操作。
  • Asynchronous Operation Processor完成I/O操作后通知Proactor。
  • Proactor根据不同的事件类型回调不同的Handler进行业务处理。
  • Handler完成业务处理,Handler也可以注册新的Handler到内核进程。
理论上Proactor比Reactor效率要高一些,异步I/O能够充分利用DMA特性,让I/O操作与计算重叠,但要实现真正的异步I/O,操作系统需要做大量的工作。目前Windows下通过IOCP实现了真正的异步I/O,而在Linux系统下的AIO并不完善,因此在Linux下实现高并发网络编程时都是以Reactor模式为主。所以即使Boost.Asio号称实现了Proactor模型,其实它在Windows下采用IOCP,而在Linux下是用Reactor模式(采用epoll)模拟出来的异步模型。