Reactor 与 Proactor

2023/2/7

总结多种概念之间的关系:

# PPC 模型(服务器处理请求模型)

PPC 是 Process Per Connection 的缩写,其含义是指每次有新的连接就新建一个进程去专门处理这个连接的请求,这是传统的UNIX网络服务器所采用的模型。基本的流程图是:

image.png

  • 父进程接受连接(图中 accept )
  • 父进程 “fork” 子进程(图中 fork )
  • 子进程处理连接的读写请求(图中子进程 read、业务处理、write )
  • 子进程关闭连接(图中子进程中的 close )

PPC 模式实现简单,比较适合服务器的连接数没那么多的情况,例如数据库服务器。对于普通的业务服务器,在互联网兴起之前,由于服务器的访问量和并发量并没有那么大,这种模式其实运作得也挺好,世界上第一个 web服务器 CERN httpd 就采用了这种模式

互联网兴起后,服务器的并发和访问量从几十剧增到成千上万,这种模式的弊端就凸显出来了,主要体现在这几个方面:

  1. fork 代价高:站在操作系统的角度,创建一个进程的代价是很高的,需要分配很多内核资源,需要将内存映像从父进程复制到子进程
  2. 父子进程通信复杂:父进程 “fork” 子进程时,文件描述符可以通过内存映像复制从父进程传到子进程,但“fork” 完成后,父子进程通信就比较麻烦了,需要采用 IPC(Interprocess Communication)之类的进程通信方案。例如,子进程需要在 close 之前告诉父进程自己处理了多少个请求以支撑父进程进行全局的统计,那么子进程和父进程必须采用 IPC 方案来传递信息。
  3. 支持的并发连接数量有限:如果每个连接存活时间比较长,而且新的连接又源源不断的进来,则进程数量会越来越多,操作系统进程调度和切换的频率也越来越高,系统的压力也会越来越大。因此,一般情况下,PPC 方案能处理的并发连接数量最大也就几百

# prefork 模式(pre-fork)

PPC 模式中,当连接进来时才 fork 新进程来处理连接请求,由于 fork 进程代价高,用户访问时可能感觉比较慢,prefork 模式的出现就是为了解决这个问题

顾名思义,prefork 就是提前创建进程(pre-fork)。系统在启动的时候就预先创建好进程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去 fork 进程的操作,让用户访问更快、体验更好。prefork 的基本示意图是: image.png prefork 的实现关键就是多个子进程都 accept 同一个 socket,当有新的连接进入时,操作系统保证只有一个进程能最后 accept 成功。但这里也存在一个小小的问题:“惊群”现象,就是指虽然只有一个子进程能 accept 成功,但所有阻塞在 accept 上的子进程都会被唤醒,这样就导致了不必要的进程调度和上下文切换了。幸运的是,操作系统可以解决这个问题,例如 Linux 2.6 版本后内核已经解决了 accept 惊群问题

prefork 模式和 PPC 一样,还是存在父子进程通信复杂、支持的并发连接数量有限的问题,因此目前实际应用也不多。Apache服务器提供了MPM prefork模式,推荐在需要可靠性或者与旧软件兼容的站点时采用这种模式,默认情况下最大支持256个并发连接。

# TPC 模式(Thread Per Connection)

TPC 是 Thread Per Connection 的缩写,其含义是指每次有新的连接就新建一个线程去专门处理这个连接的请求。与进程相比,线程更轻量级,创建线程的消耗比进程要少得多;同时多线程是共享进程内存空间的,线程通信相比进程通信更简单。因此,TPC 实际上是解决或者弱化了 PPC fork 代价高的问题和父子进程通信复杂的问题。TPC的基本流程是: image.png

  • 父进程接受连接(图中 accept )
  • 父进程创建子线程(图中 pthread )
  • 子线程处理连接的读写请求(图中子线程 read、业务处理、write )
  • 子线程关闭连接(图中子线程中的 close )

TPC 虽然解决了 fork 代价高和进程通信复杂的问题,但是也引入了新的问题,具体表现在:

  1. 创建线程虽然比创建进程代价低,但并不是没有代价,高并发时(例如每秒上万连接)还是有性能问题
  2. 无须进程间通信,但是线程间的互斥和共享又引入了复杂度,可能一不小心就导致了死锁问题 (线程共享空间就会有并发问题)
  3. 除了引入了新的问题,TPC 还是存在 CPU 线程调度和切换代价的问题。因此,TPC 方案本质上和 PPC 方案基本类似,在并发几百连接的场景下,反而更多地是采用PPC的方案,因为PPC方案不会有死锁的风险,也不会多进程互相影响,稳定性更高

# prethread

TPC 模式中,当连接进来时才创建新的线程来处理连接请求,虽然创建线程比创建进程要更加轻量级,但还是有一定的代价,而 prethread 模式就是为了解决这个问题。和 prefork 类似,prethread 模式会预先创建线程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去创建线程的操作,让用户感觉更快、体验更好。由于多线程之间数据共享和通信比较方便,因此实际上 prethread 的实现方式相比 prefork 要灵活一些,常见的实现方式有下面几种:

  1. 主进程accept,然后将连接交给某个线程处理
  2. 子线程都尝试去accept,最终只有一个线程accept成功,方案的基本示意图如下:

image.png

PPC 和 TPC 模式,它们的优点是实现简单,缺点是都无法支撑高并发的场景,尤其是互联网发展到现在,各种海量用户业务的出现, PPC 和 TPC 完全无能为力。接下来介绍可以应对高并发场景的单服务器高性能架构模式:Reactor 和 Proactor

# Reactor 模型

