8. 高性能服务器程序框架

第8章 高性能服务器程序框架

1. 服务器模型

1.1 C/S模型

  • TCP/IP协议在设计和实现上并没有客户端和服务器的概念,在通信过程中所有机器都是对等的。
  • 但由于资源(视频、新闻、软件等)都被数据提供者所垄断,所以几乎所有的网络应用程序都很自然地采用了图8-1所示的C/S(客户端/服务器)模型:
    • 所有客户端都通过访问服务器来获取所需的资源。

C/S模型的逻辑:

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

需要注意的是,服务器在处理一个客户请求的同时还会继续监听其他客户请求。图8-2中,服务器同时监听多个客户请求是通过select系统调用实现的。

C/S模型非常适合资源相对集中的场合,并且它的实现也很简单,但其缺点也很明显:服务器是通信的中心,当访问量过大时,可能所有客户都将得到很慢的响应。、P2P模型解决了这个问题。

1.2 P2P模型

P2P(Peer to Peer,点对点)模型比C/S模型更符合网络通信的实际情况。

它摒弃了以服务器为中心的格局,让网络上所有主机重新回归对等的地位。

  • P2P模型使得每台机器在消耗服务的同时也给别人提供服务,这样资源能够充分、自由地共享。

  • 云计算机群可以看作P2P模型的一个典范。

  • 但P2P模型的缺点也很明显:当用户之间传输的请求过多时,网络的负载将加重。

  • 图8-3a所示的P2P模型存在一个显著的问题,即主机之间很难互相发现。所以实际使用的P2P模型通常带有一个专门的发现服务器,如图8-3b所示。这个发现服务器通常还提供查找服务(甚至还可以提供内容服务),使每个客户都能尽快地找到自己需要的资源。

图 8-3 两种P2P模型 a)P2P模型 b)带有发现服务器的P2P模型

从编程角度来讲,P2P模型可以看作C/S模型的扩展:每台主机既是客户端,又是服务器。因此,我们仍然采用C/S模型来讨论网络编程。

2. 服务器编程框架

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

该图既能用来描述一台服务器,也能用来描述一个服务器机群。两种情况下各个部件的含义和功能如表8-1所示。

I/O处理单元是服务器管理客户连接的模块。它通常要完成以下工作:

  • 等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端。
  • 但是,数据的收发不一定在I/O处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式(见后文)。
  • 对于一个服务器机群来说,I/O处理单元是一个专门的接入服务器。它实现负载均衡,从所有逻辑服务器中选取负荷最小的一台来为新客户服务。
  • 一个逻辑单元通常是一个进程或线程。
    • 它分析并处理客户数据,然后将结果传递给I/O处理单元或者直接发送给客户端(具体使用哪种方式取决于事件处理模式)。
    • 对服务器机群而言,一个逻辑单元本身就是一台逻辑服务器。服务器通常拥有多个逻辑单元,以实现对多个客户任务的并行处理。
    • 网络存储单元可以是数据库、缓存和文件,甚至是一台独立的服务器。但它不是必须的,比如ssh、telnet等登录服务就不需要这个单元。
    • 请求队列是各单元之间的通信方式的抽象。I/O处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。
    • 同样,多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来协调处理竞态条件。
    • 请求队列通常被实现为池的一部分,我们将在后面讨论池的概念。
    • 对于服务器机群而言,请求队列是各台服务器之间预先建立的、静态的、永久的TCP连接。
    • 这种TCP连接能提高服务器之间交换数据的效率,因为它避免了动态建立TCP连接导致的额外的系统开销。

3. I/O模型

第5章讲到,socket在创建的时候默认是阻塞的。我们可以给socket系统调用的第2个参数传递SOCK_NONBLOCK标志,或者通过fcntl系统调用的F_SETFL命令,将其设置为非阻塞的。

阻塞和非阻塞的概念能应用于所有文件描述符,而不仅仅是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事件。6.8节的最后一段提到,我们可以为一个目标文件描述符指定宿主进程,那么被指定的宿主进程将捕获到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完成事件。
    • Linux环境下,aio.h头文件中定义的函数提供了对异步I/O的支持。不过这部分内容不是本书的重点,所以只做简单的讨论。

