【Socket】两种高效事件处理模式&并发模式

9

两种高效事件处理模式&并发模式

  • 来源如下,侵删。
    • 游双-《Linux高性能服务器编程》
  • 本来想做个笔记的,但是发现这块内容书中很多都感觉是有用的,所以很大篇幅的搬了过来,其中加入了我的理解,并有重点标注。

服务器编程框架

  • 服务器程序种类繁多,但是基本框架都一样,不同之处在于逻辑处理
  • 下图所示,服务器基本框架。该图既能用来描述一台服务器,也能用来描述一个服务器机群

image-20221019112102896

  • 各模块概念
模块 单个服务器框架 服务器机群
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模式的工作流程:

    1. 主线程往epoll内核事件表中注册socket上的读就绪事件。(监听socket与连接socket成功建立连接后,以下socket都指的是连接socket)
    2. 主线程调用epoll_wait等待socket上有数据可读。
    3. 当socket上有数据可读时,epoll_wait通知主线程。主线程则将socket可读事件放入请求队列。
    4. 睡眠在请求队列上的某个工作线程被唤醒,它从连接socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件。
    5. 主线程调用epoll_wait等待scoket可写。
    6. 当socket可写时,epoll_wait通知主线程。主线程将socket可写事件放入请求队列。
    7. 睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果。
  • 总结: 主线程仅负责监听socket看是否有发生事件,然后就通知工作线程读取,处理数据,如为写事件(即,要应答),再在epoll内核事件表上注册该连接socket的可写事件,然后再由某个工作线程接管,处理,执行应答,读事件同理。

  • Reactor模式工作流程图如下所示:

image-20221019165354383

  • 工作线程从请求队列中取出事件后,将根据事件的类型决定如何处理它。
    • 对于可读事件,执行读数据和处理请求的操作;
    • 对于可写事件,执行写数据的操作;
  • 因此,如上图所示的Reactor模式中,没必要区分所谓的“读工作线程”和“写工作线程”。

Proactor模式

  • 与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。

  • 因此,Proactor模式更符合服务器基本框架图中的描述。

  • 使用异步I/O模型(以aio_read和aio_write为例)实现的Proactor模式的工作流程:

    1. 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例,详情请参考sigevent的man手册)。
    2. 主线程继续处理其它逻辑
    3. 当socket上的数据被读入用户缓冲区后,内核用户向应用程序发送一个信号,以通知应用程序数据已经可用
    4. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操完成时如何通知应用程序(仍以信号为例)。
  • 总结: 内核用户与主线程进行I/O操作,由信号通知主线程唤醒一个工作线程进行处理数据(业务逻辑),业务处理完,再交给内核用户与主线程进行I/O操作(服务器应答)。

  • Proactor模式工作流程图如下图所示:

image-20221020180038685

  • 上图中,连接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)

    1. 主线程往epoll内核事件表中注册socket上的读就绪事件
    2. 主线程调用epoll_wait等待socket上有数据可读
    3. 当socket上有数据可读时,epoll_wait通知主线程。主线程从socket上循环读取数据,将读取到的数据封装成一个请求对象并插入到请求队列中。
    4. 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核表中注册socket上的写就绪事件
    5. 主线程调用epoll_wait等待socket可读。
    6. 当socket可写时,epoll_wait通知主线程主线程往socket上写入服务器处理客户请求的结果
  • 总结: 主线程负责I/O操作,工作线程仅负责数据的处理(业务逻辑)。

  • 工作流程如下图所示:

image-20221020180055400


两种高效的并发模式

  • 并发编程的目的是让程序“同时”执行多个任务。
  • 如果程序是计算密集型的,并发编程并没有优势,反而由于任务的切换使效率降低
  • 如果程序是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)

image-20221020091435304


半同步/半异步模式

  • 这里的半同步/半异步模式中的“同步"与”异步“与I/O模型中的“同步"与”异步“是完全不同的概念
  • I/O模型中:
    • “同步"与”异步“区分的是内核向应用程序通知的是何种I/O事件(是就绪事件还是完成事件);
    • 以及该由谁来完成I/O读写(是应用程序还是内核)。
  • 在并发模式中:
    • "同步"指的是程序完全按照代码序列的顺序执行
    • “异步”指的是程序的执行需要由系统事件来驱动
    • 常见的系统事件包括中断、信号等。
  • 下图a描述了同步的读操作,下图b描述了异步的读操作。

