# 两种高效的事件处理(事件分发)模式

  • Reactor 模式(反应堆)—— 依赖同步 IO,主线程中处理 IO,监听就绪事件的发生,然后通知工作线程进行读写数据(IO 操作)+ 读写完成后的处理逻辑
  • Proactor 模式(前摄器)—— 依赖异步 IO,主线程和内核处理全部的 IO(包括读写数据),监听完成事件(异步 IO 产生的是完成信号,即信号产生时读写已经完成)的发生,然后通知工作线程进行读写完成后的处理逻辑
  • 模拟 Proactor 模式 —— Linux 中没有真正的异步 IO,AIO(aio_read 等)内部是用 pthread 模拟的(多线程 + 请求队列 + 信号等)。故使用同步 IO 来模拟 Proactor 的模式,即称为模拟 Proactor。
    • 模拟 Proactor 与 Proactor 的区别是:前者的数据读写由用户完成,后者的数据读写由内核完成;
    • 模拟 Proactor 与 Proactor 的共同点是:通知给工作线程的都是完成事件,以此避免了工作线程中的读写操作(IO 操作

# 两种高效的并发模式:

  1. 半同步 / 半异步模式
  2. 领导者 / 追随者模式

并发模式:多个逻辑单元和 IO 处理单元之间协调完成任务的方法

  1. IO 模型中:同步 / 异步
    • 同步 / 异步区分的是内核向应用程序通知的是何种 IO 事件(就绪 / 完成事件),以及该由谁来完成 IO 读写(应用程序还是内核)
  2. 并发模式中:同步 / 异步
    • 同步是指程序完全按照代码序列的顺序执行
    • 异步是指程序的执行需要由系统事件来驱动(中断、信号)

服务器适合半同步 / 半异步模式! 同步线程:按照同步方式运行的线程 异步线程效率更高

# 半同步 / 半异步模式

同步线程用于处理客户逻辑

异步线程用于处理 IO 事件:

  • 异步线程监听到客户请求就将其封装成请求对象插入到请求队列
  • 请求队列通知某个工作在同步模式的工作进程来读取并处理该请求对象

具体选择哪个工作线程取决于请求队列的设计:轮流选取 Round Robin、条件变量 + 信号量随机选取


  • 半同步 / 半异步模式 —— 同步线程与异步线程结合的模式
  • Reactor 和 Proactor 都属于半同步 / 半异步模式

半同步 / 半异步模式中异步线程怎么分发任务给同步线程呢?(即怎么协同处理事件呢):

  • 用 Reactor 模式分发 —— 半同步 / 半反应器模式
  • 用模拟 Proactor 模式分发 —— 半同步 / 半模拟前摄器模式(这个模式是我自己造的,为了分清楚概念而已)

我的理解:

  1. 从游双老师书上的描述来看,Reactor 和 Proactor 模式应该都是属于半同步 / 半异步模式的(Reactor 和 Proactor 都是一个异步线程分发任务给其他同步线程,与不分发任务的领导者 / 追随者模式有着本质上的不同,它们显然都是半同步 / 半异步的模式)。
  2. 因此,Reactor 和 Proactor 模式又像是 “半同步 / 半异步模式” 的子分类。书上是这样描述的:“结合考虑两种事件处理模式… 半同步 / 半异步模式就存在多种变体,其中一种就是半同步 / 半反应堆模式”,意思就是 “半同步 / 半异步模式 + Reactor 模式 = 半同步 / 半反应堆模式(half-sync/half-reactive)”

# 半同步 / 半反应堆模式

异步线程只有一个由主线程充当,负责监听所有 socket 上的事件:

  1. 如果监听 socket 上有可读事件发生,即有新的连接请求到来,接收连接并向 epoll 内核事件表注册该 socket 上的读写事件。
  2. 如果连接 socket 上有读写事件发生,即有新的客户请求到来或有数据要发送至客户端。 模拟 Proactor 中:主线程首先循环读取数据完毕,随后将读取的数据封装成请求对象插入请求队列中。
  3. 所有工作线程睡眠在请求队列上,当有任务到来时它们将通过竞争获取任务的接管权,从请求队列中取出任务对象直接处理无需读写操作。

半同步 / 半反应堆模式缺点:

  1. 主线程和工作线程共享请求队列,因此主线程往请求队列中添加任务或工作线程从请求队列中取出任务都需要对请求队列加锁保护。浪费 CPU 时间
  2. 每个工作线程在同一时间只能处理一个客户请求,如果客户数量 > 工作线程数→导致请求队列中任务堆积,客户端响应速度会越来越慢

改进方案:

主线程只负责监听 socket,连接 socket 由工作线程管理

当有新连接到来时,主线程接收,并通过向管道写数据的方式将新返回的连接发给某个工作线程,该连接 socket 上的任何 IO 操作都由工作线程处理。工作线程检测到有数据可读就将连接 socket 上的读写事件注册到自己的 epoll 内核事件表

# 领导者 / 追随者模式

含义:多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。 包含组件:句柄集、线程集、事件处理器、具体事件处理器

任意时间点都只有一个领导者线程:负责监听 IO 事件,其他线程都是追随者,它们休眠在线程池等待成为新的领导者。

  • 当前领导者如果检测到 IO 事件,首先从线程池推选出新的领导者线程,然后处理 IO 事件
  • 此时新领导者等待新的 IO 事件,旧领导者处理 IO 事件,二者形成并发

# 有限状态机:用于 HTTP 请求的读取和分析

之前探讨了关于服务器的 IO 处理单元、请求队列和逻辑单元之间协调完成任务的各种模式。

现在介绍逻辑单元内部高效编程方法:有限状态机:

主状态机有三种可能状态:当前正在分析请求行、当前正在分析头部字段、当前正在分析消息体 从状态机有三种可能状态(行的读取状态):读到一个完整的行、行出错、行数据尚不完整

从状态机用于解析出一行内容,

  • 判断 HTTP 头部结束的一句是遇到一个空行,该空行仅包含一对回车换行符 (\r 和 \n)。
  • 如果一次读操作没有读入 HTTP 请求的整个头部,即没有遇到空行那么必须等待客户继续写数据再次读入,返回 LINE_OPEN。因此每完成一次读操作就需要分析读入的数据是否有空行。
  • 在寻找空行的过程中,我们可以同时完成对整个 HTTP 请求头部的分析

主状态机在内部调用从状态机:

  1. 分析从状态机,parse_line 函数:它从 buffer 中解析出一个行。从状态机初始状态是 LINE_OK,原始驱动力来自于 buffer 中新到达的客户数据。
  2. 在 main 函数中循环调用 recv 函数往 buffer 中读入客户数据,每次成功读取数据后就调用 process_read 函数来分析新读入的数据。
    • process_read 作为分析 http 请求的入口函数
    • process_read 函数首先调用 parse_line 函数来获取一个行,当读取到一个完整的行后 parse_line 函数就可以将这行内容交给 process_read 函数中的主状态机来处理了。

parse_line 只是提取出一行,并不解析其中内容

parse_request_line 解析 http 请求行,获得请求方法,目标 url 及 http 版本号 parse_headers 解析 http 请求的一个头部信息 parse_content 解析 http 请求的消息体

  1. 主状态机初始状态为 CHECK_STATE_REQUESTLINE
  2. 如果主状态机当前状态为 CHECK_STATE_REQUESTLINE,说明 parse_line 解析出的行是请求行,调用 parse_request_line 函数处理,分析完请求后将主状态机设置为 CHECK_STATE_HEADER 实现状态转移
  3. 如果主状态机当前状态为 CHECK_STATE_HEADER,说明 parse_line 解析出的行是请求头部,调用 parse_headers 函数处理,并将主状态机设置为 CHECK_STATE_CONTENT

# 流程:

浏览器端发出 HTTP 请求报文,服务器端接收该报文并调用 process_read 对其进行解析,根据解析结果 HTTP_CODE 做出响应,process_write () 填写相应的回复到缓冲区。

#

并发编程:进程池、线程池

当需要一个工作线程、进程来处理新到来的客户请求时可以直接从进程池、线程池中取得一个执行实体,无序动态调用 fork 或 pthread_create 函数创建

连接池

连接池是服务器预先和数据库程序建立的一组连接的集合。 当某个逻辑单元需要访问数据库时,它可以直接从连接池中取得一个连接的实体并使用,完成访问后逻辑单元再将该连接还给连接池