4. 两种高效的事件处理模式

服务器程序通常需要处理三类事件:I/O事件、信号及定时事件。我们将在后续章节依次讨论这三种类型的事件,这一节先从整体上介绍一下两种高效的事件处理模式:Reactor和Proactor。 随着网络设计模式的兴起,Reactor和Proactor事件处理模式应运而生。同步I/O模型通常用于实现Reactor模式,异步I/O模型则用于实现Proactor模式。不过后面我们将看到,如何使用同步I/O方式模拟出Proactor模式。

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

  • 图8-5中,工作线程从请求队列中取出事件后,将根据事件的类型来决定如何处理它:
    • 对于可读事件,执行读数据和处理请求的操作;
    • 对于可写事件,执行写数据的操作。
  • 因此,图8-5所示的Reactor模式中,没必要区分所谓的“读工作线程”和“写工作线程”。

4.2 Proactor模式

与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。因此,Proactor模式更符合图8-4所描述的服务器编程框架。

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

  • 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例,详情请参考sigevent的man手册)。
  • 主线程继续处理其他逻辑。
  • 当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
  • 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序(仍然以信号为例)。
  • 主线程继续处理其他逻辑。
  • 当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
  • 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。

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

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

5. 两种高效的并发模式

并发编程的目的是让程序“同时”执行多个任务。

  • 如果程序是计算密集型的,并发编程并没有优势,反而由于任务的切换使效率降低。
  • 但如果程序是I/O密集型的,比如经常读写文件,访问数据库等,则情况就不同了。
  • 由于I/O操作的速度远没有CPU的计算速度快,所以让程序阻塞于I/O操作将浪费大量的CPU时间。
  • 如果程序有多个执行线程,则当前被I/O操作所阻塞的执行线程可主动放弃CPU(或由操作系统来调度),并将执行权转移到其他线程。这样一来,CPU就可以用来做更加有意义的事情(除非所有线程都同时被I/O操作所阻塞),而不是等待I/O操作完成,因此CPU的利用率显著提升。
  • 从实现上来说,并发编程主要有多进程和多线程两种方式,我们将在后续章节详细讨论它们,这一节先讨论并发模式。
  • 对应于图8-4,并发模式是指I/O处理单元和多个逻辑单元之间协调完成任务的方法。
  • 服务器主要有两种并发编程模式:半同步/半异步(half-sync/half-async)模式和领导者/追随者(Leader/Followers)模式。

5.1 半同步/半异步模式

首先,半同步/半异步模式中的“同步”和“异步”与前面讨论的I/O模型中的“同步”和“异步”是完全不同的概念。

  • 在I/O模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种I/O事件(是就绪事件还是完成事件),以及该由谁来完成I/O读写(是应用程序还是内核)。

  • 在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行;“异步”指的是程序的执行需要由系统事件来驱动。

  • 常见的系统事件包括中断、信号等。比如,图8-8a描述了同步的读操作,而图8-8b则描述了异步的读操作。

  • 按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。
    • 显然,异步线程的执行效率高,实时性强,这是很多嵌入式程序采用的模型。但编写以异步方式执行的程序相对复杂,难于调试和扩展,而且不适合于大量的并发。
    • 而同步线程则相反,它虽然效率相对较低,实时性较差,但逻辑简单。
  • 因此,对于像服务器这种既要求较好的实时性,又要求能同时处理多个客户请求的应用程序,我们就应该同时使用同步线程和异步线程来实现,即采用半同步/半异步模式来实现。
    • 半同步/半异步模式中,同步线程用于处理客户逻辑,相当于图8-4中的逻辑单元;
    • 异步线程用于处理I/O事件,相当于图8-4中的I/O处理单元。
    • 异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中。
    • 请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。
    • 具体选择哪个工作线程来为新的客户请求服务,则取决于请求队列的设计。
    • 比如最简单的轮流选取工作线程的Round Robin算法,也可以通过条件变量(见第14章)或信号量(见第14章)来随机地选择一个工作线程。图8-9总结了半同步/半异步模式的工作流程。

