两种高效的并发模式
- 并发编程的目的是让程序“同时”执行多个任务。
如果程序是计算密集型的,并发编程并没有优势,反而由于任务的切换使效率降低。
但如果程序是I/O密集型的,比如经常读写文件,访问数据库等,则情况就不同了。 - 由于I/O操作的速度远没有CPU计算速度快,所以让程序阻塞I/O操作将浪费大量CPU时间。
如果程序有多个执行线程,则当前被I/O操作所阻塞的执行线程可主动放弃CPU(或由操作系统来调度),并将执行权转移到其他线程。这样一来,CPU就可以用来做更加有意义的事情(除非所有线程都同时被I/O操作所阻塞),而不是等待I/O操作完成,因此这样做能使CPU的利用率可以显著提升。 - 从实现上来说,并发编程主要有多进程和多线程两种方式。
- 并发模式是指I/O处理单元和多个逻辑单元之间协调完成任务的方法。
- 服务器主要有两种并发编程模式:
- 半同步/半异步模式
- 领导者/追随者模式
半同步/半异步模式
- 首先,半同步/半异步模式中的“同步”和“异步”与I/O模型中的“同步”和“异步”完全是不同的概念。
- 在I/O模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种I/O事件(是就绪事件还是完成事件)以及该由谁来完成I/O读写(是应用程序还是内核)。
- 在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行;“异步”指的是程序的执行需要由系统事件来驱动。
常见的系统事件包括中断,信号等。
- 按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。
显然,异步线程的执行效率高,实时性强。但编写以异步方式执行的程序相对复杂,难以调试和扩展,而且不适合于大量的并发。
而同步线程则相反,虽然效率相对较低,实时性较差,但逻辑简单。 - 因此,对于服务器这种既要求实时性好,又要求能同时处理多个客户请求的应用程序,我们就应该同时使用同步线程和异步线程来实现,即采用半同步/半异步来实现。
- 半同步/半异步模式中:
- 同步线程用于处理客户逻辑,异步线程用于处理I/O事件。
- 异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。
具体选择哪个工作线程来为新的客户请求服务,取决于请求队列的设计。
- 在服务器程序中,如果考虑两种事件处理模式和几种I/O模型,则半同步/半异步模式就存在多种变体。
其中有一种变体称为半同步/半反应堆模式:- 异步线程只有一个,由主线程来充当。它负责监听所有socket上的事件。
- 如果监听socket上有可读事件发生,即有新的连接请求到来,主线程就接收之以得到新的连接socket,然后往epoll内核事件表中注册socket上的读写事件。
- 如果连接socket上有读写事件发生,既有新的客户请求到来或有数据要发送的客户端,主线程就将该连接socket插入请求队列中。
- 所有工作线程都睡眠在请求队列上,当任务到来时,它们将通过竞争(比如申请互斥锁)获得任务的接管权。这种竞争机制使得只有空闲的工作线程才有机会来处理新任务,这是很合理的。
- 上图中主线程插入请求队列中的任务是就绪的连接socket。
说明该图所示的半同步/半反应堆模式采用的事件处理模式是Reactor模式:- 要求工作线程自己从socket上读取客户端请求和往socket写入服务器应答。这就是半同步/半反应堆中半反应堆的含义。
- 实际上,半同步/半反应堆模式也可以使用模拟的Proactor事件处理模式,即由主线程来完成数据的读写。在这种情况下,主线程一般会将应用程序书、任务类型等信息封装成一个任务对象,然后将其插入请求队列。
工作线程从请求队列中取得任务对象之后,即可直接处理,而无须执行读写操作了。
- 半同步/半反应堆模式存在一些缺点:
- 主线程和工作线程共享请求队列。
主线程往请求队列里面添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费CPU时间。 - 每个工作线程在同一时间只能处理一个客户请求。
如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢。
如果增加工作线程来解决这一问题,则工作线程的切换也将耗费大量CPU时间。 - 下图是一种相对高效的半同步/半异步模式,它的每个工作线程都能同时处理多个客户连接:
- 主线程和工作线程共享请求队列。
- 主线程只管理监听socket,连接socket由工作线程来管理。
当有新的连接到来时,主线程就接受之并将返回的连接socket派发给某个工作线程,此后该新socket上的任何I/O操作都由被选中的工作线程来处理,直到客户关闭连接。
主线程向工作线程派发socket的最简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新的socket上的读写事件注册到自己的epoll内核事件表中。
上图中,每个线程(主线程和工作线程)都维持自己的事件循环,它们各自独立地监听不同的事件。因此,在这种高效的半同步/半异步模式中,每个线程都工作在异步模式,所以它并非严格意义上的半同步/半异步模式。
领导者/追随者模式
- 领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。
- 在任意时间点,程序都仅有一个领导者线程,它负责监听I/O事件。而其他线程则是追随者,它们休眠在线程池中等待称为新的领导者。
当前的领导者如果检测到I/O事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时,新的领导者等待新的I/O事件,而原来的领导者则处理I/O事件,二者实现了并发。 - 领导者/追随者模式包含了如下几个组件:
- 句柄集(HandleSet)
- 线程集(ThreadSet)
- 事件处理器(EventHandler)
- 具体的事件处理器(ConcreteEventHandler)
句柄集
- 句柄用于表示I/O资源,在Linux下通常就是一个文件描述符。
- 句柄集管理众多句柄,它使用wait_for_event方法来监听这些句柄上的I/O事件,并且将其中的就绪事件通知给领导者线程。
领导者线程则调用绑定到Handle上的事件处理器来处理事件。
领导者将Handle和事件处理器绑定是通过调用句柄集中的register_handle方法实现的。
线程集
-
这个组件是所有工作线程(包括领导者线程和追随者线程)的管理者。
-
它负责各线程之间的同步,以及新领导者线程的推选。
-
线程集中的线程在任一时间必处于如下三种状态之一:
- Leader:线程当前处于领导者身份,负责等待句柄集上的I/O事件。
- Processing:线程正在处理事件。领导者检测到I/O事件后,可以转移到Processing状态来处理该事件,并调用promote_new_leader方法推选新的领导者;也可以指定其他追随者来处理事件,此时领导者的地位不变。
当处于Processing状态的线程处理完事件后,如果当前线程集中没有领导者,则它将称为新的领导者,否则他就直接转变为追随者。 - Follower:线程当前处于追随者身份,通过调用线程集的join方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务。
- 需要注意的是,领导者线程推选新的领导者和追随者等待成为新领导者这两个操作都将改变线程集,因此线程集提供一个成员Synchronizer来同步这两个操作,以避免竞态条件。
事件处理器和具体的事件处理器
- 事件处理器通常包含一个或多个回调函数handle_event。这些回调函数用于处理事件对应的业务逻辑。
- 事件处理器在使用前需要被绑定到某个句柄上,当该句柄上有事件发生时,领导者就执行与之绑定的事件处理器中的回调函数。
具体的事件处理器是事件处理器的派生类。它们必须重新实现基类的handle_event方法,以处理特定的任务。
- 由于领导者线程自己监听I/O事件并处理客户请求,因而领导者/追随者模式不需要在线程之间传递任何额外的数据,也无需像半同步/半反应堆模式那样在线程之间同步对请求队列的访问。
- 但领导者/追随者模式的一个明显的缺点是仅支持一个事件源集合,因此也无法像8-11所示的那样,让每个工作线程独立地管理多个客户连接。
有限状态机
- 逻辑单元内部的一种高效编程方法。
- 有的应用层协议头部包含数据报类型字段,每种类型可以映射为逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 |
// 状态独立的有限状态机 STATE_MACHINE(Package _pack) { PackageType _type = _pack.GetType(); switch(_type) { case type_A: process_package_A(_pack); break; case type_B: process_package_B(_pack); break; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// 待状态转移的有限状态机 STATE_MACHINE() { State cur_state = type_A; while(cur_state != type_C) { Package _pack = getNewPackage(); switch(_pack) { case type_A: process_package_state_A(_pack); break; case type_B: process_package_state_B(_pack); cur_state = type_C; break; } } } |
提高服务器性能
- 影响服务器性能的首要因素就是系统的硬件资源,比如CPU的个数,速度,内存的大小等。
- 但是现代服务器一般都不怎么缺乏硬件资源,可以考察从服务器的“软环境”方面来提升服务器的性能。
关于服务器的“软环境”,一方面是指系统的软件资源,比如操作系统允许用户打开的最大文件描述符的数量;一方面指的是服务器程序本身,即如何从编程的角度来确保服务器的性能。
池
- 硬件资源”充裕“的情况下,提高服务器性能的一个方法就是以空间换时间,即”浪费“服务器的硬件资源,以换取其允许效率。这就是池的概念。
- 池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源分配。
- 当服务器进入正式允许阶段,即开始处理客户请求的时候,如果它需要相关的资源,就可以直接从池里面获取,无须动态分配。
很显然,直接从池里获取所需的资源比动态分配资源的速度要快得多,因为分配系统资源的系统调用都是很耗时的。 - 当服务器处理完一个客户连接后,可以把相关的资源放回池中,无须执行系统调用来释放资源。
- 池的资源是预先分配的,无法准确预期应该分配多少资源,有两种方案:
- 一种是一开始就分配”足够多“的资源
- 一种是预先分配一定的资源,此后如果发现资源不够用,就再动态分配一些并加入池中
- 常见的池的类型:
- 内存池
- 线程池
- 连接池
- 内存池通常用于socket的接收缓存和发送缓存。对于某些长度有限的客户请求,比如HTTP请求,预先分配一个大小足够(比如5000个字节)的接收缓冲区是很合理的。
当客户请求的长度超过接收缓冲区的大小时,可以选择丢弃请求或者动态扩大接收缓冲区。 - 进程池和线程池都是并发编程常用的技术。当我们需要一个工作进程或工作线程来处理新到来的客户请求时,我们可以直接从进程池或线程池中取得一个执行实体,而无须动态地调用fork或者pthread_create等函数来创建进程或线程。
- 连接池通常用于服务器或者服务器机群的内部永久连接。
如图8-4中所示,每个逻辑单元可能都需要频繁地访问本地的某个数据库,简单的做法是:逻辑单元每次需要访问数据库的时候,就向数据库程序发起连接,而访问完毕后释放连接。但是这样效率是比较低的。
一种解决方案是使用连接池。连接池是服务器预先和数据库建立的一组连接的集合。当某个逻辑单元需要访问数据库的时候,它可以直接从连接池中取得一个连接的实体并使用。待完成数据库的访问后,逻辑单元再将该连接返还给连接池。
数据复制
- 高性能服务器应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候。
- 如果内核可以直接处理从socket或者文件读入的数据,则应用程序就没必要将这些数据从内核缓冲区复制到应用程序缓冲区中。(这里说的”直接处理”是指应用程序不关心这些数据的内容,不需要对它们做任何分析)
- 此外,用户代码内部(不访问内核)的数据复制也是应该避免的。
比如当两个进程间需要传递大量数据时,我们就应该使用共享内存来在它们之间直接共享这些数据,而不是使用管道后者消息队列来传递。
上下文切换和锁
- 并发编程必须考虑上下文切换的问题,即进程切换或线程切换导致的系统开销。
- 即使是I/O密集型的服务器,也不应该使用过多的工作线程(或工作进程)。否则线程间的切换将占用大量的CPU时间,服务器真正用于处理业务逻辑的CPU时间的比重就显得不足了。
因此,为每个客户连接都创建一个工作线程的服务器模型是不可取的。
上面描述的半同步/半异步模型是一种比较合理的解决方案,它允许一个线程同时处理多个客户连接。 - 此外,多线程服务器的一个优点是不同的线程可以同时运行在不同的CPU上。当线程的数量不大于CPU的数目时,上下文的切换就不是问题了。
- 并发编程需要考虑的另外一个问题是共享资源的加锁保护。锁通常被任务是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。
因此,服务器如果有更好的解决方案,就应该避免使用锁。
如果服务器必须使用锁,则可以考虑减小锁的粒度,比如使用读写锁。当所有工作线程都只读取一块共享内存的内容时,读写锁并不会增加系统的额外开销。只有当其中某一个工作线程需要写这块内存时,系统才必须去锁住这块区域。
本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ Linux_ 命令大全 电子邮件与新闻组03/16
- ♥ Linux_命令大全 压缩备份03/16
- ♥ Linux 高性能服务器编程:高级I/O函数11/28
- ♥ 51CTO:Linux C++网络编程二08/14
- ♥ gcc/g++编译器03/21
- ♥ Linux 进程描述&&相关介绍03/28