服务器模型@C/S模型
C/S模型的逻辑很简单。服务器启动后,首先创建一个(或多个)监听socket,并调用bind函数将其绑定到服务器感兴趣的端口上,然后调用listen函数等待客户连接。服务器稳定运行之后,客户端就可以调用connect函数向服务器发起连接了。由于客户连接请求是随机到达的异步事件,服务器需要使用某种I/O模型来监听这一事件。I/O模型有多种,图中,服务器使用的是1/O复用技术之一的select系统调用。当监听到连接请求后,服务器就调用accept函数接受它,并分配一个逻辑单元为新的连接服务。逻辑单元可以是新创建的子进程、子线程或者其他。图8-2中,服务器给客户端分配的逻辑单元是由fork 系统调用创建的子进程。逻辑单元读取客户请求,处理该请求,然后将处理结果返回给客户端。客户端接收到服务器反馈的结果之后,可以继续向服务器发送请求,也可以立即主动关闭连接。如果客户端主动关闭连接,则服务器执行被动关闭连接。至此,双方的通信结束。需要注意的是,服务器在处理--个客户请求的同时还会继续监听其他客户请求,否则就变成了效率低下的串行服务器了(必须先处理完前-一个客户的请求,才能继续处理下一个客户请求)。图8-2中,服务器同时监听多个客户请求是通过select系统调用实现的。
C/S模型非常适合资源相对集中的场合,并且它的实现也很简单,但其缺点也很明显:服务器是通信的中心,当访问量过大时,可能所有客户都将得到很慢的响应。
服务器模型@P2P模型
P2P (Peer to Peer,点对点)模型比C/S模型更符合网络通信的实际情况。它摒弃了以服务器为中心的格局,让网络上所有主机重新回归对等的地位。P2P 模型如图a所示。
P2P模型使得每台机器在消耗服务的同时也给别人提供服务,这样资源能够充分、自由地共享。云计算机群可以看作P2P模型的一个典范。但P2P模型的缺点也很明显:当用户之间传输的请求过多时,网络的负载将加重。
图a所示的P2P模型存在一个显著的问题,即主机之间很难互相发现。所以实际使用的P2P模型通常带有一个专门的发现服务器,如图b所示。这个发现服务器通常还提供查找服务(甚至还可以提供内容服务),使每个客户都能尽快地找到自己需要的资源。
从编程的角度来看,P2P模型可以看作C/S模型的拓展:每台主机既是客户端又是服务器。
服务器编程框架
I/O处理单元是服务器管理客户连接的模块。它通常要完成以下工作:等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端。但是,数据的收发不一定在I/O处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式。对于一个服务器机群来说,I/O 处理单元是一个专门的接人服务器。它实现负载均衡,从所有逻辑服务器中选取负荷最小的一台来为新客户服务。
一个逻辑单元通常是一个进程或线程。它分析并处理客户数据,然后将结果传递给I/O处理单元或者直接发送给客户端(具体使用哪种方式取决于事件处理模式)。对服务器机群而言,一个逻辑单元本身就是一台逻辑服务器。服务器通常拥有多个逻辑单元,以实现对多个客户任务的并行处理。
网络存储单元可以是数据库、缓存和文件,甚至是一台独立的服务器。但它不是必须的,比如ssh、telnet 等登录服务就不需要这个单元。
请求队列是各单元之间的通信方式的抽象。I/O处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。同样,多个逻辑单元同时访问-一个存储单元时,也需要采用某种机制来协调处理竞态条件。请求队列通常被实现为池的一部分,我们将在后面讨论池的概念。对于服务器机群而言,请求队列是各台服务器之间预先建立的、静态的、永久的TCP连接。这种TCP连接能提高服务器之间交换数据的效率,因为它避免了动态建立TCP连接导致的额外的系统开销。
I/O模型
事件处理模式@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模式中,没必要区分所谓的“读工作线程”和“写工作线程”。
事件处理模式@Proactor模式
与Reactor模式不同,Proactor 模式将所有IO操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。使用异步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.上的读写事件。
模拟Proactor模式
使用同步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上写人服务器处理客户请求的结果。
图总结了用同步I/O模型模拟出的Proactor模式的工作流程。
并发模式@半同步/半异步模式
- 同步:指的是程序完全按照代码序列的顺序执行;
- 异步:指的是程序的执行需要由系统事件来驱动;
按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。显然,异步线程的执行效率高,实时性强。但编写以异步方式执行的程序相对复杂,难于调试和扩展,而且不适合于大量的并发。而同步线程则相反,它虽然效率相对较低,实时性较差,但逻辑简单。因此,对于像服务器这种既要求较好的实时性,又要求能同时处理多个客户请求的应用程序,我们就应该同时使用同步线程和异步线程来实现,即采用半同步/半异步模式来实现。
在服务器程序中,如果结合考虑两种事件处理模式和几种1/O模型,则半同步1半异步模式就存在多种变体。其中有一种变体称为半同步/半反应堆(half-sync/half-rcactive) 模式。如图所示:
并发模式@领导者/追随者模式
领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负贵监听I/O事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到I/O事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时,新的领导者等待新的I/O事件,而原来的领导者则处理I/O事件,二者实现了并发。领导者/追随者模式包含如下几个组件:句柄集(HandleSet)、 线程集(ThreadSet).事件处理器(EventHandler) 和具体的事件处理器(ConcreteEventHandler)。 它们的关系如图所示:
-
句柄集
句柄(Handle) 用于表示I/0资源,在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方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务。下图显示了这三种状态之间的转换关系。
需要注意的是,领导者线程推选新的领导者和追随者等待成为新领导者这两个操作都将修改线程集,因此线程集提供一个成员Synchronizer来同步这两个操作,以避免竞态条件。
-
事件处理器和具体的事件处理器
事件处理器通常包含一个或多个回调函数handle_event。这些回调函数用于处理事件对应的业务逻辑。事件处理器在使用前需要被绑定到某个句柄上,当该句柄上有事件发生时,领导者就执行与之绑定的事件处理器中的回调函数。具体的事件处理器是事件处理器的派生类。它们必须重新实现基类的handle_event 方法,以处理特定的任务。根据上面的讨论,我们将领导者/追随者模式的工作流程总结于图中。
由于领导者线程自己监听IO事件并处理客户请求,因而领导者/追随者模式不需要在线程之间传递任何额外的数据,也无须像半同步/半反应堆模式那样在线程之间同步对请求队列的访问。但领导者/追随者的一个明显缺点是仅支持一个事件源集合, 因此也无法让每个工作线程独立地管理多个客户连接。
有限状态机
各种模式,这一节我们介绍逻辑单元内部的一种高效编程方法:有限状态机(inite state machine)。这就是一个简单的有限状态机,只不过该状态机的每个状态都是相互独立的,即状态之间没有相互转移。状态之间的转移是需要状态机内部驱动的,如代码清单所示。
STATE_MACHINE()
{
State cur_State = type_A;
while( cur_State != type_C )
{
Package_pack = getNewPackage();
switch( cur_StateI )
{
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方法获得一个新的数据包,然后根据cur_State 变量的值判断如何处理该数据包。数据包处理完之后,状态机通过给cur_State 变量传递目标状态值来实现状态转移。那么当状态机进人下一趟循环时,它将执行新的状态对应的逻辑。
提高服务器性能的其他建议@池
既然服务器的硬件资源“充裕”,那么提高服务器性能的一个很直接的方法就是以空间换时间,即“浪费”服务器的硬件资源,以换取其运行效率。这就是池(pool) 的概念。池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源分配。当服务器进人正式运行阶段,即开始处理客户请求的时候,如果它需要相关的资源,就可以直接从池中获取,无须动态分配。很显然,直接从池中取得所需资源比动态分配资源的速度要快得多,因为分配系统资源的系统调用都是很耗时的。当服务器处理完一个客户连接后,可以把相关的资源放回池中,无须执行系统调用来释放资源。从最终的效果来看,池相当于服务器管理系统资源的应用层设施,它避免了服务器对内核的频繁访问。不过,既然池中的资源是预先静态分配的,我们就无法预期应该分配多少资源。这个问题又该如何解决呢?最简单的解决方案就是分配“足够多”的资源,即针对每个可能的客户连接都分配必要的资源。这通常会导致资源的浪费,因为任一时刻的客户数量都可能远远没有达到服务器能支持的最大客户数量。好在这种资源的浪费对服务器来说一般不会构成问题。还有一种解决方案是预先分配一定的资源,此后如果发现资源不够用,就再动态分配一些并加入池中。根据不同的资源类型,池可分为多种,常见的有内存池、进程池、线程池和连接池。它们的含义都很明确。内存池通常用于socket的接收缓存和发送缓存。对于某些长度有限的客户请求,比如HTTP请求,预先分配一个大小足够( 比如5000字节)的接收缓存区是很合理的。当客户请求的长度超过接收缓冲区的大小时,我们可以选择丢弃请求或者动态扩大接收缓冲区。进程池和线程池都是并发编程常用的“伎俩"。当我们需要一个工作进程或工作线程来处理新到来的客户请求时,我们可以直接从进程池或线程池中取得一个执行实体, 而无须动态地调用fork或pthread_create 等函数来创建进程和线程。连接池通常用于服务器或服务器机群的内部永久连接。
图上每个逻辑单元可能都需要频繁地访问本地的某个数据库。简单的做法是:逻辑单元每次需要访问数据库的时候,就向数据库程序发起连接,而访问完毕后释放连接。很显然,这种做法的效率太低。一种解决方案是使用连接池。连接池是服务器预先和数据库程序建立的一组连接的集合。 当某个逻辑单元需要访问数据库时,它可以直接从连接池中取得一个连接的实体并使用之。待完成数据库的访问之后,逻辑单元再将该连接返还给连接池。
提高服务器性能的其他建议@数据复制
高性能服务器应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候。如果内核可以直接处理从socket或者文件读人的数据,则应用程序就没必要将这些数据从内核缓冲区复制到应用程序缓冲区中。这里说的“直接处理”指的是应用程序不关心这些数据的内容,不需要对它们做任何分析。比如ftp服务器,当客户请求一个文件时,服务器只需要检测目标文件是否存在,以及客户是否有读取它的权限,而绝对不会关心文件的具体内容。这样的话,ftp 服务器就无须把目标文件的内容完整地读人到应用程序缓冲区中并调用send函数来发送,而是可以使用“零拷贝”函数sendfile来直接将其发送给客户端。此外,用户代码内部(不访问内核)的数据复制也是应该避免的。举例来说,当两个工作进程之间要传递大量的数据时,我们就应该考虑使用共享内存来在它们之间直接共享这些数据,而不是使用管道或者消息队列来传递。又比如解析HTTP请求的实例中,我们用指针(start. line) 来指出每个行在buffer中的起始位置,以便随后对行内容进行访问,而不是把行的内容复制到另外-一个缓冲区中来使用,因为这样既浪费空间,又效率低下。
提高服务器性能的其他建议@上下文切换和锁
并发程序必须考虑上下文切换(context switch)的问题,即进程切换或线程切换导致的的系统开销。即使是IO密集型的服务器,也不应该使用过多的工作线程(或工作进程,下同),否则线程间的切换将占用大量的CPU时间,服务器真正用于处理业务逻辑的CPU时间的比重就显得不足了。因此,为每个客户连接都创建一个工作线程的服务器模型是不可取的。
图上所描述的半同步/半异步模式是一种比较合理的解决方案,它允许一个线程同时处理多个客户连接。此外,多线程服务器的一个优点是不同的线程可以同时运行在不同的CPU上。当线程的数量不大于CPU的数目时,上下 文的切换就不是问题了。并发程序需要考虑的另外一个问题是共享资源的加锁保护。锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。因此,服务器如果有更好的解决方案,就应该避免使用锁。显然,图上所描述的半同步/半异步模式就比图下
所描述的半同步/半反应堆模式的效率高。如果服务器必须使用“锁”,则可以考虑减小锁的粒度,比如使用读写锁。当所有工作线程都只读取一块共享内存的内容时,读写锁并不会增加系统的额外开销。只有当其中某一个工作线程需要写这块内存时,系统才必须去锁住这块区域。