在服务器程序中,如果结合考虑两种事件处理模式和几种I/O模型,则半同步/半异步模式就存在多种变体。其中有一种变体称为半同步/半反应堆(half-sync/half-reactive)模式,如图8-10所示。

  • 图8-10中,异步线程只有一个,由主线程来充当。它负责监听所有socket上的事件。
  • 如果监听socket上有可读事件发生,即有新的连接请求到来,主线程就接受之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件。
  • 如果连接socket上有读写事件发生,即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接socket插入请求队列中。
  • 所有工作线程都睡眠在请求队列上,当有任务到来时,它们将通过竞争(比如申请互斥锁)获得任务的接管权。
    • 这种竞争机制使得只有空闲的工作线程才有机会来处理新任务,这是很合理的。
  • 图8-10中,主线程插入请求队列中的任务是就绪的连接socket。这说明该图所示的半同步/半反应堆模式采用的事件处理模式是Reactor模式:
    • 它要求工作线程自己从socket上读取客户请求和往socket写入服务器应答。这就是该模式的名称中“half-reactive”的含义。
    • 实际上,半同步/半反应堆模式也可以使用模拟的Proactor事件处理模式,即由主线程来完成数据的读写。
    • 在这种情况下,主线程一般会将应用程序数据、任务类型等信息封装为一个任务对象,然后将其(或者指向该任务对象的一个指针)插入请求队列。
    • 工作线程从请求队列中取得任务对象之后,即可直接处理之,而无须执行读写操作了。
  • 半同步/半反应堆模式存在如下缺点:
    • 主线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费CPU时间。
    • 每个工作线程在同一时间只能处理一个客户请求。如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢。如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量CPU时间。
    • 图8-11描述了一种相对高效的半同步/半异步模式,它的每个工作线程都能同时处理多个客户连接。

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

5.2 领导者/追随者模式

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

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

  1. 句柄集
  • 句柄(Handle)用于表示I/O资源,在Linux下通常就是一个文件描述符。

  • 句柄集管理众多句柄,它使用wait_for_event方法来监听这些句柄上的I/O事件,并将其中的就绪事件通知给领导者线程。

  • 领导者则调用绑定到Handle上的事件处理器来处理事件。

  • 领导者将Handle和事件处理器绑定是通过调用句柄集中的register_handle方法实现的。

  1. 线程集
  • 这个组件是所有工作线程(包括领导者线程和追随者线程)的管理者。
  • 它负责各线程之间的同步,以及新领导者线程的推选。
  • 线程集中的线程在任一时间必处于如下三种状态之一:
    • Leader:线程当前处于领导者身份,负责等待句柄集上的I/O事件。
    • Processing:线程正在处理事件。
      • 领导者检测到I/O事件之后,可以转移到Processing状态来处理该事件,并调用promote_new_leader方法推选新的领导者;
      • 也可以指定其他追随者来处理事件(Event Handoff),此时领导者的地位不变。
      • 当处于Processing状态的线程处理完事件之后,如果当前线程集中没有领导者,则它将成为新的领导者,否则它就直接转变为追随者。
    • Follower:线程当前处于追随者身份,通过调用线程集的join方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务。

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

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

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

6. 有限状态机

逻辑单元内部的一种高效编程方法:有限状态机(finite state machine)。

有的应用层协议头部包含数据包类型字段,每种类型可以映射为逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑,如代码清单8-1所示。

代码清单8-1 状态独立的有限状态机

1
2
3
4
5
6
7
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;
}
}

这就是一个简单的有限状态机,只不过该状态机的每个状态都是相互独立的,即状态之间没有相互转移。

状态之间的转移是需要状态机内部驱动的.

