第八章 高性能服务器程序框架(3) - Huoke/Linux-net-Programma GitHub Wiki
8.5 两种高效的并发模式
并发编程的目的是让程序 “同时” 执行多个任务呀。如果程序时计算密集型,并发编程并没有优势,反而由于任务的切换使效率降低。但如果程序时I/O密集型的,比如经常读写文件,访问数据库等,则情况就不同了。由于I/O操作的速度远没有 CPU的计算速度快,所以让程序阻塞于I/O操作将浪费大量的 CPU时间。如果程序由多个执行线程, 则当前被 I/O 操作所阻塞的执行线程可主动放弃 CPU(或者由操作系统来调度),并将执行权限转移到其他线程。这样一来, CPU就可以用来做更加有意义的事情(除非所有线程都同时被 I/O 操作所阻塞),而不是等待I/O操作完成,因此CPU的利用率显著提升。
从实现上来说, 并发编程主要有多进程和多线程两种方式,我们将在后续章节详细讨论它们,这一节将讨论并发模式。对应于图8-4
并发模式是指I/O处理单元和多个逻辑单元之间协调完成任务的方法。服务器主要有两种并发编程模式: 半同步/ 半异步模式 和 领导者/追随者模式。
8.5.1 半同步/半异步模式
首先, 半同步/半异步模式中的 “同步” 和 “异步” 与前面讨论的I/O模型中的 “同步” 和 “异步” 是完全不同的概念。
- 在I/O模型中,“同步” 和 “异步” 区分的是内核向应用程序通知的是何种I/O事件(是就绪事件还是完成事件),以及该由谁来完成I/O读写(是应用程序还是内核)。
- 在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行;“异步”指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。比如图8-8a描述了同步的读操作,而图8-8b则描述了异步的读操作。
按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。显然,异步线程的执行效率高。实时性强,这是很多切入式程序采用的模型。但编写以异步方式执行的程序相对复杂,难以调试和扩展,而且不适合大量的并发。而同步线程则相反,它虽然效率相对较低,实时性较差,但逻辑简单。因此,相对于像服务器这种既要求较好的实时性,又要求能同时处理多个客户请求的应用程序,我们就应该同时使用同步线程和异步线程来实现,即采用半同步/半异步模式来实现。
<1> 半同步/半异步模式
半同步/半异步模式中,同步线程用于处理客户逻辑,相当于图8-4中的逻辑单元;异步线程用于处理I/O事件,相当于图8-4中的I/O处理单元。异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。具体选择哪个工作线程来为新的客户请求服务,则取决于请求队列的设计。比如最简单的轮流选取工作线程的Round Robin算法,也可以通过条件变量(第14章)或信号量(第14章)来随机第选择一个工作线程。图8-9总结了半同步/半异步模式的工作流程。
服务器程序中,如果结合考虑两种事件处理模式和几种I/O模型,则半同步/半异步模式就存在多种变体。其中有一种变体称为半同步/半反应堆模式,如图8-18所示:
图8-10中,异步线程只有一个,由主线程来充当。它负责监听所有 socket 上的事件。如果监听socket上有可读事件发生,即有新的连接请求到来,主线程就接受之得到新的连接 socket,然后往epoll内核事件表中注册该 socket 上的读写事件。如果连接socket上有读写事件发生,即有限的客户请求到来或有数据要发送到客户端,主线程就将该连接socket插入请求队列中。所有工作线程都睡眠在请求队列上,当有任务到来时,它们将通过竞争(比如申请互斥锁)获得任务的接管权。这种竞争机制使得只有空闲的工作线程才有机会来处理新任务,这是很合理的。
图8-10中,主线程插入请求队列中的任务是就绪的连接 socket。这就说明该图所示的半同步/半反应堆模式采用的事件处理模式是Reactor模式:它要求工作线程自己从 socket上读取客户请求和往socket写入服务器应答。实际上,半同步/半反应堆模式也可以使用模拟的Proactor事件处理模式,即由主线程来完成数据的读写。在这种情况下,主线程一般会将应用程序数据、任务类型等信息封装为一个任务对象,然后将其(或者执行该任务对象的一个指针)插入请求队列中。工作线程从请求队列中取得任务对象之后,即可直接处理,不需要执行读写操作。我们将在第15章给出一个用半同步/半反应堆模式实现的简单Web服务器的代码。
半同步/半反应器模式存在如下缺点:
- 主线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费CPU时间。
- 每个工作线程在同一时间只能处理一个客户请求。如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢。如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量CPU时间。
下面将描述一种相对高效的半同步/半异步模式,它的每个工作线程都能同时处理很多个客户连接。
<2> 高效的半同步/半异步模式
上图中,主线程只管理监听 socket,连接socket由工作线程来管理。当有新的连接到来时,主线程就接受之并将返回的连接socket派发给某个工作线程,此后该新 socket上的任何I/O操作都由被选中的工作线程来处理,直到客户关闭连接。主线程向工作线程派发 socket的最简单的方式,就是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新socket上的读写事件注册到自己的epoll内核事件表中。
可见,图8-11中,每个线程(主线程和工作线程)都是维持自己的事件循环,它们各自独立地监听不同的事件。因此,在这种高效的半同步/半异步模式中,每个线程都工作在异步模式,所以它并非严格意义上的半同步/半异步模式。我们将在第15章给出一个用这种高效的半同步/半异步模式实现的简单CGI服务器的代码。