0%

高性能服务器程序框架


按照服务器程序的一般原理,将服务器解构为如下三个主要模块:

  • I/O处理单元。四种I/O模型和两种高效事件处理模式。
  • 逻辑单元。两种高效并发模式,以及高效的逻辑处理方式——有限状态机。
  • 存储单元。服务器程序的可选模块。

服务器模型

C/S模型

C/S(客户端/服务器)模型:所有客户端都通过访问服务器来获取所需的资源。

image-20220726231329183

采用C/S模型的TCP服务器和TCP客户端的工作流程:

image-20220726231424223

服务器启动后,首先创建一个(或多个)监听socket,并调用bind函数将其绑定到服务器感兴趣的端口上,然后调用listen函数等待客户连接。
服务器稳定运行之后,客户端就可以调用connect函数向服务器发起连接了。由于客户连接请求是随机到达的异步事件,服务器需要使用某种I/O模型来监听这一事件。这里服务器使用的是I/O复用技术之一的select系统调用。
当监听到连接请求后,服务器就调用accept函数接受它,并分配一个逻辑单元为新的连接服务。逻辑单元可以是新创建的子进程、子线程或者其他。服务器给客户端分配的逻辑单元是由fork系统调用创建的子进 程。逻辑单元读取客户请求,处理该请求,然后将处理结果返回给客户端。
客户端接收到服务器反馈的结果之后,可以继续向服务器发送请求,也可以立即主动关闭连接。如果客户端主动关闭连接,则服务器执行被动关闭连接。至此,双方的通信结束。

注意:服务器在处理一个客户请求的同时还会继续监听其他客户请求,否则就变成了效率低下的串行服务器了(必须先处理完前一个客户的请求,才能继续处理下一个客户请求)。这里服务器同时监听多个客户请求是通过select系统调用实现的。

优点:非常适合资源相对集中的场合,实现简单。

缺点:服务器是通信的中心,当访问量过大时,可能所有客户都将得到很慢的响应。

P2P模型

P2P(Peer to Peer,点对点)模型,摒弃了以服务器为中心的格局,让网络上所有主机重新回归对等的地位。两种P2P模型:

image-20220726232111418

优点:每台机器在消耗服务的同时也给别人提供服务,这样资源能够充分、自由地共享。

缺点:当用户之间传输的请求过多时,网络的负载将加重。

图a的P2P模型的问题,主机之间很难互相发现。实际使用中,需要加入一个专门的发现服务器,如图b,发现服务器通常还提供查找服务(甚至还可以提供内容服务),使每个客户都能尽快地找到自己需要的资源。

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

服务器编程框架

虽然服务器程序种类繁多,但其基本框架都一样,不同之处在于逻辑处理。

服务器基本框架如下:

image-20220726232518637

服务器基本模块的功能概述:

模块 单个服务器程序 服务器机群
I/O处理单元 处理客户连接,读写网络数据 作为接入服务器,实现负载均衡
逻辑单元 业务进程或线程 逻辑服务器
网络存储单元 本地数据库、文件或缓存 数据库服务器
请求队列 各单元之间的通信方式 各服务器之间的永久TCP连接

I/O处理单元:服务器管理客户连接的模块。主要工作:等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端。但是,数据的收发不一定只在I/O处理单元中执行,也可能在逻辑单元中执行。对于一个服务器机群来说,I/O处理单元是一个专门的接入服务器。它实现负载均衡,从所有逻辑服务器中选取负荷最小的一台来为新客户服务。

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

网络存储单元:可以是数据库、缓存和文件,甚至是一台独立的服务器。但它不是必须的,比如ssh、telnet等登录服务就不需要这个单元。

请求队列:是各单元之间的通信方式的抽象。I/O处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来协调处理竞态条件。对于服务器机群而言,请求队列是各台服务器之间预先建立的、静态的、永久的TCP连接。这种TCP连接能提高服务器之间交换数据的效率,因为它避免了动态建立TCP连接导致的额外的系统开销。

I/O模型

阻塞与非阻塞应用于文件描述符包括socket。阻塞的文件描述符为阻塞I/O,非阻塞的文件描述符为非阻塞I/O。

