Linux高性能服务器编程第八章高性能服务器程序框架

这一章是全书的核心,也是后续章节的总览,在这一章节中,我们将服务器解构为如下三个主要模块:

  • I/O处理单元 四种I/O模型和两种高效事件处理模式
  • 逻辑单元 本章将介绍逻辑单元的两种高效并发模式以及高效的逻辑处理方式:有限状态机
  • 存储单元 本章不讨论存储单元

服务器模型

C/S模型

TCP/IP协议在设计和实现上并没有客户端和服务端的概念,在通信过程中所有机器都是对等的,由于资源都被资源提供者垄断,所以所有网络应用几乎都很自然的采用了C/S模型,如下图所示:

image-20231108102649413

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

image-20231108104127252

P2P模型

P2P (Peer to Peer,点对点)模型比C/S模型更符合网络通信的实际情况。它摒弃了以服务器为中心的格局,让网络上所有主机重新回归对等的地位。
P2P模型使得每台机器在消耗服务的同时也给别人提供服务,这样资源能够充分、自由地共享。云计算机群可以看作P2P模型的一个典范。但P2P模型的缺点也很明显:当用户之间传输的请求过多时,网络的负载将加重。

服务器编程框架

本章节先讨论基本框架,如下图所示:
image-20231108104330293

  • IO处理单元

    处理客户连接读写网络数据

  • 逻辑单元

    业务进程或线程

  • 网络存储单元

    本地数据库文件或缓存

  • 请求队列

    各单元之间的通信方式

I/O模型

第五章讲到socket在创建的时候默认是阻塞的,阻塞的文件描述符叫阻塞I/O,不阻塞的叫非阻塞I/O

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

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

I/O复用是最常使用的I/O通知机制,他指的是应用程序通过I/O复用函数向内核注册一组事件,内核通过I/O复用函数把其中就绪的时间通知给应用程序。

Linux中常用的I/O复用函数有select、poll和epoll_wait

两种高效的事件处理模式

服务器程序通常需要处理三类事情,IO事件,信号和定时事件,这一章节介绍一下两种高效的事件处理模式:Reactor和proactor

Reactor

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

  1. 主线程往epoll内核事件表注册socket读就绪事件
  2. 主线程调用epoll_wait等待socket上有数据可读
  3. 当socket上有数据可读时,epoll_wait通知主线程,主线程将socket可读事件放入请求队列
  4. 睡眠在请求队列的某个工作进程被唤醒,从socket读取数据,并处理客户请求,往epoll内核事件注册写就绪事件
  5. 主线程调用epoll_wait等待socket可写
  6. 通知主线程,主线程将socket可写事件放入请求队列
  7. 睡眠在请求队列上的某个工作进程被唤醒,往socket上写入服务器处理客户请求的结果

下图总结了工作流程:

image-20231108111012269

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

image-20231108112501562

有限状态机

前面两节探讨的是服务器的I/O处理单元、请求队列和逻辑单元之间协调完成任务的各种模式,这一节我们介绍逻辑单元内部的一种高效编程方法:有限状态机(finite statemachine)。

  • 带状态转移的有限状态机

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    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;
    }
    }
    }

其他建议

既然服务器的硬件资源“充裕”,那么提高服务器性能的一个很直接的方法就是以空间换时间,即“浪费”服务器的硬件资源,以换取其运行效率。这就是池(pool)的概念。池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源分配。当服务器进入正式运行阶段,即开始处理客户请求的时候,如果它需要相关的资源,就可以直接从池中获取,无须动态分配。很显然,直接从池中取得所需资源比动态分配资源的速度要快得多,因为分配系统资源的系统调用都是很耗时的。当服务器处理完一个客户连接后,可以把相关的资源放回池中,无须执行系统调用来释放资源。从最终的效果来看,池相当于服务器管理系统资源的应用层设施,它避免了服务器对内核的频繁访问。

比如内存池、线程池

数据复制

高性能的服务器应该避免不必要的数据复制

上下文切换和锁

半同步/半异步的模式是一个比较合理的解决方法

对于锁,我们认为锁是导致服务器效率低下的一个原因,如果有更好的方法尽量避免使用锁。
如果要使用锁,那么可以考虑减小锁的粒度,比如读写锁。