第八章、 高性能服务器程序框架
服务器模型
第八章为全书核心,本章将介绍如下三个主要模块:
- I/O 处理单元。将介绍 I/O 处理单元的四种 I/O 模型和两种高效时间处理模式
- 逻辑单元。将介绍逻辑单元的两种高效并发模式,以及高效的逻辑处理方式----有限状态机
- 存储单元。服务器程序的可选模块,内容与网络编程本身无关
C/S 模型
C/S 模型的 TCP 服务器和 TCP 客户端工作流程如图
C/S 模型逻辑很简单。服务器启动后,收件创建一个 (或多个) 监听 socket
, 并调用 bind
函数将其绑定到服务器感兴趣的端口上,然后调用 listen
函数等待客户连接。服务器稳定运行之后,客户端就可以调用 connect
函数向服务器发起连接了。由于客户连接请求是随机到达的 异步 时间,服务器需要使用某种 I/O 模型来监听这一事件。I/O 模型有多种,上图服务器使用的是 I/O 复用计数之一的 select
系统调用。当监听到连接请求后,服务器就调用 accept
函数接受它,并分配一个逻辑单元为新的连接服务。逻辑单元可以是新创建的子进程、子线程或者其他。上图中,服务器给客户端分配的逻辑单元是由 fork
系统调用创建的子进程。逻辑单元读取客户请求,处理该请求,然后将处理结果返回给客户端。客户端接收到服务器反馈的结果之后,可以继续向服务器发送请求,也可以立即主动关闭连接。如果客户端主动关闭连接,则服务器执行被动关闭。至此,双方的通信结束。需要注意的是,服务器在处理一个客户请求的同时还会继续监听其他客户请求,否则就变成效率低下的穿行服务器了(必须先处理完前一个客户的请求,才能继续处理下一个客户的请求)。途中服务器同时监听多个客户请求是通过 select
系统调用实现的。
p2p模型
Peer to Peer
点对点,比 C/S 模型更符合网络通信的实际情况。
P2P 使得每台服务器在小号服务的同时也在给别人提供服务,这样资源能够充分、自由地共享。云计算群可以看成 P2P 模型的一个典范,但缺点是:当用户之间的传输请求过多时,网络的负载将加重。
左(a)图存在一个显著的问题,即主机之间很难互相发现。所以实际使用的 P2P 模型通常带有一个专门的发现服务器,右(b)图。这个发现服务器通常还停工查找服务(甚至内容服务),使每个客户都能尽快地找到自己需要的资源
P2P编程角度上可以是 C/S 模型的扩展:每台主机既是客户端,又是服务器。
服务器编程框架
该图既能用来描述一台服务器,也能用来描述一个服务器机群。
模块 | 单个服务器程序 | 服务器机群 |
---|---|---|
I/O处理单元 | 处理客户链接,读写网络数据 | 作为连接服务器,实现负载均衡 |
逻辑单元 | 业务进程或线程 | 逻辑服务器 |
网络存储单元 | 本地数据库、文件或缓存 | 数据库服务器 |
请求队列 | 各单元之间的通信方式 | 各服务器之间的永久 TCP 连接 |
- I/O 处理单元是服务器管理客户连接的模块。完成连接和接收客户连接,数据,发送数据等工作。但手法不一定在 I/O 处理单元中执行,也可能在逻辑单元中执行
- 一个逻辑单元通常是一个进程或者线程。一个逻辑单元本身就是一台逻辑服务器。服务器通常拥有多个逻辑单元,以实现对多个客户任务的并行处理
- 网络存储单元可以是数据库、缓存和文件,甚至是一台独立的服务器
- 请求队列是各单元之间通信方式的抽象。
I /O 模型
socket
在创建的时候默认是阻塞的。可以通过 socket
系统调用的第 2 个参数传递 SOCK_NONBLOCK
标志,或者通过 fcntl
系统调用的 F_SETFL
命令,将其设置为非阻塞的。
针对阻塞 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 复用和 SIGIO 信号。
- I/O复用 应用程序通过 I/O 复用函数向内核注册一组事件,内核通过 I/O 复用函数把其中就绪的事件通知给应用程序。
Linux
上常用的 I/O 复用函数是select、poll
和epoll_wait
。需要指出的是,I/O
复用函数本身是阻塞的,他们能提高程序效率的原因在于它们具有同时监听多个 I/O 事件的能力。 - SIGIO 为一个目标文件描述符指定宿主进程,那么宿主进程将捕获到 SIGIO 信号。当目标文件描述符上由事件发生时, SIGIO 信号的信号处理函数将被出发。
理论上,阻塞 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 | 内核执行读写操作并触发读写完成事件。程序没有阻塞阶段 |
查看资料后,上表三个都属于同步,其实都会阻塞到一个地方
blocking IO 会一直阻塞用户进程知道操作完成
non-blocking IO 在 kernel
还准备数据的情况下立刻返回,执行别得操作,直到 kernel
给出得数据已准备好得信号,再执行这个操作。
同步IO和异步IO的区别在于:
同步IO在做IO操作的时候将 process
阻塞
异步不一样,当进程发起 I/O 操作后,就不管了,直到 kernel
发送一个信号,告诉进程说 I/O 完成。在这整个过程中,进程完全没有被阻塞
两种高效的事件处理模式
服务器程序通常需要处理三类事件: I/O 事件、信号及定时事件。这一节主要介绍:
- Reactor
- Proactor
同步用于实现 Reactor
模式,异步用于实现 Proactor
模式。
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
模式中,没必要区分所谓的“读工作线程”和“写工作线程”
Proactor模式
与 Reactor 模式不同, Proactor 模式将所有的 I/O 操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。因此, Proactor 模式更符合图 8-4 所描述的服务器编程框架。
使用异步 I/O 模型 ( 以 aio_read
和aio_write
为例 ) 实现的 Proactor 模式的工作流程是:
1. 主线程调用 aio_read 函数向内核注册`socket`上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序
2. 主线程继续处理其他的逻辑
3. 当socket 上的数据被读入用户缓冲区后,内核将向引用程序发送一个信号,以通知应用程序数据已经可用
4. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。
5. 工作线程处理完客户请求之后,调用`aio_write`函数向内核注册`socket`上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序
6. 主线程继续处理其他逻辑
7. 当用户缓冲区被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序的数据已经发送完毕
8. 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭`socket`
如图,连接socket
上的读写事件是通过aio_read/aio_write
向内核注册的,因此内核将通过信号来向应用程序报告连接socket
上的读写事件。所以,主线程中的epoll_wait
调用仅能用来检测监听socket
上的连接请求时间,而不能用来检测连接socket
上的读写事件
模拟 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
上写入服务器处理客户请求的结果
粗浅的理解:这里的模拟差不多是让主线程在监听的同时要I/O把事件完成,让工作线程认为已经得到了数据读写结果,只需要对读写结果进行处理,让内核完成的事件找主线程完成。
两种高效的并发模式
如果程序是计算密集型(CPU密集)的,并发编程并没有优势,反而由于任务的切换使得效率降低。但如果程序是 I/O 密集的,比如经常读写文件,访问数据库等,则情况就不同了。如果程序有多个执行线程,则当前被 I/O操作所阻塞的执行线程可主动放弃 CPU ( 或由操作系统来调度 ),并将执行权转移到其他线程。
从实现上来说,并发编程主要由多进程和多进程两种模式。这一节先讨论并发模式
半同步/半异步模式
这里的”同步“和”异步“与之前讨论的 I/O 中同步异步的概念完全不同。在 I/O 模型中同步异步区分的是内核向应用程序通知的是就绪时间还是完成时间的 I/O 事件,以及该有谁来完成 I/O 读写(是应用程序还是内核)。在并发模式中,”同步“指的是程序完全按照代码序列的顺序执行;”异步“指的是程序的执行需要由系统时间来驱动。常见的系统时间包括中断、信号等。
按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。
在服务器程序中,半同步/半异步模型存在多种变体,其中一种被称为半同步/半反应堆( half-sync/half-reactive
)模式
上图中,异步线程只有一个,由主线程来当。它负责监听所有socket
上的事件。如果监听socket
上有可读事件发生,即有新的连接请求到来,主线程就接受得到新的连接socket
,然后往epoll
内核事件表中注册该socket
上的读写事件。如果连接socket
上有读写事件发生,主线程将该连接socket
插入到请求队列中。所有工作线程都睡眠在请求队列上,当有任务到来时,它们将通过竞争获得任务管理权。该模式采用的事件处理模式是 Reactor 模式:要求工作线程自己从socket
上读取客户请求和往socket
写入服务器应答。
该模式存在如下缺点:
- 主线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费CPU事件
- 每个工作线程在同一时间只能处理一个客户请求。如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的相应速度将越来越慢。如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量的 CPU 时间
上图中是更高效的半同步/半异步模式,他的每个线程都能同时处理多个客户连接
主线程只管监听socket
,连接socekt
由工作线程来管理。当有新的连接到来时,主线程就接受并将新返回的连接socket
派发给某个工作线程,此后该新socket
上任何 I/O 操作都由被选中的工作线程来处理,直到客户关闭连接。
上图中,每个线程都维持自己的事件循环,它们各自独立地监听不同地时间。
领导者/追随者模式
多个工作线程轮流获得事件源集合,轮流监听,轮流称为领导者、分发并处理事件。
领导者/追随者模式包含如下几个组件:句柄集(HandleSet)、线程集(ThreadSet)、事件处理器(EventHandler)和具体地事件处理器(ConcreteEventHandler)。如下图
-
句柄集
句柄 ( Handle ) 用于表示 I/O 资源,在 Linux 下通常就是一个文件描述符。句柄集管理众多句柄,它使用
wait_for_event
方法来监听这些句柄上的 I/O 事件,并将其中的就绪事件通知给领导者线程。领导者则调用绑定到Handle
上的事件处理器来处理事件。领导者将Handle
和事件处理器绑定是通过调用句柄集中的register_handle
方法实现的。 -
线程集
这个组件时所有工作线程(包括领导者线程和追随者线程)的管理者。它负责各线程之间的同步,以及新领导者线程的推选。线程集中的线程在任一时间必处于如下三种状态之一:
- Leader 线程当前处理领导者身份,负责等待句柄集上的 I/O 事件
- Processing 线程正在处理事件。领导者检测到事件后,可以转移到 Processing 状态来处理该事件,并调用
promote_new_leader
方法推选新的领导者;也可以指定其他追随者来处理事件( Event Handoff), 此时领导者的地位不变。 - Follower 线程当前处于追随者身份,通过调用线程集的
join
方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务
-
事件处理器和具体的事件处理器
事件处理器通常包含一个或多个回调函数
handle_event
,用来处理时间对应业务逻辑。
由于领导者线程自己监听 I/O 并处理客户请求,因而领导者/追随者模式不需要再线程之间传递任何额外的数据,也无须像半同步/版反应堆模式那样在线程之间同步对请求队列的访问。但该模式一个明显的缺点时仅支持一个事件源集合,因此无法让每个工作线程独立地管理多个客户连接。
有限状态机
本节将讨论逻辑单元内部的一种高效变成方法:有限状态机(finite state machine)
有的应用层协议头部包含数据包类型字段,每种类型可以映射位逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑。
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;
}
}
上诉代码的状态机每个状态都是相互独立的,状态之间没有相互转移。状态之间的转移是需要状态机内部驱动的。
STATE_MACHINE( Package _pack ){
State cur_State = type_A;
switch( cur_State != type_C )
{
Package _pack = getNewPackage();
case type_A:
process_package_A( _pack );
cur_State = type_B;
break;
case type_B:
process_package_B( _pack );
cur_State = type_C;
break;
}
}
应用实例:HTTP请求的读取和分析。很多网络协议,包括 TCP 协议和 IP 协议,都在其头部中提供头部长度字段。程序根据该字段的值就可以知道是否接收到一个完整的协议头部。但 HTTP 协议并未提供这样的头部长度字段,并且其头部长度变化也很大,可以只有十几字节,也可以有上百字节。
http_read_anlysis.c
该代码中的两个有限状态机分别为主状态机和次(从)状态机,这体现了它们之间的关系:主状态机在内部调用次状态机。
- parse_line 函数,从
buffer
中解析出一个行。如图
这个状态机的初始状态是 LINE_OK
, 其原始驱动力来自于 buffer
中新到达的客户数据。在mian
函数中,我们循环调用recv
函数往buffer
中读入客户数据。每次成功读取数据后,我们就调用parse_content
函数来分析新读入的数据
-
parse_content 函数首先要做的就是调用
parse_line
函数来获取一个行。现在假设服务器经过一次recv
调用之后,buffer
的内容以及部分变量的值如图所示 -
parse_line
函数处理之后如图所示,它挨个检查上图所示的buffer
中checked_index
到(read_index - 1
)之间的字节,判断是否存在行结束符,并更新checked_index
的值。当前buffer
中不存在行结束符,所以parse_line
返回LINE_OPEN
。 -
接下来,程序继续调用
recv
以读取更多客户数据,这次读操作后buffer
中的内容以及部分变量的值如下图所示 -
然后
parse_line
函数又开始处理这部分新到来的数据,如下图所示。这次它读到了一个完整的行,即"HOST:localhost\r\n"
。此时。parse_line
函数就可以将这行内容递交给parse_content
函数中的主状态机来处理了
上面四张图分别代表
- 调用
recv
后,buffer
里的初始内容和部分变量的值 parse_line
函数处理buffer
后的结果- 再次调用
recv
后的记过 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
,从而实现状态转移。
提高服务器性能的其他建议
池
如果服务器硬件资源“充裕”,那么提高服务器性能的一个很直接的方法就是以空间换时间。即“池(poll)”的概念。当服务器处理完一个客户连接后,可以把相关的资源放回池中,无须执行系统调用来释放资源。从最终的效果来看,池相当于服务器管理系统资源的应用层设置,它避免了服务器堆内核的频繁访问。
根据不同资源类型,池可分为很多种,常见的有
-
内存池
通常用于
socket
的连接缓存和发送缓存。对于某些长度有限得客户请求,比如 HTTP 请求,预先分配一个大小足够(比如 5000 字节) 的接收缓存区是很合理的。当客户请求的长度超过接收缓冲区的大小时,我们可以选择丢弃请求或者动态扩大接收缓冲区 -
线程池和进程池
都是并发编程常用的方法。当需要一个工作进程或者工作线程来处理新到来的客户请求时,我们可以直接从进程池或者线程池中取一个执行实体,而无需动态地调用
fork
或pthread_create
等函数来创建进程和线程 -
连接池
长用于服务器或者服务器机群的内部永久连接。
上下文切换和锁
并发程序必须考虑上下文切换(context switch)的问题,即进程切换或者线程切换导致的系统开销。
并发程序需要考虑的另一个问题时共享资源的加锁保护。锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。
如果服务器必须使用“锁”,则可以考虑减小锁的粒度,比如使用读写锁。当所有工作线程都制度一块共享内存的内容时,读写锁不会增加系统的额外考校。只有当其中某一个工作线程需要写这块内存时,系统才必须去锁住这块区域。