阻塞I/O:执行的系统调用可能因为无法立即完成而被操作系统挂起,直到等待的事件发生为止。例如,客户端通过connect向服务器发起连接时,connect将首先发送同步报文段给服务器,然后等待服务器返回确认报文段。如果服务器的确认报文段没有立即到达客户端,则connect调用将被挂起,直到客户端收到确认报文段并唤醒connect调用。
socket基础API中,可能被阻塞的系统调用包括:accept、send、recv和connect。

非阻塞I/O:执行的系统调用总是立即返回,而不管事件是否已经发送。如果事件没有立即发生,这些系统调用就返回-1,和出错的情况一样。此时必须根据errno来区分这两种情况。
对accept、send和recv而言,事件未发生时errno通常被设置成EAGAIN(意为“再来一次”)或者EWOULDBLOCK(意为“期望阻塞”);对connect而言,errno则被设置成EINPROGRESS(意为“在处理中”)。

使用条件:只有在事件已经发送的情况下操作非阻塞I/O(读、写等),才能提高程序的效率。非阻塞I/O通常要和其他I/O通知机制一起使用,比如I/O复用和SIGIO信号。

I/O复用:最常使用的I/O通知机制。应用程序通过I/O复用函数向内核注册一组事件,内核通过I/O复用函数把其中就绪的事件通知给应用程序。Linux常用的I/O复用函数是select、poll和epoll_wait。I/O复用函数本身是阻塞的,它们能提高程序效率的原因在于它们具有同时监听多个I/O事件的能力

SIGIO信号:可以用来报告I/O事件。可以为一个目标文件描述符指定宿主进程,那么被指定的宿主进程将捕获到SIGIO信号。这样,当目标文件描述符上有事件发生时,SIGIO信号的信号处理函数将被触发,也就可以在该信号处理函数中对目标文件描述符执行非阻塞I/O操作了。

从理论上说,阻塞I/O、I/O复用和信号驱动I/O都是同步I/O模型。这三种I/O模型中,I/O的读写操作,都是在I/O事件发生之后,由应用程序来完成的。

POSIX规范所定义的异步I/O模型,用户可以直接对I/O执行读写操作,这些操作告诉内核用户读写缓冲区的位置,以及I/O操作完成之后内核通知应用程序的方式。

同步I/O模型:要求用户代码自行执行I/O操作(将数据从内核缓冲区读入用户缓冲区,或将数据从用户缓冲区写入内核缓冲区)

异步I/O机制:由内核来执行I/O操作(数据在内核缓冲区和用户缓冲区之间的移动是由内核在“后台”完成的)。异步I/O的读写操作总是立即返回,而不论I/O是否是阻塞的,因为真正的读写操作已由内核接管。

同步I/O向应用程序通知的是I/O就绪事件,而异步I/O向应用程序通知的是I/O完成事件

几种I/O模型的差异对比:

I/O模型 读写操作和阻塞阶段
阻塞I/O 程序阻塞于读写函数
I/O复用 程序阻塞于I/O复用系统调用,但可以同时监听多个I/O事件。对I/O本身的读写操作是非阻塞的
SIGIO信号 信号触发读写就绪事件,用户程序执行读写操作。程序没有阻塞阶段
异步I/O 内核执行读写操作并触发读写完成事件。程序没有阻塞阶段

两种高效的事件处理模式

服务器程序需要处理的三类事件:I/O事件、信号、定时事件。

两种高效的事件处理模式:Reactor(同步I/O模型)和Proactor(异步I/O模型)。

Reactor模式

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

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

image-20220727120151684
  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上写入服务器处理客户请求的结果。

Proactor模式

Proactor模式:将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。

Proactor模式的工作流程:

image-20220727150928621
  1. 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序。
  2. 主线程继续处理其他逻辑。
  3. 当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
  4. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序。
  5. 主线程继续处理其他逻辑。
  6. 当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
  7. 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。

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

模拟Proactor模式

使用同步I/O方式模拟出Proactor模式的一种方法:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。

用同步I/O模拟出的Proactor模式工作流程:

image-20220727152705121
  1. 主线程往epoll内核事件表中注册socket上的读就绪事件。
  2. 主线程调用epoll_wait等待socket上有数据可读。
  3. 当socket上有数据可读时,epoll_wait通知主线程。主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。
  4. 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册socket上的写就绪事件。
  5. 主线程调用epoll_wait等待socket可写。
  6. 当socket可写时,epoll_wait通知主线程。主线程往socket上写入服务器处理客户请求的结果。

