《Linux高性能服务器编程》学习笔记——第八章 高性能服务器程序框架

服务器程序一般可以解构为三个部分:

(1)IO处理单元。四种IO模型和两种高效的事件处理模式。

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

(3)存储单元。(不讨论)


一、服务器模型

1、C/S模型

所有客户端都通过服务器获取所需资源。

逻辑很简单。服务器启动后创建一个或多个监听socket,并bind到服务器感兴趣的端口上,调用listen等待客户连接。服务器稳定后,客户端调用connect主动向服务器发起连接。客户连接时随机到达的异步事件,服务器需要某种IO模型来监听事件。IO模型有很多种,以IO复用技术之一的select为例。当监听到连接请求后,服务器accept并分配一个逻辑单元为新的连接服务。逻辑单元可以是新创建的子进程子线程或者其他,以fork子进程为例。逻辑单元读取客户请求处理请求并将结果返回给客户。客户收到结果可以继续发送请求也可以关闭连接。如果客户端关闭连接,服务器则被动关闭连接。服务器在处理一个请求时也会监听其他请求,这是通过select实现的。

C/S模型适合资源集中的场合,且实现简单。缺点是服务器为通信中心,访问量过大时客户得到的响应慢。

2、P2P模型

所有主机在网络上地位对等,消耗服务的同时也为别人提供服务,典型代表云计算机群。缺点是用户间传输请求过多,网络负载加重。主机之间很难相互发现,实际使用时会有一个发现服务器提供查找服务。


二、服务器编程框架

一台服务器或一个服务器机群

IO处理单元:处理客户连接,读写网络数据;作为接入服务器,实现负载均衡。

逻辑单元:业务进程或线程;逻辑服务器。

网络存储单元:本地数据库、文件或缓存;数据库服务器。

请求队列:各单元间的通信方式;各服务器之间的永久TCP连接。

IO处理单元是服务器管理客户连接的模块,工作包括:等待并接受新连接,接收客户数据,将结果返回给客户。数据收发不一定在IO处理单元,也可能在逻辑单元执行,取决于事件处理模式。对于服务器机群来说,IO处理单元是一个专门的接入服务器,实现负载均衡,从所有逻辑服务器中选择负载最小的为新连接服务。

逻辑单元通常是一个进程或线程。它处理客户数据,将结果发送给IO处理单元或客户端,取决于事件处理模式。

网络存储单元可以是数据库、文件和缓存,也可以是一台数据库服务器。

请求队列是各单元通信方式的抽象。IO处理单元接收到客户请求后需要以某种方式通知逻辑单元处理该请求。多个逻辑单元访问一个存储单元时也需要某种方式处理竞态。请求队列经常被实现为池的一部分。对于服务器机群来讲,请求队列是各服务器之间预先建立的静态的永久的TCP连接。


三、IO模型

socket创建时默认是阻塞的,可以在创建时指定第二个参数传递SOCK_NONBLOCK标志,或者fcntl的F_SETFL命令设置为非阻塞的。阻塞非阻塞可应用于所有文件描述符,阻塞文件描述符称为阻塞IO,非阻塞文件描述符称为非阻塞IO。

针对阻塞IO的系统调用可能因为无法立即完成而被操作系统挂起,直到等待的事件发生。socket基础API中可能被阻塞的调用有accept、recv、send、connect。

针对非阻塞IO的系统调用总是立即返回。如果事件没有发生返回-1,和出错情况相同。必须根据errno区分。对accept、recv、send而言,事件未发生,errno通常被置为EAGAIN或EWOULDBLOCK,对connect而言,errno被设置为EINPROCESS。很显然,只有在事件已经发生的情况下操作非阻塞IO才能提高效率。因此非阻塞IO通常和其他IO通知机制一起使用,如IO复用和SIGIO信号。

IO复用是最常使用的IO通知机制。应用程序通过IO复用函数向内核注册一组事件,内核通过IO复用函数将就绪的事件通知给应用程序。linux常用的IO复用函数有select、poll、epoll_wait。IO复用函数本身是阻塞的,它们能提高效率是因为可以同时监听多个IO事件。

SIGIO信号也可以用来告知IO事件。我们可以为目标文件描述符指定宿主进程,宿主进程将捕获到SIGIO信号,当目标文件描述符上有事件发生时,SIGIO的信号处理函数被触发,我们就可以在信号处理函数中对目标文件描述符执行非阻塞IO操作了。