1
2
3
4
5
6
7
8
9
10
11
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方法获得一个新的数据包,然后根据cur_State变量的值判断如何处理该数据包。
    • 数据包处理完之后,状态机通过给cur_State变量传递目标状态值来实现状态转移。那么当状态机进入下一趟循环时,它将执行新的状态对应的逻辑。
  • 下面我们考虑有限状态机应用的一个实例:HTTP请求的读取和分析。
    • 很多网络协议,包括TCP协议和IP协议,都在其头部中提供头部长度字段。程序根据该字段的值就可以知道是否接收到一个完整的协议头部。但HTTP协议并未提供这样的头部长度字段,并且其头部长度变化也很大,可以只有十几字节,也可以有上百字节。
    • 根据协议规定,我们判断HTTP头部结束的依据是遇到一个空行,该空行仅包含一对回车换行符(<CR><LF>)。如果一次读操作没有读入HTTP请求的整个头部,即没有遇到空行,那么我们必须等待客户继续写数据并再次读入。
    • 因此,我们每完成一次读操作,就要分析新读入的数据中是否为空行。不过在寻找空行的过程中,我们可以同时完成对整个HTTP请求头部的分析(记住,空行前面还有请求行和头部域),以提高解析HTTP请求的效率。
    • 代码清单8-3使用主、从两个有限状态机实现了最简单的HTTP请求的读取和分析。为了使表述简洁,我们约定,直接称HTTP请求的一行(包括请求行和头部字段)为行。

代码清单8-3 HTTP请求的读取和分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>

#define BUFFER_SIZE 4096
/*
主状态机的两种可能状态,分别表示:
当前正在分析请求行,
当前正在分析头部字段
*/
enum CHECK_STATE {CHECK_STATE_REQUESTLINE = 0, CHECK_STATE_HEADER, CHECK_STATE_CONTENT};
/*
从状态机的三种可能状态,即行的读取状态,分别表示:
读取到一个完整的行、
行出错
行数据尚且不完整
*/
enum LINE_STATUS {LINE_OK = 0, LINE_BAD, LINE_OPEN};
/*
服务器处理HTTP请求的结果:
NO_REQUEST表示请求不完整,需要继续读取客户数据;
GET_REQUEST表示获得了一个完整的客户请求;
BAD_REQUEST表示客户请求有语法错误;
FORBIDDEN_REQUEST表示客户对资源没有足够的访问权限;
INTERNAL_ERROR表示服务器内部错误;
CLOSED_CONNECTION表示客户端已经关闭连接了
*/
enum HTTP_CODE {NO_REQUEST, GET_REQUEST, BAD_REQUEST, FORBIDDEN_REQUEST,
INTERNAL_ERROR, CLOSED_CONNECTION};
static const char* szret[] = {"I get a correct result\n", "Something wrong\n"};

/*从状态机,用于解析出一行内容*/
LINE_STATUS parse_line(char* buffer, int& checked_index, int& read_index) {
char temp;
/*
checked_index指向buffer(应用程序的读缓冲区)中当前正在分析的字节,
read_index指向buffer中客户数据的尾部的下一字节。
buffer中第0~checked_index字节都已分析完毕,
第checked_index~(read_index-1)字节由下面的循环挨个分析
*/
for ( ; checked_index < read_index; ++checked_index) {
/*获得当前要分析的字节*/
temp = buffer[checked_index];
/*如果当前的字节是“\r”,即回车符,则说明可能读取到一个完整的行*/
if (temp == '\r') {
/*
如果“\r”字符碰巧是目前buffer中的最后一个已经被读入的客户数据,
那么这次分析没有读取到一个完整的行,
返回LINE_OPEN以表示还需要继续读取客户数据才能进一步分析
*/
if ((checked_index + 1) == read_index) {
return LINE_OPEN;
} else if (buffer[checked_index + 1] == '\n') {
buffer[checked_index++] = '\0';
buffer[checked_index++] = '\0';
return LINE_OK;
}
return LINE_BAD;
} else if (temp == '\n') { /*如果下一个字符是“\n”,则说明我们成功读取到一个完整的行*/
if((checked_index > 1) && buffer[checked_index - 1] == '\r') {
buffer[checked_index - 1] = '\0';
buffer[checked_index++] = '\0';
return LINE_OK;
}
return LINE_BAD;
}
}
/*如果所有内容都分析完毕也没遇到“\r”字符,则返回LINE_OPEN,表示还需要继续读取客户数据才能进一步分析*/
return LINE_OPEN;
}