两种高效的并发模式

对于I/O密集型的程序,如经常读写文件、访问数据库等,由于I/O操作的速度远没有CPU的计算速度快,让程序阻塞于I/O将浪费大量的CPU时间,并发编程让程序“同时”执行多个任务。

如果程序有多个执行线程,则当前被I/O操作所阻塞的执行线程可主动放弃CPU(或由操作系统来调度),并将执行权转移到其他线程。这样一来,CPU就可以用来做更加有意义的事情(除非所有线程都同时被I/O操作所阻塞),而不是等待I/O操作完成,因此CPU的利用率显著提升。

并发编程主要有多进程和多线程两种方式。

并发模式:I/O处理单元和多个逻辑单元之间协调完成任务的方法。

服务器主要有两种并发编程模式:

  • 半同步/半异步(half-sync/half-async)模式
  • 领导者/追随者(Leader/Followers)模式。

半同步/半异步模式

I/O模型中的同步与异步的区分:

  • 内核向应用程序通知的是何种I/O事件(是就绪事件还是完成事件)。
  • 该由谁来完成I/O读写(是应用程序还是内核)。

并发模式中的同步与异步:

  • 同步:程序完全按照代码序列的顺序执行。
  • 异步:程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。
image-20220727155301767

按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。

异步线程:执行效率高,实时性强,这是很多嵌入式程序采用的模型。但编写以异步方式执行的程序相对复杂,难于调 试和扩展,而且不适合于大量的并发。

同步线程:虽然效率相对较低,实时性较差,但逻辑简单。

半同步/半异步模式:同步线程用于处理客户逻辑,相当于逻辑处理单元;异步线程用于处理I/O事件,相当于I/O处理单元。

异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。具体选择哪个工作线程来为新的客户请求服务,则取决于请求队列的设计。

半同步/半异步模式的工作流程:

image-20220727155822372

服务器程序中,结合考虑两种事件处理模式和几种I/O模型,其中有一种变体称为半同步/半反应堆(half-sync/half-reactive)模式:

image-20220727160014636

图8-10中,异步线程只有一个,由主线程来充当。它负责监听所有socket上的事件。如果监听socket上有可读事件发生,即有新的连接请求到来,主线程就接受之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件。如果连接socket上有读写事件发生,即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接socket插入请求队列中。
所有工作线程都睡眠在请求队列上,当有任务到来时,它们将通过竞争(比如申请互斥锁)获得任务的接管权。这种竞争机制使得只有空闲的工作线程才有机会来处理新任务,这是很合理的。

主线程插入请求队列中的任务是就绪的连接socket。这说明该图所示的半同步/半反应堆模式采用的事件处理模式是Reactor模式:它要求工作线程自己从socket上读取客户请求和往socket写入服务器应答。这就是该模式的名称中“half-reactive”的含义。

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

  • 主线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费CPU时间。
  • 每个工作线程在同一时间只能处理一个客户请求。如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢。如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量CPU时间。

一种相对高效的半同步/半异步模式,它的每个工作线程都能同时处理多个客户连接:

image-20220727161338185

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

领导者/追随者模式

领导者/追随者模式:多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。

在任意时间点,程序都仅有一个领导者线程,它负责监听I/O事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到I/O事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时,新的领导者等待新的I/O事件,而原来的领导者则处理I/O事件,二者实现了并发。

领导者/追随者模式的组件:句柄集(HandleSet)、线程集(ThreadSet)、事件处理器(EventHandler)和具体的事件处理器(ConcreteEventHandler)。

image-20220727162049774

句柄集

句柄(Handle)用于表示I/O资源,在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方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务。

三种状态之间的转换关系:

image-20220727162626955

注意:领导者线程推选新的领导者和追随者等待成为新领导者这两个操作都将修改线程集,因此线程集提供一个成员Synchronizer来同步这两个操作,以避免竞态条件。

事件处理器和具体的事件处理器

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

领导者/追随者模式的工作流程总结:

image-20220727163037306

优点:由于领导者线程自己监听I/O事件并处理客户请求,因而领导者/追随者模式不需要在线程之间传递任何额外的数据,也无须像半同步/半反应堆模式那样在线程之间同步对请求队列的访问。

缺点:仅支持一个事件源集合,因此也无法像图8-11所示的那样,让每个工作线程独立地管理多个客户连接。