linux高性能服务器编程学习笔记七:高性能服务器程序框架

来源:互联网 发布:武装突袭3终极优化 编辑:程序博客网 时间:2024/04/29 03:35

1、服务器一般可以解构为以下三个主要模块:

(1)I/O处理单元。(四种I/O模型和两种高效事件处理模式)

(2)逻辑单元。(两种高效并发方式以及高效的逻辑处理方式—有限状态机)

(3)存储单元(服务器程序的可选模块,其本身与网络编程无关)

2、服务器模型

(1)C/S模型:由于资源(视频、新闻、软件等)都被数据提供者垄断,所以几乎所有的网络应用程序都很自然的采用C/S(客户端/服务器模型)。也就是所有客户端都通过访问服务器来获取所需的资源。C/S模型见下图:


一般的C/S模型逻辑很简单。如下图所示:


首先创建一个(或多个)监听sokcet,然后将其绑定到服务器感兴趣的端口上(调用bind函数),然后调用listen函数等待客户连接。由于客户端发起连接请求是异步事件,所以服务器就需要某种I/O模型来监听这一事件。上图使用的I/O某型是I/O复用的select系统调用函数。若是监听到来自客户端的连接请求,就会调用accept函数接收它,然后分配一个新的逻辑单元去处理这个新的连接。上图所示,产生的新的逻辑单元为子进程。逻辑单元处理该连接之后就将处理结果返回给客户端。客户端接收到服务器反馈的结果之后,可以继续发送请求,也可以关闭连接。如果客户端关闭连接,服务器就执行被动关闭连接,至此,双方的通信结束。这里要提醒的是,上图的框架,服务器在处理一个客户请求的时候同时还会继续监听其它的客户请求,这与效率低下的串行服务器(必须处理完前一个客户请求,才能监听并处理下一个客户请求)有所区别。C/S模型适合于资源相对集中的场合,并且实现也简单,但缺点也明显:服务器是通信的中心,假如访问量过大,那么所有客户端都将得到很慢的响应。

(2)P2P模型:点对点模型,比C/S模型更符合网络通信的实际情况,它摒弃了以服务器为中心的布局,让网络上的所有主机处于对等地位。P2P模型主要就是使得每台机器在消耗服务的时候也能作为服务器给别人提供服务,这样资源能够充分、自由的共享。云计算机群就是典型的P2P模型。它的缺点就是:当用户之间传输的请求过多时,网络的负载将加重。模型如下图所示


a的模型存在一个显著的的问题,就是主机之间很难互相发现,所以实际使用的P2P模型如b所示,会专门提供一个发现服务器,甚至这个发现服务器还提供查找服务,使的每个客户都能尽快地找到自己所需要的资源。

P2P模型可以看作是C/S模型的扩展:每台主机既是客户端,又是服务器。

3、服务器编程框架

(1)服务器基本框架如下所示,该框架也能用来描述一个服务器机群。(各种各样的服务器的不同之处主要在于逻辑处理)


(2)服务器基本模块的功能描述



I/O处理单元主要工作:等待并接收新的连接请求,接收客户数据(recv),将服务器相应的数据返回给客户端(send)。但是数据的收发也可以在逻辑单元中执行,而具体在何处执行取决于事件处理模式(reactor、proactor)。对于服务器集群来说,I/O处理单元就是一个专门的接入服务器,它负责实现负载均衡,从所有的服务器当中选择负荷最小的一台来为新客户服务。

   一个逻辑单元通常是一个进程或线程。分析客户的连接请求并处理客户数据,然后将结果传递给I/O处理单元或者直接发送给客户端(哪种方式取决于事件处理模式)。对服务器机群而言,逻辑处理单元本身就是一台逻辑服务器。服务器通常拥有多个逻辑单元以实现对多个客户的并行处理。

   网络存储单元可以是数据库、缓存或文件,甚至是一台独立的服务器

   请求队列是各单元之间的通信方式的抽象。I/O处理单元接收到客户的连接请求时,需要以某种方式通知逻辑单元处理该请求。同样的,多个逻辑单元同时访问一个存储单元时,也需要某种机制来协调处理竞争条件。(总体来说,I/O处理单元和逻辑单元之间通信需要采用某种方式;多个逻辑单元和存储单元之间也需要某种机制来协调处理。)请求队列通常实现为池的一部分。

4、I/0模型(阻塞/非阻塞I/O、I/O复用、信号驱动I/O、异步I/O)

