两种高效事件处理模式&并发模式
来源如下,侵删。
- 游双-《Linux高性能服务器编程》
- 本来想做个笔记的,但是发现这块内容书中很多都感觉是有用的,所以很大篇幅的搬了过来,其中加入了我的理解,并有重点标注。
服务器编程框架
- 服务器程序种类繁多,但是基本框架都一样,
不同之处在于逻辑处理
。- 下图所示,服务器基本框架。该图既能用来描述
一台服务器
,也能用来描述一个服务器机群
。
- 各模块概念
模块 | 单个服务器框架 | 服务器机群 |
---|---|---|
I/O逻辑单元 | 处理客户连接,读写网络数据 | 作为接入服务器,实现负载聚恒 |
逻辑单元 | 业务进程或线程 | 逻辑服务器 |
网络存储单元 | 本地数据库、文件或缓存 | 数据库服务器 |
请求队列 | 各单元之间的通信方式 | 各服务器之间的永久TCP连接 |
I/O处理单元
I/O处理单元是服务器
管理客户端连接的模块
。它通常要完成一下工作:
- 等待并
接受
新的客户链接;接收
客户数据;- 将服务器响应的数据
返回
给客户端。- 数据的收发不一定在I/O处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式。
- 对于一个服务器机群来说,I/O处理单元是一个专门的接入服务器。它实现负载均衡,从所有逻辑服务器中选取
负荷最小
的一台来为新客户服务。
逻辑单元
一个逻辑单元通常是一个
进程或线程
。
- 它
分析并处理客户数据
,然后将结果传递
给I/O处理单元或者直接发送
给客户端。- 具体使用哪种方式取决于事件处理模式。
- 对服务器机群而言,一个逻辑单元本身就是一台逻辑服务器。服务器通常拥有多个逻辑单元, 以实现对多个客户任务的
并行
处理。
网络存储单元
- 网络存储单元可以是数据库、缓存和文件,甚至是一台独立的服务器。
- 它不是必须的,例如:ssh、telnet等登录服务器就不需要这个单元。
请求队列
请求队列是
各个单元之间通信方式的抽象
。
- I/O处理单元接收到客户请求时,需要以某种方式来
通知
一个逻辑单元来处理该请求。- 同样,多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来
协调
处理竞态条件。- 请求队列通常被实现为
池
的一部分。对于服务器机群而言,请求队列是各台服务器之间预先建立的、静态的、永久的TCP链接。
- 这种TCP连接能
提高服务器之间交换数据的效率,因为它避免了动态建立TCP导致的额外系统开销
。
两种高效的事件处理模式
服务器通常需要处理三类事件:
- I/O事件
- 信号
- 定时事件
下面介绍两种高效的事件处理模式: Reactor与Proactor。
同步I/O模型通常用于实现Reactor模式
,异步I/O模型则用于实现Proactor模式
。可以以使用同步I/O模型去模拟
Proactor模式。
Reactor模式
- Reactor模式中,
主线程只负责监听文件描述符上是否有事件发生
,有的话就立即将该事件通知
工作线程(即,逻辑单元,下同)除此之外,主线程不做任何其它实质性的工作
。- 读写数据,接收新的连接,以及处理客户请求(业务逻辑)均在
工作线程
中完成。使用同步I/O模型(以epoll_wait为例)实现的Reactor模式的工作流程:
- 主线程往epoll内核事件表中
注册
socket上的读就绪事件。(监听socket与连接socket成功建立连接后,以下socket都指的是连接socket)- 主线程调用epoll_wait
等待
socket上有数据可读。- 当socket上有数据可读时,epoll_wait
通知
主线程。主线程则将socket可读事件放入
请求队列。- 睡眠在请求队列上的某个工作线程被
唤醒
,它从连接socket读取数据,并处理
客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件。- 主线程调用epoll_wait
等待
scoket可写。- 当socket可写时,epoll_wait
通知
主线程。主线程将socket可写事件放入
请求队列。- 睡眠在请求队列上的某个工作线程被
唤醒
,它往socket上写入
服务器处理客户请求的结果。- 总结: 主线程仅负责监听socket看是否有发生事件,然后就通知工作线程读取,处理数据,如为写事件(即,要应答),再在epoll内核事件表上注册该连接socket的可写事件,然后再由某个工作线程接管,处理,执行应答,读事件同理。
- Reactor模式工作流程图如下所示:
工作线程从请求队列中取出事件后,将
根据事件的类型决定如何处理
它。
- 对于
可读
事件,执行读数据和处理请求
的操作;- 对于
可写
事件,执行写数据
的操作;- 因此,如上图所示的Reactor模式中,没必要区分所谓的“读工作线程”和“写工作线程”。
Proactor模式
- 与Reactor模式不同,
Proactor模式将所有I/O操作都交给主线程和内核来处理
,工作线程仅仅负责业务逻辑。- 因此,Proactor模式更符合服务器基本框架图中的描述。
使用异步I/O模型(以aio_read和aio_write为例)实现的Proactor模式的工作流程:
- 主线程调用aio_read函数向内核
注册socket上的读完成事件
,并告诉内核用户读缓冲区的位置
,以及读操作完成时如何通知
应用程序(这里以信号为例,详情请参考sigevent的man手册)。- 主线程
继续处理其它逻辑
。- 当socket上的数据被
读入用户缓冲区后
,内核用户向应用程序发送一个信号
,以通知应用程序数据已经可用
。- 应用程序
预先定义好的信号处理函数选择一个工作线程来处理客户请求
。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件
,并告诉内核用户写缓冲区的位置
,以及写操完成时如何通知
应用程序(仍以信号为例)。- 总结: 内核用户与主线程进行I/O操作,由信号通知主线程唤醒一个工作线程进行处理数据(业务逻辑),业务处理完,再交给内核用户与主线程进行I/O操作(服务器应答)。
- Proactor模式工作流程图如下图所示:
- 上图中,连接socket上的读写事件是
通过aio_read/aio_write向内核注册的
,因此内核将通过信号来向应用程序报告连接socket上的读写事件
。- 所以,
主线程上的epoll_wait调用仅能用来检测监听socket上的连接请求事件
,而不能用来检测连接socket上的读写事件。
对比&总结
- Reactor模式与Proactor相对比:
I/O操作:
Reactor: 工作线程来完成。
- 对应socket上可以读(写)数据了,
唤醒一个工作线程在对应socket上读(写)
。Proactor: 主线程和内核来完成。
- 对应socket上可以读(写)数据了,
内核用户后读取
完成通知应该程序,由定义好的信号处理函数选择一个工作线程进行数据业务处理
,然后通知内核可以写回去了
(服务器应答)。数据业务处理:
- Reactor: 工作线程来完成。
- Proactor: 工作线程来完成。
模拟Proactor模式
- 使用同步I/O方式模拟出Proactor模式。
- 原理是:
主线程执行读写操作
,读写完成之后,主线程向工作线程通知这一"完成事件"。从工作线程
的角度来看,它们就直接获取了数据读写的结果
,接下来要做的只是对读写的结果进行逻辑处理。使用同步I/O模型(仍以epoll_wait为例)模拟出的Proactor模式的工作流程如下:(其中socket为连接socket)
主线程
往epoll内核事件表中注册socket上的读就绪事件
。主线程
调用epoll_wait等待socket上有数据可读
。- 当socket上有数据可读时,epoll_wait通知主线程。
主线程
从socket上循环读取数据
,将读取到的数据封装成一个请求对象并插入到请求队列
中。- 睡眠在请求队列上的某个
工作线程被唤醒
,它获得请求对象并处理客户请求
,然后往epoll内核表中注册socket上的写就绪事件
。主线程
调用epoll_wait等待socket可读。- 当socket可写时,epoll_wait
通知主线程
。主线程往socket上写入服务器处理客户请求的结果
。- 总结: 主线程负责I/O操作,工作线程仅负责数据的处理(业务逻辑)。
- 工作流程如下图所示:
两种高效的并发模式
- 并发编程的目的是让程序“同时”执行多个任务。
- 如果程序是
计算密集型
的,并发编程并没有优势,反而由于任务的切换使效率降低
。如果程序是
I/O密集型
的,比如经常读写文件,访问数据等,因为I/O操作的速度远没有CPU的计算速度快,所以让程序阻塞于I/O操作将浪费大量的CPU时间
。
- 如果程序有多个执行线程,则当前被I/O操作所阻塞的执行线程可
主动放弃
CPU(由操作系统来调度),并将执行线程转移到其他线程。- 这样一来,CPU就可以做更加有意义的事情(除非所有线程都同时被I/O操作所阻塞),而不是等待I/O操作完成,从而显著提升CPU的利用率。
- 实现上: 并发编程主要有
多进程
和多线程
两种方式。- 对于下图来说,
并发模式是指I/O处理单元和多个逻辑单元之间协调完成任务的方法
。服务器主要有两种并发编程模式:
- 半同步/半异步模式(half-sync/half-async)
- 领导者/追随者模式(Leader/Followers)
半同步/半异步模式
- 这里的半同步/半异步模式中的“同步"与”异步“与I/O模型中的“同步"与”异步“是完全
不同的概念
。I/O模型中:
- “同步"与”异步“区分的是内核向应用程序通知的是
何种I/O事件
(是就绪事件还是完成事件);- 以及该由
谁来完成I/O读写
(是应用程序还是内核)。在并发模式中:
- "同步"指的是程序完全按照代码序列的
顺序执行
;“异步”指的是程序的执行需要由
系统事件来驱动
。
- 常见的系统事件包括中断、信号等。
- 下图a描述了同步的读操作,下图b描述了异步的读操作。
- 按照
同步方式运行的线程称为同步线程
,按照异步方式运行的线程称为异步线程
。相比于同步线程,`异步线程的执行效率更高,实时性强。——(异步线程优点)
- 但编写以异步方式执行的程序相对
复杂,难于调试和扩展
,而且不适合于大量的并发
。——(异步线程缺点)- 同步线程则相反,虽然它
效率相对较低
,实时性较差
,但是逻辑简单
。——(同步线程优缺点)
- 因此,对于像服务器这种及要求
较好的实时性
,又要求能同时处理多个客户请求
的应用程序,我们就应该同时使用同步线程与异步线程来实现,即采用半同步/半异步模式来实现。
半同步/半异步模式中:
同步线程用于处理客户逻辑,相当于服务器基本框架图中的逻辑单元
;异步线程用于处理I/O请求, 相当于服务器基本框架图中的I/O处理单元。
- 工作线程处理I/O操作,所以半同步/半异步模式采用的是Reactor事件处理模式。
异步线程监听
到客户请求后,就将其封装成请求对象并插入请求队列
中。请求队列将通知
某个工作在同步模式的工作线程
来读取并处理该请求对象。具体选择哪个工作线程来为新的客户请求服务器,则取决于请求队列
的设计。
- 比如最简单的轮流选取工作线程的Round Robin算法,也可以通过条件变量或信号量来随机地选择一个工作线程。
- 半同步/半异步模式的工作流程如下图所示:
半同步/半反应堆模式
- 在服务器程序中,如果结合考虑两种事件处理模式的几种I/O模型,则半同步/半异步模式就存在多种
变体
。- 其中一种就叫做半同步/半反应堆模式(half-sync/half-reactive),如下图所示:
- half-reactive体现在工作线程读写连接socket上的数据,详见下面。
如上图所示,
异步线程只有一个,由主线程来充当
,它负责监听所有socket上的事件
。
- 如果
监听socket
上有可读事件发生
(监听socket在服务端当然只能发生可读事件,哪有自己给自己发消息的,即在监听socket上写),即有新的连接到来,主线程就接受以得到新的连接socket
,然后往epoll内核事件表中注册
该socket的读写事件。- 如果
连接socket
上有读写事件发生
,即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接socket插入请求队列
中。- 所有
工作线程都睡眠在请求队列上
,当有任务到来时,它们将通过竞争
(比如申请互斥锁)来获得任务的接管权
。这种竞争机制使得只有空闲
的工作线程才有机会来处理新任务
,这是很合理的。
- 上图中,主线程插入请求中队列中的任务是
就绪的连接socket
(即,该连接socket上有读写事件发生)。这说明该图所示的半同步/半反应堆模式采用的事件处理模式是
Reactor模式
,它要求工作线程自己从socket上读取客户数据和往socket上写入服务器应答
。
- 这就是其名字(half-reactive)的含义。
半同步/半反应堆也可以
模拟Proactor事件处理模式
,即由主线程完成数据的读写
。在这种情况下:
主线程
一般会将应用程序数据、任务类型等信息封装成一个任务对象
(即把对应socket上的数据读出来,封装到一个任务对象中);- 然后将其(或者指向该任务对象的一个指针)
插入请求队列
;工作线程
从请求队列中取得
任务对象之后,即可处理
之,无需读写操作。
- 半同步/半反应堆模式存在如下缺点:
主线程和工作线程共享请求队列
。
- 主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费CPU时间。
每个工作线程在同一时间只能处理一个客户请求
。
- 如果客户数量较多,而工作线程较少,则请求队列中奖
堆积很多任务对象,客户端的响应速度将越来越慢
。- 如果通过增加工作线程来解决这一问题,则工作线程的
切换也将耗费大量CPU时间
。
相对高效的半同步/半异步模式
- 下图描述了一种相对高效的半同步/半异步模式,它的
每个工作线程都能同时处理多个客户连接
。
- 上图中:
主线程只管理监听socket,连接socket由工作线程来管理
。
- 当有新的连接到来时,主线程就接受之并将
新返回的连接socket派发给某个工作线程
。- 此后
该连接socket上的任何I/O操作都由被选中的工作线程
来处理,直到客户关闭连接。主线程派发socket的最简单的方式,是往它和工作线程之间的
管道
里写数据。
- 工作线程检测到
管道上有数据可读时
,就分析是否是一个新的客户连接到来。如果是,则把该新的连接socket上的读写事件注册到自己的epoll内核事件表中。
以后该连接socket上的所有I/O事件都由此工作线程进行监听与操作,直到客户关闭连接。
- (与上面重复了,这里再写一遍我想印象会深一些。)
- 如上图所示,每个线程(主线程与工作线程)都维持自己的事件循环,它们各自独立地监听不同的事件。
- 因此,在这种高效的半同步/半异步模式中,
每个线程都工作在异步模式
,所以它并非严格意义上的半同步/半异步模式。
领导者/追随者模式
领导者/追随者模式是
多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件
的一种模式。
- 在任意时间点,程序都
仅有一个领导者线程,它负责监听I/O事件。
- 其它线程都是追随者,它们休眠在线程池中等待成为新的领导者。
- 当前的领导者如果
检测到I/O事件
,首先要从线程池中推选出新的领导者线程
,然后处理I/O事件
。- 此时,
新的领导者等待新的I/O事件
,而原来的领导者则处理当前检测到的I/O事件
,二者实现了并发。
领导者/追随者模式包含如下几个组件:
- 句柄集(HandleSet)
- 线程集(ThreadSet)
- 事件处理器(EventHandler)
- 具体的事件处理器(ConcreteEventHandler)
- 它们的关系如下图所示:
句柄集:
- 句柄(Handle)用于表示
I/O资源
,在Linux下通常就是一个文件描述符
。- 句柄集管理众多句柄,它使用wait_for_event方法来
监听
这些句柄上的I/O事件,并将其中的就绪事件通知给领导者线程
。- 领导者线程调用绑定到Handle上的事件处理器来处理事件。
- 领导者将Handle和事件处理器绑定是通过调用句柄集中的register_handle方法实现的。
线程集:
线程集是所有工作线程(包括领导者线程和追随者线程)的管理者
。- 它负责各个线程之间的
同步
,以及新领导者线程的推选
。线程集中的线程在任一时间必处于如下
三种状态之一
;
- Leader: 线程单当前处于
领导者身份
,负责等待
句柄集上的I/O事件。Processing: 线程
正在处理事件
。
- 领导者检测到I/O事件之后,可以
转移
到Processing状态来处理事件,并调用promote_new_leader方法推选
新的领导者;也可以指定
其他追随者来处理
事件(Event Handoff),此时领导者的地位不变
。- 当处于Processing状态的线程处理完事件之后,如果当前线程集中
没有
领导者,则它将成为新的领导者
,否则它就直接转变为追随者
。- Follower: 线程当前处于
追随者身份
,通过调用线程集的join方法等待成为新的领导者
,也可能被当前的领导者指定来处理新的任务
。- 如下图所示这三种状态之间的转换关系:
- 需要注意的是,领导者线程推选新的领导者和追随者等待成为新的领导者,这两个操作都将修改线程集,因此线程集提供一个成员Synchronizer来同步这两个操作,以
避免竞态
条件。事件处理器和具体的事件处理器:
- 事件处理器通常包含一个过多个回调函数(handle_event)。这些
回调函数用于处理事件对应的业务逻辑
。- 事件处理器在使用前需要被
绑定到某个句柄上
,当该句柄上有事件发生时
,领导者就执行与之绑定的事件处理器中的回调函数
。- 具体的事件处理器是事件处理器的派生类,它们必须
重新实现基类handle_event方法,以处理特定的任务
。- 综上所述,领导者/追随者工作流程如下图所示:
- 由于
领导者线程自己监听I/O事件并处理客户请求
,因而领导者/追随者模式不需要在线程之间传递任何额外的数据
,也无须
像半同步/半反应堆那样在线程之间同步
对请求队列的访问
。- 但领导者/追随者的一个明显缺点是
仅支持一个事件源集合
,因此也无法像高效的半同步/半异步模式那样,让每个工作线程独立地管理多个客户链接。