HTTP_CODE parse_requestline(char* szTemp, CHECK_STATE& checkstate) {
char* szURL = strpbrk(szTemp, " \t");
/*如果请求行中没有空白字符或“\t”字符,则HTTP请求必有问题*/
if(!szURL) {
return BAD_REQUEST;
}
*szURL++ = '\0';

char* szMethod = szTemp;
/*仅支持GET方法*/
if(strcasecmp(szMethod, "GET") == 0) {
printf("The request method is GET\n");
} else {
return BAD_REQUEST;
}

szURL += strspn(szURL, " \t");
char* szVersion = strpbrk(szURL, " \t");
if(!szVersion) {
return BAD_REQUEST;
}
*szVersion++ = '\0';
szVersion += strspn(szVersion, " \t");
/*仅支持HTTP/1.1*/
if(strcasecmp(szVersion, "HTTP/1.1") != 0) {
return BAD_REQUEST;
}
/*检查URL是否合法*/
if(strncasecmp(szURL, "http://", 7) == 0) {
szURL += 7;
szURL = strchr(szURL, '/');
}

if(!szURL || szURL[0] != '/') {
return BAD_REQUEST;
}

printf("The request URL is: %s\n", szURL);
/*HTTP请求行处理完毕,状态转移到头部字段的分析*/
checkstate = CHECK_STATE_HEADER;
return NO_REQUEST;
}

/*分析头部字段*/
HTTP_CODE parse_headers(char* szTemp) {
/*遇到一个空行,说明我们得到了一个正确的HTTP请求*/
if(szTemp[0] == '\0') {
return GET_REQUEST;
} else if (strncasecmp(szTemp, "Host:", 5) == 0) { /*处理“HOST”头部字段*/
szTemp += 5;
szTemp += strspn(szTemp, " \t");
printf("the request host is: %s\n", szTemp);
} else {
printf("I can not handle this header\n");
}

return NO_REQUEST;
}

/*分析HTTP请求的入口函数*/
HTTP_CODE parse_content(char* buffer, int& checked_index, CHECK_STATE& checkstate,
int& read_index, int& start_line) {
/*记录当前行的读取状态*/
LINE_STATUS linestatus = LINE_OK;
/*记录HTTP请求的处理结果*/
HTTP_CODE retcode = NO_REQUEST;
/*主状态机,用于从buffer中取出所有完整的行*/
while ((linestatus = parse_line(buffer, checked_index, read_index)) == LINE_OK) {
/*start_line是行在buffer中的起始位置*/
char* szTemp = buffer + start_line;
/*记录下一行的起始位置*/
start_line = checked_index;
/*checkstate记录主状态机当前的状态*/
switch (checkstate)
{
/*第一个状态,分析请求行*/
case CHECK_STATE_REQUESTLINE: {
retcode = parse_requestline(szTemp, checkstate);
if(retcode == BAD_REQUEST) {
return BAD_REQUEST;
}
break;
}
/*第二个状态,分析头部字段*/
case CHECK_STATE_HEADER: {
retcode = parse_headers(szTemp);
if(retcode == BAD_REQUEST) {
return BAD_REQUEST;
} else if (retcode == GET_REQUEST) {
return GET_REQUEST;
}
break;
}

default: {
return INTERNAL_ERROR;
break;
}
}
}
if(linestatus == LINE_OPEN) {
return NO_REQUEST;
} else {
return BAD_REQUEST;
}
}

int main(int argc, char* argv[])
{
if(argc <= 2) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}

const char* ip = argv[1];
int port = atoi(argv[2]);

struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);

int listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);

int ret = bind(listenfd, (struct sockaddr* )&address, sizeof(address));
assert(ret != -1);

ret = listen(listenfd, 5);
assert(ret != -1);

struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);

int fd = accept(listenfd, (struct sockaddr* )&client_address, &client_addrlength);
if (fd < 0) {
printf("errno is: %d\n", errno);
} else {
char buffer[BUFFER_SIZE]; /*读缓冲区*/
memset(buffer, '\0', BUFFER_SIZE);
int data_read = 0;
int read_index = 0;/*当前已经读取了多少字节的客户数据*/
int checked_index = 0;/*当前已经分析完了多少字节的客户数据*/
int start_line = 0;/*行在buffer中的起始位置*/
CHECK_STATE checkstate = CHECK_STATE_REQUESTLINE; /*设置主状态机的初始状态*/
while(1) {
data_read = recv(fd, buffer + read_index, BUFFER_SIZE - read_index, 0); //接收到的字节数
if(data_read == -1) {
printf("reading failed\n");
break;
} else if (data_read == 0) {
printf("remote client has closed the connection\n");
break;
}

read_index += data_read;
HTTP_CODE result = parse_content(buffer, checked_index, checkstate, read_index, start_line);

if(result == NO_REQUEST) {
continue;
} else if (result == GET_REQUEST) {
send(fd, szret[0], strlen(szret[0]), 0);
break;
} else {
send(fd, szret[1], strlen(szret[1]), 0);
break;
}
}
close(fd);
}
close(listenfd);
return 0;
}