(1)socket在创建的时候默认是非阻塞的。我们可以通过socket系统调用的第2个参数传递SOCK_NONBLOCK,也可以通过fcntl系统调用的F_SETFL命令,将其设置为非阻塞的。

(2)针对阻塞I/O的系统调用可能会因为无法立即完成而被系统挂起,直至等待的事件发生为止。connect函数就是会被阻塞的系统调用,假如在客户端发送了同步报文段之后,并没有接收到服务器返回的确认报文段,那么connect调用就会被挂起直至客户端接收到确认报文段才会唤醒connect系统调用。Socket基础API当中,会被阻塞的有accept、connect、send、recv。

(3)针对非阻塞I/O的系统调用,假如所需的事件并没有准备好,会返回-1并且设置相应的errno。对与accept、send、recv来说,事件未发生时errno会被设置成EAGAIN(“再来一次”)或者EWOULDBLOCK(“期望阻塞”),对connect而言则是EINPROGRESS(“在处理中”)。

(4)一般来说,只有在事件已经发生的情况下操作非阻塞I/O(读、写等),才能提高程序的效率。因此非阻塞I/O通常和其它I/O通知机制(I/O复用、信号驱动I/O(SIGIO))一起使用。

(5)I/O复用是最常使用的I/O通知机制。一般来说,应用程序通过I/O复用向内核注册一组事件,内核再通过I/O复用函数把其中就绪的事件通知给应用程序。linux常用的I/O复用函数是select、poll和epoll_wait。I/O复用函数本身是非阻塞的,但是它可以同时监听多个I/O事件,这是它能提高程序效率的主要原因。

(6)SIGIO信号也可以用来报告I/O事件。我们可以为一个目标文件指定宿主进程,那么被指定的宿主进程将会捕捉到SIGIO信号。当目标文件描述符上有事件发生的时候,就会触发SIGIO信号并且调用信号处理函数,然后在信号处理函数中对目标文件描述符执行非阻塞I/O。

(7)实际上,前几种(阻塞/非阻塞、I/O复用、信号驱动I/O)都是同步I/O模型,因为在这三种模型中,I/O的读写操作都是在I/O事件发生后,由应用程序来完成

(8)异步I/O与同步I/O模型本质上的区别是:用户可以直接对I/O执行读写操作,这些操作会告诉内核用户读写缓冲区的位置,以及I/O操作完成之后内核通知应用程序的方式(也就是所有I/O操作都有内核自动处理)。异步I/O的读写操作总是立即返回,而不管I/O是否是阻塞的,因为真正的读写操作已经由内核接管。

(9)换句话说,同步I/O模型要求用户代码自动执行I/O操作(将数据从用户缓冲区写入内核缓冲区,或将数据从内核缓冲区读入用户缓冲区),而异步I/O则是由内核执行I/O操作(数据在内核缓冲区和用户缓冲区之间的移动是由内核而非用户代码完成的)。实际上同步I/O向应用程序通知的是I/O就绪事件,而异步I/O向应用程序通知的是I/O完成事件。


5、两种高效的事件处理模式

1)服务器通常需要处理三类事件:I/O事件、信号及定时事件,相应的有两种高效的事件处理模式:ReactorProactor模式。同步I/O模型通常用于实现Reactor模式,异步I/O模型则用于实现Proactor模式。

2Reactor模型:它要求主线程(I/O处理单元)只负责监听文件描述符是否有事件(连接请求事件、读写事件)发生,有的话立即通知工作线程(逻辑单元)。除此之外,主线程不做任何其它实质性的工作。读写数据(读写操作非读写事件),接受新的连接,以及处理客户请求均在工作线程中完成

3)使用同步I/O模型(以epoll_waot为例)实现的Reactor模式的工作流程是:

   1)主线程往epoll内核事件表中注册socket上的读就绪事件

   2)主线程调用epoll_wait等待socket上有数据可读。

   3)当socket上有数据可读时,epoll_wait通知主线程。主线程将socket可读事件放入请求队列

   4)睡眠在请求队列上的某个工作线程被唤醒并从socket上读取数据和处理客户的请求,然后往epoll内核事件表中注册该socket的写就绪事件

   5)当socket可写时,epoll_wait通知主线程,主线程将socket可写事件放入请求队列

   6)睡眠在请求队列上的某个工作线程被唤醒并往socket上写入服务器处理客户请求的结果


(4)Proactor事件:与Reactor模式不同,Proactor模式将所有I/O操作(读写/收发数据)都交给主线程和内核来处理,工作线程仅仅负责业务逻辑(处理客户请求)。