阻塞IO、IO复用、信号驱动IO都是同步IO模型。因为IO的读写都是在IO事件发生之后,由应用程序完成。异步IO来讲,用户可以直接操作IO,这些操作告知内核用户读写缓存的位置,以及IO操作完成后内核通知应用程序的方式。异步IO的读写总是立即返回,不论IO是否阻塞,因为真正的读写操作已经由内核接管。也就是说,同步IO要求应用程序自己完成IO操作,异步IO则由内核来执行IO操作。同步IO向应用程序通知的事IO就绪事件,异步IO向应用程序通知的是IO完成事件。

阻塞IO:程序阻塞于读写函数。

IO复用:程序阻塞于IO复用系统调用,但可同时监听多个IO事件。对IO本身的读写操作是非阻塞的。

SIGIO信号:信号触发读写就绪事件,用户程序执行读写操作,程序没有阻塞阶段。

异步IO:内核执行读写操作并触发读写完成事件。程序没有阻塞阶段。


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

服务器通常要处理三类事件:IO事件、信号事件、定时事件。同步IO模型通常用于实现Reactor模式,异步IO模型用于实现Proactor模式。

1、Reactor模式:

主线程(IO处理单元)只负责监听文件描述符上是否有事件发生,有的话立刻通知工作线程(逻辑单元)。除此之外主线程不做任何其他工作,读写数据,接收连接,处理客户请求都在工作线程中完成。

同步IO模型(以epoll_wait为例)实现的Reactor模式的工作流程:

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

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

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

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

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

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

7、睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果。



工作线程从队列中取出事件后,将根据事件是可读或可写执行读写数据和处理请求的操作。因此,在Reactor模式中,没必要区分所谓的“读工作线程”和“写工作线程”。


2、Proactor模式:

与Reactor模式不同,Proactor模式将所有IO操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。

(以aio_read和aio_write为例)工作流程:

1、主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例,详情sigevent的man手册)

2、主线程继续处理其他逻辑。

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

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

5、主线程继续处理其他逻辑。

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

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



连接socket上的读写事件是通过aio_read/aio_write向内核注册的,内核将通过信号向应用程序报告连接socket上的读写事件。所以主线程的epoll_wait仅能检测监听socket上的连接请求事件,不能用来检测连接socket上的读写事件。


3、模拟proactor模式

使用同步IO模型(仍然以epoll_wait为例)模拟出的Proactor模式的工作流程如下:
1、主线程往epoll内核事件表中注册socket上的读就绪事件。
2、主线程调用epoll_wait等待socket上有数据可读。
3、当socket上有数据可读时,epoll_wait通知主线程。主线程从socket循环读取数据,知道没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入到请求队列。
4、睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后网epoll内核事件表中注册socket上的写就绪事件。
5、主线程调用epoll_wait等待socket可写。
6、当socket可写时,epoll_wait通知主线程。主线程网socket上写入服务器处理客户端请求的结果。


五、两种高效的并发模式

并发编程在计算密集型程序中没有优势,反而由于频繁切换降低效率。但在IO密集型程序中可以提高CPU利用率。并发模式是指IO单元和多个逻辑单元协调完成任务的方法。

1、半同步半异步模式:

这里的“同步”和“异步”和前面的IO的“同步”“异步”是完全不同的概念。在IO模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种IO事件(是就绪事件还是完成事件),以及该由谁来完成IO读写(是应用程序还是内核)。在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行;“异步”指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。

显然异步线程的执行效率高,实时性强,是很多嵌入式系统采用的模型。但编写异步方式执行的程序相对复杂,难于调试和扩展,而且不适合于大量的并发。而同步线程则相反,它虽然效率相对较低,实时性较差,但逻辑简单。

在半同步半异步模式中,同步线程用于处理客户逻辑,异步线程用于处理IO事件。异步线程监听到客户请求后,就将其封装成请求对象并插入到请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。




半同步半反应堆模式存在如下缺点:

1、主线程和工作线程共享请求队列,需要对请求队列加锁,耗费CPU时间。

2、每一个工作线程在同一时间只能处理一个客户请求。

高效模式:每个工作线程都能同时处理多个客户连接。
主线程只管理监听socket,连接socket由工作线程来管理。当有新的连接到来时,主线程就接受之并将新返回的连接socket派发给某个工作线程,此后该socket上的任何IO操作都由被选中的工作线程来处理,直到客户端关闭连接。主线程向工作线程派发socket的最简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道里有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新socket上的读写事件注册到自己的epoll内核事件表中。
每个线程(主线程和工作线程)都维持自己的事件循环,它们各自独立的监听不同的事件。因此在这种模式中,每个线程都工作在异步模式,所以它并非严格意义上的半同步半异步模式。



2、领导者追随者模式
是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听IO事件。而其他线程都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到IO事件,首先要从线程池中推选出新的领导者线程,然后处理IO事件。此时,新的领导者等待新的IO事件,而原来的领导者则处理IO事件,二者实现了并发。
包含如下几个组件:句柄集(HandleSet)、线程集(ThreadSet)、事件处理器(EventHandler)、具体的事件处理器(ConcreteEventHandler)。



