目录
服务器可被解构为 3 个主要模块:
- I/O 处理单元
- 逻辑单元
- 存储单元
一、服务器模型
1. C/S 模型
TCP/IP 协议在设计和实现上并没有客户端和服务器的概念,在通信过程中所有机器都是对等的。但由于资源(视频、新闻、软件等)都被数据提供者所垄断,所以几乎所有的网络应用程序都很自然地用了客户端/服务器模型,即所有客户端都通过访问服务器来获取所需的资源:
C/S 模型的逻辑很简单:
- 服务器启动后,首先创建一个(或多个) 监听 socket ,并调用 bind 函数将其绑定到服务器感兴趣的端口上;
- 然后调用 listen 函数等待客户连接;
- 服务器稳定运行之后,客户端就可以用 connect 函数向服务器发起连接了。由于客户连接请求是随机到达的异步事件,服务器需要使用某种 I/O 模型来监听这一事件。I/O模型有多种,上图中服务器使用的是 I/O 复用技术之一的 select 系统调用;
- 当监听到连接请求后,服务器就调用 accept 函数接受它,并分配一个逻辑单元为新的连接服务。逻辑单元可以是新创建的子进程、子线程或者其他。上图中服务器给客户端分配的逻辑单元是由 fork 系统调用创建的子进程;
- 逻辑单元读取客户请求,处理该请求,然后将处理结果返回给客户端;
- 客户端接收到服务器反馈的结果之后,可以继续向服务器发送请求,也可以立即主动关闭连接。如果客户端主动关闭连接,则服务器执行被动关闭连接。至此,双方的通信结束。
- 需要注意的是,服务器在处理一个客户请求的同时还会继续监听其他客户请求,否则就变成了效率低下的串行服务器了(必须先处理完前一个客户的请求,才能继续处理下一个客户请求)。上图中服务器同时监听多个客户请求是通过 select 系统调用实现的。
2. P2P 模型
P2P(Peer to Peer,点对点)模型比 C/S 模型更符合网络通信的实际情况。它摒弃了以服务器为中心的格局,让网络上所有主机重新回归对等的地位:
P2P 模型使得每台机器在消耗服务的同时也给别人提供服务,这样资源能够充分、自由地共享。云计算机群可以看作 P2P 模型的一个典范。但 P2P 模型的缺点也很明显:当用户之间传输的请求过多时,网络的负载将加重。 上图中 a 存在一个显著的问题,即主机之间很难互相发现。所以实际使用的 P2P 模型通常带有一个专门的发现服务器,如图 b 所示。这个发现服务器通常还提供查找服务(甚至还可以提供内容服务),使每个客户都能尽快地找到自己需要的资源。
从编程角度来讲,P2P 模型可以看作 C/S 模型的扩展:每台主机既是客户端,又是服务器。因此仍然采用 C/S 模型来讨论网络编程。
二、服务器编程框架
虽然服务器程序种类繁多,但其基本框架都一样,不同之处在于逻辑处理,本章先讨论基本框架:
上图既能描述一台服务器,也能用来描述一个服务器机群,两种情况下的各个部件的含义如下:
模块 | 单个服务器程序 | 服务器机群 |
---|---|---|
I/O 处理单元 | 处理客户连接,读写网络数据 | 作为接入服务器,实现负载均衡 |
逻辑单元 | 业务进程或线程 | 逻辑服务器 |
网络存储单元 | 本地数据库、文件或缓存 | 数据库服务器 |
请求队列 | 各单元之间的通信方式 | 各服务器之间的永久 TCP 连接 |
- I/O处理单元是服务器管理客户连接的模块。它通常要完成以下工作:等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端。但是数据的收发不一定在 I/O 处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式。对于一个服务器机群来说,I/O 处理单元是一个专门的接入服务器。它实现负载均衡,从所有逻辑服务器中选取负荷最小的一台来为新客户服务。
- 一个逻辑单元通常是一个进程或线程。它分析并处理客户数据,然后将结果传递给 I/O 处理单元或者直接发送给客户端(取决于事件处理模式)。对服务器机群而言,一个逻辑单元本身就是一台逻辑服务器。服务器通常拥有多个逻辑单元,以实现对多个客户任务的并行处理。
- 网络存储单元可以是数据库、缓存和文件,甚至是一台独立的服务器。但它不是必须的,比如 ssh、telnet 等登录服务就不需要这个单元。
- 请求队列是各单元之间的通信方式的抽象。I/O 处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。同样多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来协调处理竞态条件。请求队列通常被实现为池的一部分。对于服务器机群而言,请求队列是各台服务器之间预先建立的、静态的、永久的 TCP 连接。这种 TCP 连接能提高服务器之间交换数据的效率,因为它避免了动态建立 TCP 连接导致的额外的系统开销。
三、I/O 模型
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 事件。我们可以为一个目标文件描述符指定宿主进程,那么被指定的宿主进程将捕获到 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 完成事件。Linux 环境下,aio.h 头文件中定义的函数提供了对异步 I/O 的支持:
I/O 模型 | 读写操作和阻塞阶段 |
---|---|
阻塞 I/O | 程序阻塞于读写函数 |
I/O 复用 | 程序阻塞于 I/O 复用系统调用,但可同时监听多个 I/O 时间,对 I/O 本身的读写操作是非阻塞的 |
SIGIO 信号 | 信号触发读写就绪事件,用户程序执行读写操作,程序没有阻塞阶段 |
异步 I/O | 内核执行读写操作并处罚读写完成事件,程序没有阻塞阶段 |
四、两种高效的事件处理模式
服务器程序通常需要处理三类事件:I/O 事件、信号及定时事件。 本节先从整体上介绍两种高效的事件处理模式:Reactor 和 Proactor 。随着网络设计模式的兴起,Reactor 和 Proactor 事件处理模式应运而生。同步 I/O 模型通常用于实现 Reactor 模式,异步 I/O 模型则用于实现 Proactor 模式。
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 上写入服务器处理客户请求的结果。
图中工作线程从请求队列中取出事件后,将根据事件的类型来决定如何处理它:
- 对于可读事件,执行读数据和处理请求的操作;
- 对于可写事件,执行写数据的操作。
因此,图中所示的 Reactor 模式中,没必要区分所谓的“读工作线程”和“写工作线程”。
2. Proactor 模式
与 Reactor 模式不同,Proactor 模式将所有 I/O 操作都交给主线程和内核来处理,工作线程仅负责业务逻辑。因此 Proactor 模式更符合图 8-4 所描述的服务器编程框架。使用异步 I/O 模型(以 aio_read 和 aio_write 为例)实现的 Proactor 模式的工作流程是:
- 主线程调用 aio_read 函数向内核注册 socket 上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序;
- 主线程继续处理其他逻辑;
- 当 socket 上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用;
- 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用 aio_write 函数向内核注册 socket 上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序;
- 主线程继续处理其他逻辑;
- 当用户缓冲区的数据被写入 socket 之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕;
- 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭 socket 。
在上中,连接 socket 上的读写事件是通过 aio_read/aio_write 向内核注册的,因此内核将通过信号来向应用程序报告连接 socket 上的读事件。所以,主线程中的 epoll_wait 调用仅能用来检测监听 socket上 的连接请求事件,而不能用来检测连接 socket 上的读写事件。
3. 模拟 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 密集型的,比如经常读写文件,访问数据库等,则情况就不同了。由于 I/O 操作的速度远没有 CPU 的计算速度快,所以让程序阻塞于 I/O 操作将浪费大量的 CPU 时间。如果程序有多个执行线程, 则当前被 I/O 操作所阻塞的执行线程可主动放弃 CPU(或由操作系统来调度),并将执行权转移到其他线程。这样一来,CPU 就可以用来做更加有意义的事情(除非所有线程都同时被 I/O 操作所阻塞),而不是等待 I/O 操作完成,因此 CPU 的利用率显著提升。
从实现上来说,并发编程主要有多进程和多线程两种方式,这一节先讨论并发模式,并发模式是指 I/O 处理单元和多个逻辑单元之间协调完成任务的方法。服务器主要有两种并发编程模式:半同步/半异步(half-sync/halfasync)模式和领导者/追随者(Leader/Followers)模式。
1. 半同步/半异步模式
首先,半同步/半异步模式中的“同步”和“异步”与前面讨论的 I/O 模型中的“同步”和“异步”是完全不同的概念。在 I/O 模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种 I/O 事件(是就绪事件还是完成事件),以及该由谁来完成 I/O 读写(是应用程序还是内核)。在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行;“异步”指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等:
按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。显然,异步线程的执行效率高,实时性强,这是很多嵌入式程序采用的模型。但编写以异步方式执行的程序相对复杂,难于调试和扩展,而且不适合于大量的并发。而同步线程则相反,它虽然效率相对较低,实时性较差,但逻辑简单。因此,对于像服务器这种既要求较好的实时性,又要求能同时处理多个客户请求的应用程序,我们就应该同时使用同步线程和异步线程来实现,即采用半同步/半异步模式来实现。
半同步/半异步模式中,同步线程用于处理客户逻辑,相当于逻辑单元;异步线程用于处理 I/O 事件,相当于 I/O 处理单元。异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。具体选择哪个工作线程来为新的客户请求服务,则取决于请求队列的设计。比如最简单的轮流选取工作线程的 Round Robin 算法,也可以通过条件变量或信号量来随机地选择一个工作线程。半同步/半异步模式的工作流程如下:
在服务器程序中,如果结合考虑两种事件处理模式和几种 I/O 模型,则半同步/半异步模式就存在多种变体。其中有一种变体称为半同步/半反应堆(half-sync/half-reactive)模式:
- 异步线程只有一个,由主线程来充当。它负责监听所有 socket 上的事件;
- 如果监听 socket 上有可读事件发生,即有新的连接请求到来,主线程就接受之以得到新的连接 socket ;
- 然后往 epoll 内核事件表中注册该 socket 上的可读事件;
- 如果连接 socket 上有读写事件发生, 即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接 socket 插入请求队列中;
- 所有工作线程都睡眠在请求队列上,当有任务到来时,它们将通过竞争(比如申请互斥锁)获得任务的接管权。这种竞争机制使得只有空闲的工作线程才有机会来处理新任务。
主线程插入请求队列中的任务是就绪的连接 socket 。这说明上图所示的半同步/半反应堆模式采用的事件处理模式是 Reactor 模式:它要求工作线程自己从 socket 上读取客户请求和往 socket 写入服务器应答。这就是该模式的名称中 half-reactive 的含义。实际上,半同步/半反应堆模式也可以使用模拟的 Proactor 事件处理模式,即由主线程来完成数据的读写。在这种情况下,主线程一般会将应用程序数据、任务类型等信息封装为一个任务对象,然后将其(或者指向该任务对象的一个指针)插入请求队列。工作线程从请求队列中取得任务对象之后,即可直接处理之,而无须执行读写操作了。
半同步/半反应堆模式存在如下缺点:
- 主线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费 CPU 时间;
- 每个工作线程在同一时间只能处理一个客户请求。如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢。如果通过增加工作线程来解决这一问题, 则工作线程的切换也将耗费大量 CPU 时间。
下图描述了一种相对高效的半同步/半异步模式,它的每个工作线程都能同时处理多个客户连接:
- 主线程只管理监听 socket ,连接 socket 由工作线程来管理;
- 当有新的连接到来时,主线程就接受之并将新返回的连接 socket 派发给某个工作线程,此后该新 socket 上的任何 I/O 操作都由被选中的工作线程来处理,直到客户关闭连接;
- 主线程向工作线程派发 socket 的简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来。 如果是则把该新 socket 上的读写事件注册到自己的 epoll 内核事件表中。
可见上图中,每个线程(主线程和工作线程)都维持自己的事件循环,它们各自独立地监听不同的事件。因此,在这种高效的半同步/半异步模式中,每个线程都工作在异步模式,所以它并非严格意义上的半同步/半异步模式。
2. 领导者/追随者模式
领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听 I/O 事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到 I/O 事件,首先要从线程池中推选出新的领导者线程,然后处理 I/O 事件。此时新的领导者等待新的 I/O 事件,而原来的领导者则处理 I/O 事件,二者实现了并发。
领导者/追随者模式包含如下几个组件:句柄集(HandleSet)、线程集(ThreadSet)、事件处理器(EventHandler)和具体的事件处理(ConcreteEventHandler):
- 句柄集:句柄(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 方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务;
- 需要注意的是,领导者线程推选新的领导者和追随者等待成为新领导者这两个操作都将修改线程集,因此线程集提供一个成员 Synchronizer 来同步这两个操作,以避免竞态条件。
- 事件处理器和具体的事件处理器:事件处理器通常包含一个或多个回调函数 handle_event 。这些回调函数用于处理事件对应的业务逻辑。事件处理器在使用前需要被绑定到某个句柄上,当该句柄上有事件发生时,领导者就执行与之绑定的事件处理器中的回调函数。具体的事件处理器是事件处理器的派生类。它们必须重新实现基类的 handle_event 方法,以处理特定的任务。
由于领导者线程自己监听 I/O 事件并处理客户请求,因而领导者/追随者模式不需要在线程之间传递任何额外的数据,也无须像半同步/半反应堆模式那样在线程之间同步对请求队列的访问。但领导者/追随者的一个明显缺点是仅支持一个事件源集合,因此也无法像图 8 - 11 所示的那样,让每个工作线程独立地管理多个客户连接。
六、有限状态机
前面两节探讨的是服务器的 I/O 处理单元、请求队列和逻辑单元之间协调完成任务的各种模式,这一节介绍逻辑单元内部的一种高效编程方法:有限状态机(finite state machine)。有的应用层协议头部包含数据包类型字段,每种类型可以映射为 逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑:
STATE_MACHINE(Package_pack) { // 状态机
PackageType _type = _pack.GetType(); // 获取状态
switch(_type) {
case type_A: // 判定状态A
process_package_A(_pack);
break;
case type_B: // 判定状态B
process_package_B(_pack);
break;
}
}
这就是一个简单的有限状态机,只不过该状态机的每个状态都是相互独立的,即状态之间没有相互转移。状态之间的转移是需要状态机内部驱动的:
STATE_MACHINE() {
State cur_State = type_A; // 设定当前状态A
while(cur_State != type_C) { // 循环判断状态转移
PackageType _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 请求的效率。以下代码使用主、从两个有限状态机实现了最简单的 HTTP 请求的读取和分析。为了使表述简洁,我们约定,直接称 HTTP 请求的一行(包括请求行和头部字段)为行:
#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
};
//从状态机的三种可能状态,即行的读取状态,分别表示:读取到一个完整的行、行出 错和行数据尚且不完整
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
};
//为了简化问题,我们没有给客户端发送一个完整的HTTP应答报文,而只是根据服务器 的处理结果发送如下成功或失败信息
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;
}
//如果下一个字符是“\n”,则说明我们成功读取到一个完整的行
else if (buffer[checked_index + 1] == '\n')
{
buffer[checked_index++] = '\0';
buffer[checked_index++] = '\0';
return LINE_OK;
}
//否则的话,说明客户发送的HTTP请求存在语法问题
return LINE_BAD;
}
//如果当前的字节是“\n”,即换行符,则也说明可能读取到一个完整的行 else if (temp == '\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 *temp, CHECK_STATE&checkstate)
{
char *url = strpbrk(temp, "\t");
//如果请求行中没有空白字符或“\t”字符,则HTTP请求必有问题
if (!url)
{
return BAD_REQUEST;
}
*url++ = '\0';
char *method = temp;
if (strcasecmp(method, "GET") == 0)
//仅支持GET方法
{
printf("The request method is GET\n");
}
else
{
return BAD_REQUEST;
}
url += strspn(url, "\t");
char *version = strpbrk(url, "\t");
if (!version)
{
return BAD_REQUEST;
}
*version++ = '\0';
version += strspn(version, "\t");
//仅支持HTTP/1.1
if (strcasecmp(version, "HTTP/1.1") != 0)
{
return BAD_REQUEST;
}
//检查URL是否合法
if (strncasecmp(url, "http://", 7) == 0)
{
url += 7;
url = strchr(url, '/');
}
if (!url || url[0] != '/')
{
return BAD_REQUEST;
}
printf("The request URL is:%s\n", url);
//HTTP请求行处理完毕,状态转移到头部字段的分析
checkstate = CHECK_STATE_HEADER;
return NO_REQUEST;
}
//分析头部字段
HTTP_CODE parse_headers(char *temp)
{
//遇到一个空行,说明我们得到了一个正确的HTTP请求
if (temp[0] == '\0')
{
return GET_REQUEST;
}
else if (strncasecmp(temp, "Host:", 5) == 0) //处理“HOST”头部字段
{
temp += 5;
temp += strspn(temp, "\t");
printf("the request host is:%s\n", temp);
}
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_CODE retcode = NO_REQUEST; //记录HTTP请求的处理结果
//主状态机,用于从buffer中取出所有完整的行
while ((linestatus = parse_line(buffer, checked_index, read_index)) == L INE_OK)
{
char *temp = buffer + start_line; //start_line是行在buffer中的起始位置
start_line = checked_index; //记录下一行的起始位置
//checkstate记录主状态机当前的状态
switch (checkstate)
{
case CHECK_STATE_REQUESTLINE: //第一个状态,分析请求行
{
retcode = parse_requestline(temp, checkstate);
if (retcode == BAD_REQUEST)
{
return BAD_REQUEST;
}
break;
}
case CHECK_STATE_HEADER: //第二个状态,分析头部字段
{
retcode = parse_headers(temp);
if (retcode == BAD_REQUEST)
{
return BAD_REQUEST;
}
else if (retcode == GET_REQUEST)
{
return GET_REQUEST;
}
break;
}
default:
{
return INTERNAL_ERROR;
}
}
}
//若没有读取到一个完整的行,则表示还需要继续读取客户数据才能进一步分析
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, star t_line);
if (result == NO_REQUEST) //尚未得到一个完整的HTTP请求
{
continue;
}
else if (result == GET_REQUEST) //得到一个完整的、正确的HTTP请求
{
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;
}
我们将上述代码中的两个有限状态机分别称为主状态机和从状态机,这体现了它们之间的关系:主状态机在内部调用从状态机。下面先分析从状态机,即 parse_line 函数,它从 buffer 中解析出一个行:
这个状态机的初始状态是 LINE_OK ,其原始驱动力来自于 buffer 中新到达的客户数据。在 main 函数中,我们循环调用 recv 函数往 buffer 中读入客户数据。每次成功读取数据后,我们就调用 parse_content 函数来分析新读入的数据。parse_content 函数首先要做的就是调用 parse_line 函数来获取一个行。现在假设服务器经过一次 recv 调用之后,buffer 的内容以及部分变量的值如(a)。
parse_line 函数处理后的结果如(b)所示,它挨个检查图(a)所示的 buffer 中 checked_index 到(read_index-1)之间的字节,判断是否存在行结束符,并更新 checked_index 的值。当前 buffer 中不存在行结束符,所以 parse_line 返回 LINE_OPEN 。接下来,程序继续调用 recv 以读取更多客户数据,这次读操作后 buffer 中的内容以及部分变量的值如图 (c)所示。然后 parse_line 函数就又开始处理这部分新到来的数据,如图(d)所示。这次它读取到了一个完整的行, 即 HOST:localhost\r\n 。此时,parse_line 函数就可以将这行内容递交给 parse_content 函数中的主状态机来处理了。
- (a)调用 recv 后,buffer 里的初始内容和部分变量的值;
- (b)parse_line 函数处理 buffer 后的结果;
- (c)再次调用 recv 后的结果;
- (d)parse_line 函数再次处理 buffer 后的结果。
主状态机使用 checkstate 变量来记录当前的状态。如果当前的状态是 CHECK_STATE_REQUESTLINE ,则表示 parse_line 函数解析出的行是请求行,于是主状态机调用 parse_requestline 来分析请求行;如果当前的状态是 CHECK_STATE_HEADER ,则表示 parse_line 函数解析出的是头部字段,于是主状态机调用 parse_headers 来分析头部字段。checkstate 变量的初始值是 CHECK_STATE_REQUESTLINE ,parse_requestline 函数在成功地分析完请求行之后将其设置为 CHECK_STATE_HEADER ,从而实现状态转移。
七、提高服务器性能的其他建议
性能对服务器来说是至关重要的,毕竟每个客户都期望其请求能很快地得到响应。影响服务器性能的首要因素就是系统的硬件资源, 比如 CPU 的个数、速度,内存的大小等。不过由于硬件技术的飞速发展,现代服务器都不缺乏硬件资源。因此需要考虑的主要问题是如何从“软环境”来提升服务器的性能。服务器的“软环境”:
- 一方面是指系统的软件资源,比如操作系统允许用户打开的最大文件描述符数量;
- 另一方面指的就是服务器程序本身,即如何从编程的角度来确保服务器的性能。
前面介绍了几种高效的事件处理模式和并发模式,以及高效的逻辑处理方式——有限状态机,它们都有助于提高服务器的整体性能。下面将进一步分析高性能服务器需要注意的其他几个方面: 池、数据复制、上下文切换和锁。
一、池
既然服务器的硬件资源“充裕”,那么提高服务器性能的一个很直接的方法就是以空间换时间,即“浪费”服务器的硬件资源,以换取其运行效率。这就是池(pool)的概念。池是一组资源的集合,这组源在服务器启动之初就被完全创建好并初始化,这称为静态资源分配。当服务器进入正式运行阶段,即开始处理客户请求的时候,如果它需要相关的资源,就可以直接从池中获取,无须动态分配。很显然,直接从池中取得所需资源比动态分配资源的速度要快得多,因为分配系统资源的系统调用都是很耗时的。当服务器处理完一个客户连接后,可以把相关的资源放回池中,无须执行系统调用来释放资源。从最终的效果来看,池相当于服务器管理系统资源的应用层设施,它避免了服务器对内核的频繁访问。
不过,既然池中的资源是预先静态分配的,我们就无法预期应该分配多少资源。这个问题又该如何解决呢?最简单的解决方案就是分配“足够多”的资源,即针对每个可能的客户连接都分配必要的资源。这通常会导致资源的浪费,因为任一时刻的客户数量都可能远远没有达到服务器能支持的最大客户数量。好在这种资源的浪费对服务器来说一般不会构成问题。还有一种解决方案是预先分配一定的资源,此后如果发现资源不够用,就再动态分配一些并加入池中。 根据不同的资源类型,池可分为多种,常见的有内存池、进程池、线程池和连接池:
- 内存池通常用于 socket 的接收缓存和发送缓存。对于某些长度有限的客户请求,比如 HTTP 请求,预先分配一个大小足够(比如 5000 节)的接收缓存区是很合理的。当客户请求的长度超过接收缓冲区的大小时,可以选择丢弃请求或者动态扩大接收缓冲区;
- 进程池和线程池都是并发编程常用的“伎俩”。当需要一个工作进程或工作线程来处理新到来的客户请求时,可以直接从进程 池或线程池中取得一个执行实体,而无须动态地调用 fork 或 pthread_create 等函数来创建进程和线程。
- 连接池通常用于服务器或服务器机群的内部永久连接。每个逻辑单元可能都需要频繁地访问本地的某个数据库。简单的做法 是:
- 逻辑单元每次需要访问数据库的时候,就向数据库程序发起连接,而访问完毕后释放连接。很显然这种做法的效率太低。
- 一种解决方案是使用连接池。连接池是服务器预先和数据库程序建立的一组连接的集合。当某个逻辑单元需要访问数据库时,它可以直接从连接 池中取得一个连接的实体并使用之。待完成数据库的访问之后,逻辑单元再将该连接返还给连接池。
2. 数据复制
高性能服务器应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候。如果内核可以直接处理从 socket 或者文件读入的数据,则应用程序就没必要将这些数据从内核缓冲区制到应用程序缓冲区中。这里说的“直接处理”指的是应用程序不关心这些数据的内容,不需要对它们做任何分析。比如 ftp 服务器,当客户请求一个文件时,服务器只需要检测目标文件是否存在,以及客户是否有读取它的权限,而绝对不会关心文件的具体内容。这样的话,ftp 服务器就无须把目标文件的内容完整地读入到应用程序缓冲区中并调用 send 函数来发送,而是可以使用“零拷贝”函数 sendfile 来直接将其发送给客户端。
此外,用户代码内部(不访问内核)的数据复制也是应该避免的。举例来说,当两个工作进程之间要传递大量的数据时,就应该考虑使用共享内存来在它们之间直接共享这些数据,而不是使用管道或者消息队列来传递。
3. 上下文切换和锁
并发程序必须考虑上下文切换(context switch)的问题,即进程切换或线程切换导致的的系统开销。即使是 I/O 密集型的服务器,也不应该使用过多的工作线程(或工作进程),否则线程间的切换将占用大量的 CPU 时间,服务器真正用于处理业务逻辑的 CPU 的时间比重就显得不足了。因此,为每个客户连接都创建一个工作线程的服务器模型是不可取的。图 8 - 11 所描述的半同步/半异步模式是一种比较合理的解决方案,它允许一个线程同时处理多个客户连接。此外,多线程服务器的一个优点是不同的线程可以同时运行在不同的 CPU 上。 当线程的数量不大于 CPU 的数目时,上下文的切换就不是问题了。
并发程序需要考虑的另外一个问题是共享资源的加锁保护。锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。因此,服务器如果有更好的解决方案,就应该避免使用锁。显然,图 8 - 11 所描述的半同步/半异步模式就比图 8 - 10 所描述的半同步/半反应堆模式的效率高。如果服务器必须使用“锁”,则可以考虑减小锁的粒度,比如使用读写锁。当所有工作线程都只读取一块共享内存的内容时,读写锁并不会增加系统的额外开销。只有当其中某一个工作线程需要写这块内存时,系统才必须去锁住这块区域。