(5)使用异步I/O模型(以aio_read和aio_write为例)实现的Proactor模式的工作流程是

   1)主线程调用aio_read函数向内核注册socket上的读完成(与同步I/O不同,同步I/O注册读就绪事件)事件,并告诉内核用户读缓冲区的位置,以及读完成时如何通知应用程序(信号通知)(之后的操作不用主线程,而是内核处理)

   2)主线程继续处理其它逻辑

   3)当socket上的数据读入用户缓冲区之后,内核将向应用程序发送一个信号以通知应用程序,数据已经可用。

   4)应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成后如何通知应用程序。

   5)主线程继续处理其它逻辑

   6)当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号以通知应用程序,数据已经发送完毕。

   7)应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。


8)内核通过信号通知应用程序socket上的读写事件。所以主线程中的epoll_wait调用仅能用来监听socket上的连接请求事件(文件描述符是否有连接请求事件发生),而不能用来检测socket上的读写事件。

6、模拟Proactor模式

(1)可以用同步I/O模拟Proactor模式,其原理是:主线程执行数据的读写事件,读写完成后,主线程向工作线程通知读写已完成。那么从工作线程的角度看。它们就直接获得了数据读写的结果,接下来只是对读写的结果进行逻辑处理

(2)使用同步I/O(以epoll_wait为例)模拟出的Proactor模式的工作流程如下:

   1)主线程往epoll内核事件表中注册读就绪事件

   2)主线程调用epoll_wait等待socket上数据可读

   3)当socket有数据可读时,epoll_wait通知主线程,主线程从socket循环读取数据直至数据读取完毕,然后将数据封装成一个请求对象并插入请求队列

   4)睡眠在请求队列上的工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册socket上的写就绪事件

   5)主线程调用epoll_wait等待socket可写。

   6)当socket可写时,epoll_wait通知主线程,主线程往socket上写入服务器处理客户请求的结果。


7、两种高效的并发模式

(1)并发编程的目的是让程序“同时”执行多个任务。如果程序是计算密集型的,并发编程并没有优势,反而会因为频繁的切换任务而降低效率。但是假如程序是I/O密集型(经常读写文件,访问数据库)的,由于I/O操作的速度远没有CPU的计算速度快,所以让程序阻塞与I/O操作会浪费大量的CPU时间。因此对于I/O密集型,并发编程能提高程序效率。实际上就是有多个执行线程,若当前线程被I/O操作阻塞,则可以主动放弃CPU(或有操作系统来调度),并将执行转移到其它线程让CPU做更有意义的事情,那么CPU的利用率会显著提升。目前并发编程主要有多线程和多进程两种方式,而并发模式也有两种:半同步/半异步模式和领导者/追随者模式。并发模式就是指I/O处理单元和多个逻辑单元之间协调完成任务的方法。

(2)半同步/半异步模式:这里的同步和异步不同于I/O模型中的同步和异步的概念,I/O模型中,同步和异步的区分在于向应用程序通知的是何种I/O事件(就绪还是完成事件),以及该由谁完成I/O读写(应用程序还是内核)。而在并发模式中,同步指的是完全按照代码顺序执行程序,异步指的是程序的执行需要通过系统事件来驱动(中断、信号等)。


3)其中比较典型的是半同步/半反应堆模式,而半同步/半反应堆模式中应用的事件处理模式一般是Reactor模式:主线程只负责监听文件描述符上是否有事件发生,而数据读写,接受新的连接,处理客户请求都有工作线程来完成。半同步/半反应堆模式如下图所示:


同时它也有如下缺点:

   1)主线程和工作线程共享一个请求队列。因此主线程往请求对流中添加任务,或者工作线程从请求队列中获取任务,都需要加锁保护,白白浪费CPU事件

         2)每个工作线程只能同一时间只能处理一个客户请求。如果客户数量较多,而工作线程较少,则请求队列中堆积很多任务对象,客户端的相应将越来越慢。如果增加工作线程,线程的切换也能耗费大量的CPU时间。因此还有一种相对高效的半同步/半异步模式,它们每个工作线程都能同时处理多个客户连接。


3)此模式下,主线程向工作线程通过管道派发socket,工作线程检测到官道上有数据可读时,就分析是否是一个新的客户连接到来,如果是,就将其socket上的读写事件注册到紫的epoll内核事件表中。这样每个工作线程都能同时处理多个客户连接

(4)领导者/追随者模式:多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听I/O事件。而其它线程都是追随者,它们休眠在线程池等待成为新的领导者。当前的领导者线程若是检测到I/O线程,首先就必须在线程池中推选出新的领导者线程。此时,新的领导者线程等待新的I/O事件,而原来的领导者线程则处理I/O事件,两者实现了并发。

