文章目录
- 同步与异步
- 半同步/半异步模式
-
- 变体:半同步/半反应堆模式
- 改进:更高效的半同步/半异步模式
- 领导者/追随者模式
-
- 组件 :句柄集、线程集、事件处理器
并发模式是指I/O处理单元和多个逻辑单元之间协调完成任务的方法。服务器主要有两种并发编程模式:半同步/半异步模式(half-sync/half-async)模式和领导者/追随者(Leader/Followers)模式
同步与异步
首先在这里要指出,这里的"同步"和“异步”与I/O模型中的”同步I/O“与“异步I/O”是两个完全不同的概念,不能将其混淆。
在I/O模型中,”同步I/O“和”异步I/O“主要的区别就是内核向应用程序通知的是哪种I/O事件(就绪事件/完成事件),以及由谁来完成I/O的读写操作(应用程序/内核)。
- 同步I/O:内核向应用程序通知就绪事件,由应用程序自身来完成I/O的读写操作
- 异步I/O:由内核来完成I/O的读写后向应用程序通知完成事件
在并发模式中,”同步和”异步“主要的区别就是功能完成的流程是否是顺序化的,是否需要等待
- 同步:当遇到阻塞任务的时候,就会一直等到到处理完成,程序完全按照代码序列的顺序执行,
- 异步:程序的执行需要由系统事件来驱动,当遇到任务的使用会发起一个系统事件进行处理,此时继续处理其他逻辑,而系统处理完成后则会触发事件返回结果,此时程序的执行流程是不确定的,没有顺序上的要求。
如下面两张图就分别是同步与异步下的读操作。
同步
异步
按照同步方式运行的线程又称为同步线程,按照异步方式运行的线程被称为异步线程。
很显然异步线程的执行效率更高,实时性也强,但是由于编写异步方式执行的程序相对复杂,难以进行调试和拓展,并且不适合于大量的并发。
而同步线程恰恰相反,虽然它的效率相对来说较低,并且实时性差,但是由于其逻辑简单,所以可维护性和拓展性高。
对于服务器来说,我们既需要较好的实时性,又要求能够同时处理多个客户的请求,我们就需要将两者互补,同时使用同步线程和异步线程,即采用半同步/半异步模式来实现
半同步/半异步模式
在半同步/半异步模式中,同步线程用于处理客户逻辑,异步线程用于处理I/O事件。
当异步线程监听到客户请求后,读取socket中的数据,将其封装成请求对象并插入请求队列中。请求队列将通知某个同步线程来获取请求对象并进行业务逻辑的处理。
具体选择哪个同步线程主要取决于请求队列的设计,如轮流选取的Round Robin算法,或使用条件变量、信号量等来随机选取。
下图就是半同步/半异步模式的工作流程。
变体:半同步/半反应堆模式
如果结合之前所讲的事件处理模式以及I/O模型,半同步/半异步模式就会存在很多种变体,其中一种比较具有代表性的变体就是半同步/半反应堆模式(half-sync/half-reactive)。
其工作流程如下图
在图中,异步线程只有主线程一个,它负责监听所有socket上的事件。如果当前有新的连接到来,主线程就会连接socket,并且往epoll内核事件表中注册该socket上的读写事件。如果epoll监控集合中有读写事件就绪,此时就会将socket放入请求队列中。当请求队列中有任务到来时,所有同步线程就会去竞争(比如申请互斥锁)获得任务的管辖权。这种竞争机制使得只有空闲的工作线程才有机会来处理新任务,避免了忙闲不均的情况。
从上面我们可以看到,这种由主线程进行监听,工作线程来完成I/O以及业务逻辑的事件处理模式正是Reactor模式(反应器模式),这也就是名字中半反应堆(half-reactive)的由来。
当然半同步/半反应堆模式也可以使用同步I/O模型模拟的Proactor模式,即由主线程来完成数据的读写,然后将任务以及程序数据封装为任务对象,放入请求队列中。工作线程从请求队列中取得任务对象之后即可直接处理,不需要进行读写操作。
半同步/半反应堆模式的缺点:
- 主线程和工作线程共享请求队列,主线程往请求队列中添加任务,工作线程从请求队列中取出任务是互斥的操作,需要对请求队列进行加锁保护,导致白白浪费CPU时间
- 每个工作线程在同一时间内只能处理一个客户请求,如果客户较多而工作线程较少,就会导致任务队列中堆积大量的任务对象,此时客户端的响应将越来越慢。而如果通过增加工作线程来解决这个问题,就会因为大量的上下文切换导致消耗大量的CPU时间。
改进:更高效的半同步/半异步模式
在前面所描述的几种模式中我们每个工作线程都只能同时处理一个连接,为了改进这一点,就有了下图这种每个工作线程可以同时处理多个客户连接(每个工作线程维护一个epoll) 的改进版本。
在图中,主线程只负责管理用于监听的socket,而新连接的socket派发给工作线程来管理。
所以每当有新连接到来时,主线程就会接收连接,并将新返回的连接socket派发给某个工作线程,之后在该socket上的任何I/O操作都由被选中的工作线程进行管理,直到客户关闭连接。
主线程向工作线程派发socket最简单的方式就是往它和工作线程之间的管道里写入数据。如果工作线程检测到管道中有数据可读,就会判断是否是一个新的客户连接请求到来,如果是,则把该socket上的读写事件注册到自己的内核事件表中。
从图中可以看出,在该模式下每个线程(主线程和工作线程)都维护了自己的事件循环(epoll),它们各自独立地监听不同的事件,每个线程都工作在异步模式,因此它并非严格意义上的半同步/半异步模式。
领导者/追随者模式
领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流进行监听、分发并处理事件者。的一种模式。
在任意时间点中,程序都只会有一个领导者线程,它负责进行I/O事件的监听。而其他的线程则为追随者线程,他们会休眠在线程池中,等待成为新的领导者。
如果当前的领导者检测到I/O事件,他就会在线程池中推选出新的领导者线程,然后自己去处理I/O事件。此时前领导者去进行I/O事件的处理,而新领导者则会继续等待新的I/O事件的到来,以此完成并发。
用通俗点的方法来讲就像是一群在营地中轮流放哨的哨兵,每次都会有一个人在值班,而其他人去休息。当值班者发现有什么特殊情况的时候就会去让领班叫醒一个哨兵来继续放哨,然后自己去探查情况。如果探查情况完后没人值班,则自己继续盯梢,否则就去休息。
组件 :句柄集、线程集、事件处理器
领导者/追随者模式包含的组件有:句柄集(HandleSet)、线程集(ThreadSet)、事件处理器(EventHandler),它们之间的关系如图所示。
句柄集
句柄用于表示I/O资源,在Linux下通常就是一个文件描述符。句柄集其实就是句柄的监控集合,通过调用wait_for_event方法来监听这些句柄上的I/O事件,并将其中的就绪事件通知给领导者线程,而领导者线程则调用绑定到Handle上的事件处理器来完成事件的处理。
线程集
线程集是所有工作线程(包括领导者和追随者)的管理者。它负责各线程之间的同步,以及新领导者线程的推选。
线程集中的线程在任意时间都必然处于下面三种状态之一
- 领导者(Leader)
线程此时处于领导者身份,负责监听句柄集上的I/O事件 - 事件处理中(Processing)
此时线程正在处理事件。领导者检测到I/O事件后,转移到Processing状态进行事件的处理,并且调用promote_new_leader()让线程集推选出新的领导者。如果不想让出领导者的地位,也可以指定其他的追随者来处理事件。
当处于Processing状态的线程处理完事件之后,如果当前线程集中没有领导者,他就会成为新的领导者,否则就会变回追随者。 - 追随者(Follower)
线程此时处于追随者身份,此时处于休眠状态,通过调用线程集中的join()等待被推选为新的领导者,也可能被当前的领导者指定处理新的任务。
状态转移图如下
事件处理器
事件处理器通常包含一个或者多个回调函数handle_event。通过这些回调函数来处理事件对应的业务逻辑。
事件处理器在使用前首先需要被绑定到某个句柄之上,每当该句柄上有事件发生的时候,领导者就执行与之绑定的事件处理器中的回调函数。
通过这几种组件以及特性,领导者/追随者模式的工作流程如上图。
由于领导者线程自己负责监听I/O事件并且自己处理客户请求,所以在领导者/追随者模式中不需要再线程之间传递任何额外的数据,也不需要像半同步/半反应堆模式那样在线程之间同步对请求队列的访问。
但是领导者/追随者模式有一个明显的缺点就是只能支持一个事件源集合,因此无法让每个工作线程独立地管理多个客户连接。