将代码清单8-3中的两个有限状态机分别称为主状态机和从状态机,这体现了它们之间的关系:主状态机在内部调用从状态机。

下面先分析从状态机,即parse_line函数,它从buffer中解析出一个行。

  • 这个状态机的初始状态是LINE_OK,其原始驱动力来自于buffer中新到达的客户数据。
  • 在main函数中,我们循环调用recv函数往buffer中读入客户数据。
  • 每次成功读取数据后,我们就调用parse_content函数来分析新读入的数据。
  • parse_content函数首先要做的就是调用parse_line函数来获取一个行。
  • 现在假设服务器经过一次recv调用之后,buffer的内容以及部分变量的值如图8-16a所示。
image-20241219205531667

a)调用recv后,buffer里的初始内容和部分变量的值 b)parse_line函数处理buffer后的结果 c)再次调用recv后的结果 d)parse_line函数再次处理buffer后的结果

  • parse_line函数处理后的结果如图8-16b所示,它挨个检查图8-16a所示的buffer中checked_index到(read_index-1)之间的字节,判断是否存在行结束符,并更新checked_index的值。
  • 当前buffer中不存在行结束符,所以parse_line返回LINE_OPEN。
  • 接下来,程序继续调用recv以读取更多客户数据,这次读操作后buffer中的内容以及部分变量的值如图8-16c所示。
  • 然后parse_line函数就又开始处理这部分新到来的数据,如图8-16d所示。
  • 这次它读取到了一个完整的行,即“HOST:localhost”。此时,parse_line函数就可以将这行内容递交给parse_content函数中的主状态机来处理了。
  • 主状态机使用checkstate变量来记录当前的状态。
    • 如果当前的状态是CHECK_STATE_REQUESTLINE,则表示parse_line函数解析出的行是请求行,于是主状态机调用parse_requestline来分析请求行;
    • 如果当前的状态是CHECK_STATE_HEADER,则表示parse_line函数解析出的是头部字段,于是主状态机调用parse_headers来分析头部字段。 checkstate变量的初始值是CHECK_STATE_REQUESTLINE,parse_requestline函数在成功地分析完请求行之后将其设置为CHECK_STATE_HEADER,从而实现状态转移。

7. 提高服务器性能的其他建议

影响服务器性能的首要因素就是系统的硬件资源,比如CPU的个数、速度,内存的大小等。

不过由于硬件技术的飞速发展,现代服务器都不缺乏硬件资源。因此,我们需要考虑的主要问题是如何从“软环境”来提升服务器的性能。

服务器的“软环境”,

  • 一方面是指系统的软件资源,比如操作系统允许用户打开的最大文件描述符数量;
  • 另一方面指的就是服务器程序本身,即如何从编程的角度来确保服务器的性能,

前面我们介绍了几种高效的事件处理模式和并发模式,以及高效的逻辑处理方式——有限状态机,它们都有助于提高服务器的整体性能。

下面我们进一步分析高性能服务器需要注意的其他几个方面:池、数据复制、上下文切换和锁。

7.1 池