PPC 模式最主要的问题就是每个连接都要创建进程(为了描述简洁,这里只以 PPC 和进程为例,实际上换成TPC 和线程,原理是一样的),连接结束后进程就销毁了,这样做其实是很大的浪费。为了解决这个问题,一个自然而然的想法就是资源复用,即不再单独为每个连接创建进程,而是创建一个进程池,将连接分配给进程,一个进程可以处理多个连接的业务

引入资源池的处理方式后,会引出一个新的问题:进程如何才能高效地处理多个连接的业务?当一个连接一个进程时,进程可以采用“ read -> 业务处理 -> write ”的处理流程,如果当前连接没有数据可以读,则进程就阻塞在read 操作上。这种阻塞的方式在一个连接一个进程的场景下没有问题,但如果一个进程处理多个连接,进程阻塞在某个连接的 read 操作上,此时即使其他连接有数据可读,进程也无法去处理,很显然这样是无法做到高性能的

解决这个问题的最简单的方式是将 read 操作改为非阻塞,然后进程不断地轮询多个连接。这种方式能够解决阻塞的问题,但解决的方式并不优雅。首先,轮询是要消耗 CPU 的;其次,如果一个进程处理几千上万的连接,则轮询的效率是很低的

为了能够更好地解决上述问题,很容易可以想到,只有当连接上有数据的时候进程才去处理,这就是I/O多路复用技术的来源

I/O 多路复用技术归纳起来有两个关键实现点:

  1. 无须再轮询所有连接,常见的实现方式有 select、epoll、kqueue 等 (这里注意下:select、epoll、kqueue 只是一种技术,这种技术能实现 I/O 事件的监控)
  2. 当某条连接有新的数据可以处理时,操作系统会通知进程,进程从阻塞状态返回,开始进行业务处理

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 模式有这三种典型的实现方案:

  1. 单Reactor单进程/线程
  2. 单Reactor多线程
  3. 多Reactor多进程/线程

以上方案具体选择进程还是线程,更多地是和编程语言及平台相关。例如,Java 语言一般使用线程(例如,Netty ),C语言使用进程和线程都可以。例如,Nginx 使用进程,Memcache 使用线程

# 单 Reactor 单进程/线程

image.png 上图描述了 Reactor 的单线程模型结构,在 Reactor 单线程模型中,所有 I/O 操作(包括连接建立、数据读写、事件分发等)、业务处理,都是由一个线程完成的。单线程模型逻辑简单,缺陷也十分明显:

  1. 一个线程支持处理的连接数非常有限,CPU 很容易打满,性能方面有明显瓶颈;
  2. 当多个事件被同时触发时,只要有一个事件没有处理完,其他后面的事件就无法执行,这就会造成消息积压及请求超时;
  3. 线程在处理 I/O 事件时,Select 无法同时处理连接建立、事件分发等操作

# 单 Reactor 多线程

image.png Reactor 多线程模型将 业务逻辑 交给多个线程进行处理。除此之外,多线程模型其他的操作与单线程模型是类似的,比如连接建立、IO事件读写以及事件分发等都是由一个线程来完成。

当客户端有数据发送至服务端时,Select 会监听到可读事件,数据读取完毕后提交到业务线程池中并发处理。 一般的请求中,耗时最长的一般是业务处理,所以用一个线程池(worker 线程池)来处理业务操作,在性能上的提升也是非常可观的。

当然,这种模型也有明显缺点,连接建立、IO事件读取以及事件分发完全有单线程处理;比如当某个连接通过系统调用正在读取数据,此时相对于其他事件来说,完全是阻塞状态,新连接无法处理、其他连接的IO查询/IO读写以及事件分发都无法完成。

对于像 Nginx、Netty 这种对高性能、高并发要求极高的网络框架,这种模式便显得有些吃力了。因为,无法及时处理新连接、就绪的IO事件以及事件转发等。接下来,我们看看主从多线程模型是如何解决这个问题的

# 多 Reactor 多进程/线程

image.png 既然是主从模式,那谁主谁从呢?哪个模块使用主从呢? 在单 Reactor 多线程模型中,我们提到,其主要缺陷在于同一时间无法处理大量新连接、IO就绪事件;因此,将主从模式应用到这一块,就可以解决这个问题。

主从 Reactor 模式中,分为了主 Reactor 和 从 Reactor,分别处理 新建立的连接、IO读写事件/事件分发

  • 一来,主 Reactor 可以解决同一时间大量新连接,将其注册到从 Reactor 上进行IO事件监听处理
  • 二来,IO事件监听相对新连接处理更加耗时,此处我们可以考虑使用线程池来处理。这样能充分利用多核 CPU 的特性,能使更多就绪的IO事件及时处理。

简言之,主从多线程模型由多个 Reactor 线程组成,每个 Reactor 线程都有独立的 Selector 对象 MainReactor 仅负责处理客户端连接的 Accept 事件,连接建立成功后将新创建的连接对象注册至SubReactor。再由 SubReactor 分配线程池中的 I/O 线程与其连接绑定,它将负责连接生命周期内所有的 I/O 事件。

在海量客户端并发请求的场景下,主从多线程模式甚至可以适当增加 SubReactor 线程的数量,从而利用多核能力提升系统的吞吐量

目前著名的开源系统 Nginx 采用的是多 Reactor 多进程,采用多 Reactor 多线程的实现有 Memcache 和 Netty

# Proactor

待补充

目前 Windows下通过 IOCP 实现了真正的异步 I/O,而在 Linux 系统下的 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 Reactor 模式为主

# 参考

  1. 《从 0 开始学架构》