image-20221020092441729

  • 按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程
  • 相比于同步线程,`异步线程的执行效率更高,实时性强。——(异步线程优点)
    • 但编写以异步方式执行的程序相对复杂,难于调试和扩展,而且不适合于大量的并发。——(异步线程缺点)
  • 同步线程则相反,虽然它效率相对较低实时性较差,但是逻辑简单。——(同步线程优缺点)

  • 因此,对于像服务器这种及要求较好的实时性,又要求能同时处理多个客户请求的应用程序,我们就应该同时使用同步线程与异步线程来实现,即采用半同步/半异步模式来实现。

  • 半同步/半异步模式中:

    • 同步线程用于处理客户逻辑,相当于服务器基本框架图中的逻辑单元

    • 异步线程用于处理I/O请求, 相当于服务器基本框架图中的I/O处理单元。

    • 工作线程处理I/O操作,所以半同步/半异步模式采用的是Reactor事件处理模式。

    • 异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。具体选择哪个工作线程来为新的客户请求服务器,则取决于请求队列的设计。

    • 比如最简单的轮流选取工作线程的Round Robin算法,也可以通过条件变量或信号量来随机地选择一个工作线程。

  • 半同步/半异步模式的工作流程如下图所示:

image-20221020122034374


半同步/半反应堆模式

  • 在服务器程序中,如果结合考虑两种事件处理模式的几种I/O模型,则半同步/半异步模式就存在多种变体
  • 其中一种就叫做半同步/半反应堆模式(half-sync/half-reactive),如下图所示:
  • half-reactive体现在工作线程读写连接socket上的数据,详见下面。

image-20221020122349283

  • 如上图所示,异步线程只有一个,由主线程来充当,它负责监听所有socket上的事件
    • 如果监听socket上有可读事件发生(监听socket在服务端当然只能发生可读事件,哪有自己给自己发消息的,即在监听socket上写),即有新的连接到来,主线程就接受以得到新的连接socket,然后往epoll内核事件表中注册该socket的读写事件。
    • 如果连接socket上有读写事件发生,即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接socket插入请求队列中。
  • 所有工作线程都睡眠在请求队列上,当有任务到来时,它们将通过竞争(比如申请互斥锁)来获得任务的接管权。这种竞争机制使得只有空闲的工作线程才有机会来处理新任务,这是很合理的。

  • 上图中,主线程插入请求中队列中的任务是就绪的连接socket(即,该连接socket上有读写事件发生)。
  • 这说明该图所示的半同步/半反应堆模式采用的事件处理模式是Reactor模式,它要求工作线程自己从socket上读取客户数据和往socket上写入服务器应答
    • 这就是其名字(half-reactive)的含义。

  • 半同步/半反应堆也可以模拟Proactor事件处理模式,即由主线程完成数据的读写。在这种情况下:
    • 主线程一般会将应用程序数据、任务类型等信息封装成一个任务对象(即把对应socket上的数据读出来,封装到一个任务对象中);
    • 然后将其(或者指向该任务对象的一个指针)插入请求队列
    • 工作线程从请求队列中取得任务对象之后,即可处理之,无需读写操作。

  • 半同步/半反应堆模式存在如下缺点:
  • 主线程和工作线程共享请求队列
    • 主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费CPU时间。
  • 每个工作线程在同一时间只能处理一个客户请求
    • 如果客户数量较多,而工作线程较少,则请求队列中奖堆积很多任务对象,客户端的响应速度将越来越慢
    • 如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量CPU时间

相对高效的半同步/半异步模式

  • 下图描述了一种相对高效的半同步/半异步模式,它的每个工作线程都能同时处理多个客户连接

image-20221020175911893

  • 上图中:
  • 主线程只管理监听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)
  • 它们的关系如下图所示:

image-20221020185642989

  • 句柄集:
    • 句柄(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方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务
  • 如下图所示这三种状态之间的转换关系:

image-20221020202845974

  • 需要注意的是,领导者线程推选新的领导者和追随者等待成为新的领导者,这两个操作都将修改线程集,因此线程集提供一个成员Synchronizer来同步这两个操作,以避免竞态条件。

  • 事件处理器和具体的事件处理器:

    • 事件处理器通常包含一个过多个回调函数(handle_event)。这些回调函数用于处理事件对应的业务逻辑
    • 事件处理器在使用前需要被绑定到某个句柄上,当该句柄上有事件发生时,领导者就执行与之绑定的事件处理器中的回调函数
    • 具体的事件处理器是事件处理器的派生类,它们必须重新实现基类handle_event方法,以处理特定的任务
  • 综上所述,领导者/追随者工作流程如下图所示:

image-20221020204100503

  • 由于领导者线程自己监听I/O事件并处理客户请求,因而领导者/追随者模式不需要在线程之间传递任何额外的数据,也无须像半同步/半反应堆那样在线程之间同步对请求队列的访问
  • 但领导者/追随者的一个明显缺点是仅支持一个事件源集合,因此也无法像高效的半同步/半异步模式那样,让每个工作线程独立地管理多个客户链接。