目录
一、服务器模型:
1.C/S模型:
C/S(客户端/服务器)模型:所有客户端都通过访问服务器来获取所需的资源。
C/S模型的逻辑很简单。服务器启动后,首先创建一个(或多个)监听socket,并调用bind函数将其绑定到服务器感兴趣的端口上,然后调用listen函数等待客户连接。服务器稳定运行之后,客户端就可以调用connect函数向服务器发起连接了。由于客户连接请求是随机到达的异步事件,服务器需要使用某种IO模型来监听这一事件。I/O模型有多种,图中服务器使用的是I/O复用技术之一的select系统调用。当监听到连接请求后,服务器就调用accept 函数接受它,并分配一个逻辑单元为新的连接服务。逻辑单元可以是新创建的子进程、子线程或者其他。图中,服务器给客户端分配的逻辑单元是由fork系统调用创建的子进程。逻辑单元读取客户请求,处理该请求,然后将处理结果返回给客户端。客户端接收到服务器反馈的结果之后,可以继续向服务器发送请求,也可以立即主动关闭连接。如果客户端主动关闭连接,则服务器执行被动关闭连接。至此,双方的通信结束。需要注意的是,服务器在处理一个客户请求的同时还会继续监听其他客户请求,否则就变成了效率低下的串行服务器了(必须先处理完前一个客户的请求,才能继续处理下一个客户请求)。如图,服务器同时监听多个客户请求是通过select系统调用实现的。
C/S模型非常适合资源相对集中的场合,并且它的实现也很简单,但其缺点也很明显﹔服务器是通信的中心,当访问量过大时,可能所有客户都将得到很慢的响应。下面讨论的P2P模型解决了这个问题。
2.P2P模型:
P2P 模型比C/S模型更符合网络通信的实际情况。它摒弃了以服务器为中心的格局,让网络上所有主机重新回归对等的地位。P2P模型使得每台机器在消耗服务的同时也给别人提供服务,这样资源能够充分、自由地共享。云计算机群可以看作P2P模型的一个典范。但P2P模型的缺点也很明显:当用户之间传输的请求过多时,网络的负载将加重。
从编程角度来讲,P2P模型可以看作C/S模型的扩展:每台主机既是客户端,又是服务器。因此,我们仍然采用C/S模型来讨论网络编程。
二、服务器编程框架:
虽然服务器程序种类繁多,但其基本框架都一样,不同之处在于逻辑处理。先讨论基本框架,如图所示。
该图既能用来描述一台服务器,也能用来描述一个服务器机群。
模块 | 单个服务器程序 | 服务器机群 |
---|---|---|
I/O处理单元 | 处理客户连接,读写网络数据 | 作为接入服务器,实现负载均衡 |
逻辑单元 | 业务进程或线程 | 逻辑服务器 |
网络存储单元 | 本地数据库、文件或缓存 | 数据库服务器 |
请求队列 | 各单元之间的通信方式 | 各服务器之间的永久TCP连接 |
- IO处理单元是服务器管理客户连接的模块。它通常要完成以下工作:等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端。文)。对于一个服务器机群来说,I/O处理单元是一个专门的接入服务器。它实现负载均衡,从所有逻辑服务器中选取负荷最小的一台来为新客户服务。
- 一个逻辑单元通常是一个进程或线程。它分析并处理客户数据,然后将结果传递给V/O处理单元或者直接发送给客户端(具体使用哪种方式取决于事件处理模式)。对服务器机群而言,一个逻辑单元本身就是一台逻辑服务器。服务器通常拥有多个逻辑单元,以实现对多个客户任务的并行处理。
- 网络存储单元可以是数据库、缓存和文件,甚至是一台独立的服务器。但它不是必须的,比如ssh、telnet等登录服务就不需要这个单元。
- 请求队列是各单元之间的通信方式的抽象。I/O处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。同样,多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来协调处理竞态条件。请求队列通常被实现为池的一部分。对于服务器机群而言,请求队列是各台服务器之间预先建立的、静态的、永久的TCP连接。这种TCP连接能提高服务器之间交换数据的效率,因为它避免了动态建立TCP连接导致的额外的系统开销。
三、I/O模型:
1.阻塞I/O和非阻塞I/O:
我们称阻塞的文件描述符为阻塞IO,称非阻塞的文件描述符为非阻塞IO。
针对阻塞IO执行的系统调用可能因为无法立即完成而被操作系统挂起。
针对非阻塞IO执行的系统调用则总是立即返回,而不管事件是否已经发生。如果事件没有立即发生,这些系统调用就返回-1,和出错的情况一样。
很显然,我们只有在事件已经发生的情况下操作非阻塞I/O(读、写等),才能提高程序的效率。因此,非阻塞I/O通常要和其他I/O通知机制一起使用,比如I/O复用和SIGIO信号。
2.I/O复用:
I/O复用是最常使用的I/O通知机制。它指的是,应用程序通过IO复用函数向内核注册一组事件,内核通过IO复用函数把其中就绪的事件通知给应用程序。Linux 上常用的IO复用函数是select、poll和 epoll_wait。需要指出的是,I/O复用函数本身是阻塞的,它们能提高程序效率的原因在于它们具有同时监听多个IO事件的能力。
3.SIGIO信号:
SIGIO信号也可以用来报告I/O事件。我们可以为一个目标文件描述符指定宿主进程,那么被指定的宿主进程将捕获到SIGIO信号。这样,当目标文件描述符上有事件发生时,SIGIO信号的信号处理函数将被触发,我们也就可以在该信号处理函数中对目标文件描述符执行非阻塞IO操作了。
4.异步I/O:
从理论上说,阻塞I/O、I/O复用和信号驱动I/O都是同步I/O模型。因为在这三种I/O模型中,I/O的读写操作,都是在I/O事件发生之后,由应用程序来完成的。而POSIX规范所定义的异步IO模型则不同。对异步IO而言,用户可以直接对I/O执行读写操作,这些操作告诉内核用户读写缓冲区的位置,以及I/O操作完成之后内核通知应用程序的方式。
异步I/O的读写操作总是立即返回,而不论I/O是否是阻塞的,因为真正的读写操作已经由内核接管。也就是说,同步I/O模型要求用户代码自行执行I/O操作(将数据从内核缓冲区读人用户缓冲区,或将数据从用户缓冲区写人内核缓冲区),而异步I/O机制则由内核来执行I/О操作(数据在内核缓冲区和用户缓冲区之间的移动是由内核在“后台”完成的)。你可以这样认为,同步IO向应用程序通知的是I/O就绪事件,而异步I/O向应用程序通知的是I/O完成事件。
5.I/O模型对比:
I/O模型 | 读写操作和阻塞阶段 |
---|---|
阻塞I/O | 程序阻塞于读写函数 |
I/O复用 | 程序阻塞于IO复用系统调用,但可同时监听多个IO事件。对 IO本身的读写操作是非阻塞的 |
SIGIO信号 | 信号触发读写就绪事件,用户程序执行读写操作。程序没有阻塞阶段 |
异步I/O | 内核执行读写操作并触发读写完成事件。程序没有阻塞阶段 |
四、两种高效的事件处理模式:
随着网络设计模式的兴起,Reactor 和 Proactor事件处理模式应运而生。同步IO模型通常用于实现Reactor模式,异步IO模型则用于实现Proactor模式。
1.Reactor模式:
Reactor是这样一种模式,它要求主线程(I/O处理单元,下同)只负责监听文件描述上是否有事件发生,有的话就立即将该事件通知.工作线程(逻辑单元,下同)。除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。
使用同步I/O模型(以epoll_wait为例)实现的Reactor模式的工作流程是:
- 主线程往epoll内核事件表中注册socket上的读就绪事件。
- 主线程调用epoll_wait等待socket 上有数据可读。
- 当socket 上有数据可读时,epoll_wait通知主线程。主线程则将socket可读事件放入请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,它从 socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件。
- 主线程调用epoll_wait等待socket可写。
- 当socket可写时,epoll_wait通知主线程。主线程将socket可写事件放入请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,它往socket上写人服务器处理客户请求的结果。
Reactor工作流程图:
工作线程从请求队列中取出事件后,将根据事件的类型来决定如何处理它:对于可读事件,执行读数据和处理请求的操作﹔对于可写事件,执行写数据的操作。因此,Reactor模式中,没必要区分所谓的“读工作线程”和“写工作线程”。
2.Proactor模式:
与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。
使用异步I/O模型(以aio_read和 aio_write为例)实现的Proactor模式的工作流程是:
- 主线程调用aio_read 函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序。
- 主线程继续处理其他逻辑。
- 当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
- 应用程序预先定义好的信号处理函数选择一个.工作线程来处理客户请求。.工作线程处理完客户请求之后,调用aio_write函数向内核注册socket 上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序。
- 主线程继续处理其他逻辑。
- 当用户缓冲区的数据被写人socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
- 应用程序预先定义好的信号处理函数选择一-个工作线程来做善后处理,比如决定是否关闭socket。
Proactor工作流程:
连接socket 上的读写事件是通过aio_read/aio_write向内核注册的,因此内核将通过信号来向应用程序报告连接socket 上的读写事件。所以,主线程中的epoll_wait调用仅能用来检测监听socket 上的连接请求事件,而不能用来检测连接socket上的读写事件。
3.使用同步I/O模拟实现Proactor模式:
其原理是:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。
使用同步I/O模型(仍然以epoll_wait为例)模拟出的 Proactor模式的工作流程如下:
- 主线程往epoll 内核事件表中注册socket上的读就绪事件。
- 主线程调用epoll_wait等待socket上有数据可读。
- 当socket 上有数据可读时,epoll_wait通知主线程。主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插人请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll 内核事件表中注册socket上的写就绪事件。
- 主线程调用epoll_wait等待socket可写。
- 当socket可写时,epoll_wait通知主线程。主线程往socket上写人服务器处理客户请求的结果。
用同步IO模拟出的Proactor模式的工作流程图:
五、两种高效的并发模式:
并发编程的目的是让程序“同时”执行多个任务。从实现上来说,并发编程主要有多进程和多线程两种方式。
并发模式是指IO处理单元和多个逻辑单元之间协调完成任务的方法。服务器主要有两种并发编程模式:半同步/半异步模式和领导者/追随者模式。
1.半同步/半异步模式:
- 在I/O模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种IO事件(是就绪事件还是完成事件),以及该由谁来完成I/O读写(是应用程序还是内核)。
- 在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行;“异步”指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。
并发模式的同步和异步:
按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。显然,异步线程的执行效率高,实时性强,这是很多嵌入式程序采用的模型。但编写以异步方式执行的程序相对复杂,难于调试和扩展,而且不适合于大量的并发。而同步线程则相反,它虽然效率相对较低,实时性较差,但逻辑简单。因此,对于像服务器这种既要求较好的实时性,又要求能同时处理多个客户请求的应用程序,我们就应该同时使用同步线程和异步线程来实现,即采用半同步/半异步模式来实现。
半同步/半异步模式中,同步线程用于处理客户逻辑,异步线程用于处理IO事件,异步线程监听到客户请求后。就将其封装成请求对象并插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。
下面介绍一种高效的半同步/半异步模式:
他的每个工作线程都能处理多个客户连接:
主线程只管理监听socket,连接socket由工作线程来管理,当有新的连接到来时,主线程就接受之并将新返回的连接socket 派发给某个工作线程,此后该新socket 上的任何I/O操作都由被选中的工作线程来处理,直到客户关闭连接。
主线程向工作线程派发socket 的最简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新socket上的读写事件注册到自己的epoll 内核事件表中。
可见,每个线程(主线程和工.作线程)都维持自己的事件循环,它们各自独立地监听不同的事件。因此,在这种高效的半同步/半异步模式中,每个线程都工作在异步模式,所以它并非严格意义上的半同步/半异步模式。
2.领导者/追随者模式:
领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听I/O事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到I/O事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时新的领导者等待新的I/O事件,而原来的领导者则处理I/O事件,二者实现了并发。
领导者/追随者模式包含如下几个组件:句柄集、线程集,事件处理器和具体的事件处理器。
- 句柄集:句柄用于表示I/O资源,在Linux下通常就是一个文件描述符。句柄集管理众多句柄,它使用wait_for_event方法来监听这些句柄上的IO事件,并将其中的就绪事件通知给领导者线程。领导者则调用绑定到句柄上的事件处理器来处理事件。领导者将句柄和事件处理器绑定是通过调用句柄集中的register_handlc方法实现的。
- 线程集:这个组件是所有工作线程(包括领导者线程和追随者线程)的管理者。它负责各线程之间的同步,以及新领导者线程的推选。线程集中的线程在任一时间必处于如下三种状态之一:
- Leader:线程当前处于领导者身份,负责等待句柄集上的I/O事件。
- Processing :线程正在处理事件。领导者检测到I/O事件之后,可以转移到Processing状态来处理该事件,并调用promote_new_leader方法推选新的领导者;也可以指定他追随者来处理事件,此时领导者的地位不变。当处于Processing状态的线程处理完事件之后,如果当前线程集中没有领导者,则它将成为新的领导者,否则它就直接转变为追随者。
- Follower :线程当前处于追随者身份,通过调用线程集的join方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务。
- 三种状态转换图:
3. 事件处理器和具体的事件处理器:事件处理器通常包含一个或多个回调函数 handle_event。这些回调函数用于处理事件对应的业务逻辑。事件处理器在使用前需要被绑定到某个句柄上,当该句柄上有事件发生时,领导者就执行与之绑定的事件处理器中的回调函数。具体的事件处理器是事件处理器的派生类。它们必须重新实现基类的 handle_event方法,以处理特定的任务。
六、有限状态机:
这一节我们介绍逻辑单元内部的一种高效编程方法:有限状态机。
1.状态独立的有限状态机:
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;
}
}
这就是一个简单的有限状态机,只不过该状态机的每个状态都是相互独立的,即状态之间没有相互转移,及状态type_A和type_B之间相互独立。状态之间的转移是需要状态机内部驱动的。
2.带状态转移的有限状态机:
STATE_MACHINE()
{
state cur_state= type_A;
while(cur_state != type_c )
{
Package _pack = getNewPackage();
switch ( cur_state )
{
case type_A:
process_package_state_A(_pack);
cur_state=type_B;
break;
case type_B:
process_package_state_B(_pack);
cur_state=type_C;
break;
}
}
}
该状态机包含三种状态: type_A、type_B和 type_C,其中 type_A是状态机的开始状态,type_C是状态机的结束状态。状态机的当前状态记录在cur_State变量中在一趟循环过程中,状态机先通过getNewPackage方法获得一个新的数据包,然后根据curState变量的值判断如何处理该数据包。数据包处理完之后,状态机通过给cur_State变量传递目标状态值来实现状态转移。那么当状态机进入下一趟循环时,它将执行新的状态对应的逻辑。
七、提高服务器性能的其他建议:
1.池:
既然服务器的硬件资源“充裕”,那么提高服务器性能的一个很直接的方法就是以空间换时间,即“浪费”服务器的硬件资源,以换取其运行效率。这就是池(pool)的概念。池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源分配。当服务器进入正式运行阶段,即开始处理客户请求的时候,如果它需要相关的资源,就可以直接从池中获取,无须动态分配。
根据不同的资源类型,池可分为多种,常见的有内存池、进程池、线程池和连接池。它们的含义都很明确:
- 内存池通常用于socket的接收缓存和发送缓存。
- 进程池和线程池都是并发编程常用的“伎俩”。
- 连接池通常用于服务器或服务器机群的内部永久连接。
2.数据复制:
高性能服务器应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候。如果内核可以直接处理从 socket或者文件读入的数据,则应用程序就没必要讲这些数据从内核缓冲区复制到应用程序缓冲区。这里说的“直接处理”指的是应用程序不关心这些数据的内容,不需要对它们做任何分析。此外,用户代码内部(不访问内核)的数据复制也是应该避免的。
3.上下文切换和锁:
并发程序必须考虑上下文切换的问题,即进程切换或线程切换导致的的系统开销。即使是IO密集型的服务器,也不应该使用过多的工作线程(或工作进程,下同),否则线程间的切换将占用大量的CPU时间,服务器真正用于处理业务逻辑的CPU时间的比重就显得不足了。因此,为每个客户连接都创建一个.工作线程的服务器模型是不可取的。半同步/半异步模式是一种比较合理的解决方案,它允许一个线程同时处理多个客户连接。此外,多线程服务器的一个优点是不同的线程可以同时运行在不同的CPU上。当线程的数量不大于CPU的数目时,上下文的切换就不是问题了。
并发程序需要考虑的另外一个问题是共享资源的加锁保护。锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。因此,服务器如果有更好的解决方案,就应该避免使用锁。