(5)由于领导者线程自己负责监听I/O事件并处理客户请求,因而领导者/追随者模式不需要在线程之间传递任何额外的数据,也无需像半同步/半反应堆模式那样在线程之间同步对请求队列的访问。但其也有一个明显的缺点:仅支持一个事件源集合,因此无法让每个工作线程独立的管理多个客户连接。详细资料见:P152。

8、有限状态机

(1)之前有讨论过事件处理模式(Reactor、Proactor),I/O处理单元和逻辑单元协调完成任务的并发模式(半同步/半异步模式和领导者/追随者模式)。现在介绍逻辑单元内部的一种高效编程方法:有限状态机。

(2)有的应用层协议的头部包含数据包类型字段,每种类型可以映射为逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑。(有限状态机当中应当有状态转移,即由一个状态自动转换为另一个状态,例如HTTP分为请求行和头部字段两个大状态,请求行处理完后可以自动转换为头部字段状态,然后处理头部字段信息)。

9、提高服务器性能的其它建议

(1)在这个硬件资源充足的时代,提高服务器性能,主要考虑如何从“软环境”来提升服务器的性能。软环境一方面指的是“系统的软资源”,例如操作系统允许用户打开的最大文件描述符数量。另一方面指的就是服务器程序本身,即从编程的角度确保服务器的性能。

(2)之前介绍的几种高效的事件处理模式和并发模式,以及高效的逻辑处理方式—有限状态机,都有助于提高服务器的性能。下面介绍其它几个方面:池、数据复制、上下文切换和锁

(3)池:池的基本概念就是以空间换取时间。它是一组系统资源的集合,在服务器启动时候就被完全创建好并初始化,这称为静态资源分配。当服务器运行的时候,假如需要资源,就可以直接从池中获取而无需动态分配。假如每次需要资源都需要动态分配,相关的系统调用是很耗时的,相应的通过系统调用释放资源也很耗时,池的出现避免了这些开销(避免了对内核的频繁访问),因此大大提升了服务器的性能。在池中应当预先分配多少资源视情况而定,可以预先分配足够数量的资源,也可以预先分配一定的资源,以后如果发现资源不够用,就再动态分配一些资源加入池中

(4)池一般有内存池、进程池、线程池以及连接池。内存池通常用于socket的接收和发送缓存。进程池和线程池都是并发编程的惯用伎俩,它避免的频繁调用fork或pthread_create等函数所带来的系统开销。连接池通常用于服务器或服务器机群的内部永久连接。举个例子假定某个逻辑单元需要频繁的访问某个数据库,简单的做法是在每次需要访问的时候就向数据库发起连接,访问完毕之后就释放连接,显而易见这种做法效率太低。解决方案就是使用连接池,预先和数据库程序建立一组连接的集合。

(5)数据复制:高性能服务器应当避免不必要的数据复制,尤其是数据复制发生在用户代码和内核之间的时候。当应用程序不需要对接收的数据或文件进行分析的时候(不关心数据或文件的具体内容),则没有必要将数据从内核缓冲区复制到应用程序缓冲区中。应当考虑内核可以直接处理从socket或者文件读入的数据。举个例子,ftp服务器,当客户请求一个文件时候,服务器只需检测到目标文件是否存在,以及客户是否有读取它的权限,而不关系其具体内容,可以使用零拷贝函数sendfile来将其发送给客户端。此外,用户代码内部(不访问内核)的数据复制也是应当避免的,举个例子,假如两个进程之间要传递大量的数据时,应当考虑使用共享内存在它们之间共享数据,而不是使用管道或者消息队列来传递。

(6)上下文切换和锁,要知道进程间的切换或者是线程间的切换都是消耗CPU的,相对的CPU处理业务时间的比重降低,性能降低。因此为每个连接创建一个工作线程不可取,应当允许一个线程处理多个客户连接(epoll_wait实现),此外,多线程服务器的一个优点是不同的线程可以同时运行在不同的CPU中,假如线程的数量不大于CPU的数目,线程的切换就不是问题了。锁通常被认为是导致服务器效率低下的一个因素,因为它引入的代码不处理业务逻辑,还需要访问内核资源。因此应当尽量避免使用锁。如果非要应用锁,应当减小锁的粒度,比如使用读写锁。读写锁在所有工作线程读取一块共享内存的内容时并不会增加系统的额外开销,只有在某一个工作线程需要写这块内存时,才会产生开销去锁住这块区域。


原创粉丝点击