(1)句柄集

句柄表示IO资源,linux下通常是文件描述符。句柄集使用wait_for_event方法监听这些句柄上的IO事件,并将其中的就绪事件通知给领导者线程。领导者调用绑定到Handle上的事件处理器来处理事件。绑定是通过句柄集的register_handle方法实现的。

(2)线程集

所有工作线程的管理者,负责线程同步、推选新领导。线程在任一时间必处于以下三种状态之一:

Leader:领导者线程,负责等待句柄集上的IO事件。

Processing:线程正在处理事件。领导者检测到IO事件后可以转移至Processing状态处理该事件,并调用promote_new_leader方法推选新领导者;也可以指定其他追随者来处理事件,此时领导者地位不变。当处于Processing状态的线程处理完事件后,如果当前线程集中没有领导者,则它将成为新领导者,否则它直接转为追随者。

Follower:线程处于追随者身份,通过调用线程集的join方法等待成为新领导者,也可能被领导者指定来处理新的事件。

三者转换如下:


注意,领导者推选新领导和追随者等待成为新领导这两个操作都会修改线程集,因此线程集提供一个Synchronizer来同步。

(3)事件处理器和具体的事件处理器

事件处理器通常包含一个或多个回调函数handle_event。这些回调函数用于处理事件对应的业务逻辑。事件处理器在使用前需要被绑定到某个句柄上,当该句柄有事件发生时,领导者就执行绑定的事件处理器的回调函数。具体的事件处理器是事件处理器的派生类。它们重新实现基类的handle_event方法,以处理特定的任务。


由于领导者自己监听IO事件并处理客户请求,该模式不需要在线程间传递额外数据,也无需像半同步/半反应堆模式那样在线程间同步对请求队列的访问。但是,该模式的明显缺点是仅支持一个事件源集合,因此也无法让每个工作线程独立管理多个客户连接。


六、有限状态机(finite state machine)

有限状态机是逻辑单元内部的一种高效编程方法。


七、提高服务器性能的其他建议:

性能对服务器来讲很重要,客户期望快速响应。影响服务器性能的首要因素是硬件资源。我们只从软件编程角度去讨论。

1、池

服务器硬件资源相对“充裕”,那么提高服务器性能的一个很直接的方法就是以空间换时间,即“浪费”服务器的硬件资源,以换取其运行效率。这就是池(pool)的概念。池是一种资源的集合,这组资源在服务器启动之初就被完全创建并初始化,这称为静态资源分配。服务器使用时直接从池中获取,无需动态分配,使用完把资源放回池中,无需释放。分配和释放系统资源的系统调用都是很耗时的。

按照资源类型分类:

内存池:通常用于socket的接收缓存和发送缓存。
进程池、线程池:并发编程。
连接池:常用于服务器或服务器集群的内部永久连接。

2、数据复制

应该避免不必要的数据复制,尤其当数据复制发生在用户代码和内核之间的时候。如果内核可以直接处理从socket或者文件读入的数据,则应用程序就没有必要将这些数据从内核缓冲区复制到应用程序缓冲区。如ftp服务器,服务器只需检测目标文件是否存在,以及客户是否有读取权限,而不用关心文件具体内容。就可以使用“零拷贝”sendfile来直接将其发送给客户。

此外,用户代码内部(不访问内核)的数据复制也是应该避免的。如两个工作进程之间要传递大量的数据时,我们就应该考虑使用共享内存来在它们之间直接共享这些数据,而不是使用管道或者消息队列来传递。

3、上下文切换和锁:

并发程序必须考虑上下文切换(context switch)的问题,即进程线程切换导致的系统开销。即使是IO密集型的服务器,也不应该使用过多的工作线程(或进程,下同),否则切换将占用大量CPU时间,服务器真正用于业务逻辑的CPU时间比重就显得不足了。因此为每个客户连接都建立一个服务器线程的模型不可取。之前描述的半同步半异步模型是一个比较合理的解决方案,它允许一个线程同时处理多个客户连接。此外,多线程服务器的一个优点是不同的线程可以同时运行在不同的cpu上。当线程数量不大于cpu的数目时,上下文切换就不是问题了。

并发程序需要考虑的另一个问题是共享资源的枷锁保护。锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。因此,服务器如果有更好的解决方案,就应该避免使用锁。如果服务器必须使用锁,则可以考虑减小锁的粒度,比如使用读写锁。当所有工作线程都只读取一块共享内存的内容时,读写锁并不会增加系统的额外开销。只有当其中一个工作线程需要写这块内存时,系统才必须去锁住这块区域。


  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值