既然服务器的硬件资源“充裕”,那么提高服务器性能的一个很直接的方法就是以空间换时间,即“浪费”服务器的硬件资源,以换取其运行效率。这就是池(pool)的概念。

  • 池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源分配。
    • 当服务器进入正式运行阶段,即开始处理客户请求的时候,如果它需要相关的资源,就可以直接从池中获取,无须动态分配。
    • 很显然,直接从池中取得所需资源比动态分配资源的速度要快得多,因为分配系统资源的系统调用都是很耗时的。
    • 当服务器处理完一个客户连接后,可以把相关的资源放回池中,无须执行系统调用来释放资源。
    • 从最终的效果来看,池相当于服务器管理系统资源的应用层设施,它避免了服务器对内核的频繁访问。
  • 不过,既然池中的资源是预先静态分配的,我们就无法预期应该分配多少资源。这个问题又该如何解决呢?
    • 最简单的解决方案就是分配“足够多”的资源,即针对每个可能的客户连接都分配必要的资源。
    • 这通常会导致资源的浪费,因为任一时刻的客户数量都可能远远没有达到服务器能支持的最大客户数量。好在这种资源的浪费对服务器来说一般不会构成问题。
  • 还有一种解决方案是预先分配一定的资源,此后如果发现资源不够用,就再动态分配一些并加入池中。
    • 根据不同的资源类型,池可分为多种,常见的有内存池、进程池、线程池和连接池。它们的含义都很明确。
    • 内存池通常用于socket的接收缓存和发送缓存。对于某些长度有限的客户请求,比如HTTP请求,预先分配一个大小足够(比如5000字节)的接收缓存区是很合理的。
      • 当客户请求的长度超过接收缓冲区的大小时,我们可以选择丢弃请求或者动态扩大接收缓冲区。
    • 进程池和线程池都是并发编程常用的“伎俩”。
      • 当我们需要一个工作进程或工作线程来处理新到来的客户请求时,我们可以直接从进程池或线程池中取得一个执行实体,而无须动态地调用fork或pthread_create等函数来创建进程和线程。
  • 连接池通常用于服务器或服务器机群的内部永久连接。图8-4中,每个逻辑单元可能都需要频繁地访问本地的某个数据库。
    • 简单的做法是:
    • 逻辑单元每次需要访问数据库的时候,就向数据库程序发起连接,而访问完毕后释放连接。
    • 很显然,这种做法的效率太低。一种解决方案是使用连接池。
    • 连接池是服务器预先和数据库程序建立的一组连接的集合。
    • 当某个逻辑单元需要访问数据库时,它可以直接从连接池中取得一个连接的实体并使用之。
    • 待完成数据库的访问之后,逻辑单元再将该连接返还给连接池。

7.2 数据复制

高性能服务器应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候。

  • 如果内核可以直接处理从socket或者文件读入的数据,则应用程序就没必要将这些数据从内核缓冲区复制到应用程序缓冲区中。
  • 这里说的“直接处理”指的是应用程序不关心这些数据的内容,不需要对它们做任何分析。
    • 比如ftp服务器,当客户请求一个文件时,服务器只需要检测目标文件是否存在,以及客户是否有读取它的权限,而绝对不会关心文件的具体内容。
    • 这样的话,ftp服务器就无须把目标文件的内容完整地读入到应用程序缓冲区中并调用send函数来发送,而是可以使用“零拷贝”函数sendfile来直接将其发送给客户端。
  • 此外,用户代码内部(不访问内核)的数据复制也是应该避免的。
    • 举例来说,当两个工作进程之间要传递大量的数据时,我们就可以考虑使用共享内存来在它们之间直接共享这些数据,而不是使用管道或者消息队列来传递。
    • 又比如代码清单8-3所示的解析HTTP请求的实例中,我们用指针(start_line)来指出每个行在buffer中的起始位置,以便随后对行内容进行访问,而不是把行的内容复制到另外一个缓冲区中来使用,因为这样既浪费空间,又效率低下。

7.3 上下文切换和锁

并发程序必须考虑上下文切换(context switch)的问题,

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

并发程序需要考虑的另外一个问题是共享资源的加锁保护。

  • 锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。
  • 因此,服务器如果有更好的解决方案,就应该避免使用锁。
  • 显然,图8-11所描述的半同步/半异步模式就比图8-10所描述的半同步/半反应堆模式的效率高。
  • 如果服务器必须使用“锁”,则可以考虑减小锁的粒度,比如使用读写锁。
  • 当所有工作线程都只读取一块共享内存的内容时,读写锁并不会增加系统的额外开销。只有当其中某一个工作线程需要写这块内存时,系统才必须去锁住这块区域。

8. 高性能服务器程序框架
http://binbo-zappy.github.io/2024/12/16/Linux高性能服务器编程-游双/8-高性能服务器程序框架/
作者
Binbo
发布于
2024年12月16日
许可协议