0%

高性能服务器程序框架

网络编程基础(5):高性能服务器程序框架

  • I/O处理单元,四种I/O模型和两种高效事件处理模式
  • 逻辑单元,两种高并发模式,以及高效逻辑处理方式——有限状态机
  • 池、锁的介绍

    主要内容

  • I/O处理单元,四种I/O模型和两种高效事件处理模式
  • 逻辑单元,两种高并发模式,以及高效逻辑处理方式——有限状态机

服务器模型

C/S模型

  • 服务逻辑:服务器开启后。首先创建一个或多个监听socket,之后调用bind函数绑定地址,再之后调用listen等待连接。服务器使用I/O复用技术来监听客户端请求,当监听到请求之后便分配给其一个逻辑单元去处理该请求。

  • 模式示意图

  • 模式逻辑图

  • 模型分析
    C/S模式适合资源集中性的场合,并且实现简单。但是正是由于资源相对集中,所有的客户端都和服务器直接建立连接,当访问量过大时,所有客户都将得到很慢的响应。

P2P模式

  • 服务逻辑
    每一台机器在消耗服务的同时也给别人提供服务,这样资源可以充分、自由地共享。

  • 模型示意图

原始地P2P模型,能够看到,主机之间很难确定哪一个是自己需要的。因此在一些架构中,增加了发现服务器,用来提供专门的发现服务,使得每个客户尽快找到资源。

服务器编程框架

  • 基础框架是固定的:主要包含三个部分,I/O处理单元、逻辑单元、存储单元(可选)。

  • 各单元功能描述

上述的框架既可以描述一台服务器,也可以用来描述一个服务器机群。

1
2
3
4
5
模块                单个服务器程序                  服务器机群
I/O处理单元 处理客户连接,读写网络数据 作为接入服务器,实现负载均衡
逻辑单元 业务进程或线程 逻辑服务器
网络存储单元 本地数据库、文件或缓存 数据库服务器
请求队列 各单元之间的通信方式 各服务器之间的永久TCP连接
  • I/O处理单元
    是服务器管理客户连接的模块,它负责的工作有:等待并接受新的客户连接,接受客户数据,将服务器响应数据返回给客户端。

  • 逻辑单元
    通常是一个进程或者线程,它分析并处理客户数据,然后将结果传递给I/O处理单元或者直接发送给数据端。

  • 网络存储单元
    非必须单元,依据情况选择。

  • 请求队列
    I/O处理单元接收到客户请求时,需要以某种方式来通知一个逻辑单元进行处理。同样,多个逻辑单元请求同一个存储单元的信息,也需要采用某种机制来协调处理竞态关系。请求队列通常被实现为池的一部分,后面会具体聊池这个话题。

i/o模型

  • 五种IO模型:阻塞、非阻塞、IO复用、信号驱动、异步。

    一些关键点

  • 阻塞的I/O在无法立即完成时,会被操作系统挂起,直到等待的事件发生为止。socket的基础API中,可能被阻塞的系统调用包括:accept、send、recv和connect。

  • 非阻塞式I/O执行则是系统调用后立即返回,不管事件是否发生,如果事件没有立即发生,这些系统调用就返回-1.因此,非阻塞式I/O通常配合I/O通知机制来一起使用(如I/O复用和SIGIO信号)

  • I/O复用函数是select、poll和epoll,I/O函数本身是阻塞的,其提高效率的原因在于函数可以同时监听多个I/O事件

  • 具体的信号的使用后面会具体介绍。

  • 异步部分暂不考虑

1
2
3
4
5
I/O模型                         读写操作&阻塞阶段
阻塞I/O 程序阻塞于读写函数
I/O复用 程序阻塞于I/O复用系统调用。对I/O本身的读写操作是非阻塞的。
SIGIO信号 信号触发读写就绪事件,用户程序执行读写操作,程序没有阻塞阶段
异步I/O 内核执行读写操作并触发读写完成事件,程序没有阻塞阶段。

两种高效的事件处理模式

服务器程序通常需要处理三类事件:
I/O事件、信号事件、定时事件。后续将分章节具体讨论。从整体来说,存在两种高效的事件处理模式:Reactor模式&Proactor模式。同步I/O模型通常用于实现Reactor模式。

Reactor模式

其处理逻辑是,它要求主线程只负责监听文件描述符上是否有事件发生,有的话就立即通知工作线程,不做任何实质性工作。

工作流程分析

  • 主线程往epoll内核事件表中注册socket上的读就绪事件
  • 主线程调用epoll_wait等待socket上有数据可读
  • 当socket上有数据可读时,epoll_wait通知主线程,主线程则将socket可读事件放入请求队列
  • 睡眠在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件
  • 主线程调用epoll_wait等待socket可写
  • 当socket可写时,epoll_wait通知主线程,主线程将socket可写事件放入请求队列
  • 睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果

Proactor模式

其处理逻辑,主线程和内核来处理所有的I/O操作,工作线程仅仅负责业务逻辑。

工作流程分析

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

模拟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模型里面的同步异步

  • I/O中同步异步是以内核向应用程序通知的是哪种I/O事件(是就绪事件还是完成事件),以及该由谁完成I/O读写(是内核还是应用程序)。
  • 并发模式中,”同步“是指程序完全按照代码顺序执行;”异步“是指程序的执行需要由系统事件来驱动。常见的系统事件包括:中断、信号等。

图示如下

按照同步方式运行的线程称为同步线程,异步方式运行的称为异步线程。明显异步线程无阻塞,执行效率高。但编写异步线程难度大,调试复杂。因此服务器的开发,同时采用同步线程和异步线程来实现,即半同步/半异步模式。

具体方案

半同步/半异步模式中,同步线程用于处理客户逻辑,充当逻辑单元的实现方案。异步线程用于处理I/O事件,充当I/O处理单元的实现方案。异步线程监听到客户端请求后,就将其封装成请求对象并插入进请求队列中,请求队列将通知某个工作在同步模式下的工作线程来读取并处理该请求对象。

在实际的使用中半同步/半异步模式存在多种变体。其中一种变体称为半同步/半反应堆模式,如下图所示

工作流程

  • 异步线程只有一个,由主线程来充当。它负责监听所有的socket上的事件。
  • 如果监听socket上有可读事件发送,即有新的连接请求到来,主线程就接受之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件。
  • 如果监听到socket上有读写事件发生,即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接socket插入请求队列中。所有工作线程都睡眠在请求队列上,当有任务到来时,他们将通过竞争来获得任务的接管权。

分析这种模式可以看出,主线程插入的请求队列的是就绪的连接socket,这就要求工作线程自己从socket上读取客户请求和往socket写入服务器应答。所以事件处理模式的角度看:这是Reactor模式。

这种模式的问题在于

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

为进一步提高并发效率,提出一种更为高效的半同步/半异步模式

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

领导者/追随者模式

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

领导者/追随者模式包含如下几个组件:句柄集、线程集、事件处理器、具体的事件处理器

  • 句柄集:管理众多句柄(句柄:用于表示I/O资源,Linux下通常就是一个文件描述符)。它使用wait_for_event来监听这些句柄上的I/O事件,并将其中的就绪事件通知给领导者线程。领导者对事件进行处理。

  • 线程集:这个组件是所有工作线程(包括领导者线程和追随者线程)的管理者。它负责个线程之间的同步,以及新的领导者线程的推选。线程集中的线程在任一时间必处于如下三种状态之一:

    Leader:线程当前处于领导者身份,负责等待句柄集上的I/O事件。

    Processing:线程正在处理事件。领导者检测到I/O事件之后,可以转移到Processing状态来处理该事件,并调用promote_new_leader方法推选新的领导者;也可以指定其他追随者来处理事件,此时领导者的地位不变。当处于Processing状态的线程处理完事件之后,如果当前线程集没有领导者,则它将成为新的领导者,否则它就直接转变为追随者。

    Follower:线程当前处于追随者的身份,通过调用线程集的join方法等待成为新的领导者,也可以被当前的领导者指定来处理新的任务。

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

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

有限状态机

有限状态机是逻辑单元内部的一种高效编程方法。

通过代码来解析这个概念:

状态独立的有限状态机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
STATE_MACHINE(Package _pack)
{
PackageType _type = _pack.GetType();

switch(_type)
{
case type_A:
process_process_A(_pack);
break;
case type_B:
process_process_B(_pack);
break;
}
}

/*
A与B这两种状态之间互相独立,状态之间没有相互转移。
*/

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
STATE_MACHINE()
{
State Cur_State = type_A;

while(Cur_State != type_c)
{
Package _pack = GetNewPackage();

switch(Cur_State)
{
case type_A:
process_package_A(_pack);
Cur_State = type_B;
break;
case type_B:
process_package_B(_pack);
Cur_State = type_C;
break;
}
}
}
/*
该状态机包含三个状态,中间有状态的自动转移。
*/

其他

  • 关于池

常用到的池有:内存池、进程池、线程池、连接池。

  • 其中,内存池通常用于socket的接收缓存和发送缓冲。

  • 进程池和线程池则是并发编程的常用技法,提前在池中放置若干进程或线程,需要使用时,直接取用。

  • 连接池通常用于服务器或者服务器机群的内部永久连接。例如,每一个逻辑单元都可能频繁访问本地某个数据库,为了提高效率,可以考虑使用连接池。

  • 关于数据复制

    主要针对的情形:用户代码和内核之间,如果可以直接对socket或者文件读入的数据进行内核上的处理,就可以避免复制,直接采用”零拷贝“函数sendfile来提高效率。

    若两个进程之间需要传递大量的数据时应该优先考虑共享内存,尽可能避免管道和消息队列来传递。

  • 关于上下文切换和锁

    并发程序必须考虑上下文切换的问题,及其导致的系统开销。即便是I/O密集型的服务器,也不应该使用过多的工作线程。因此多考虑I/O复用,一个线程同时处理多个客户连接。

    共享资源的加锁,锁通常是导致服务器效率低下的一个因素。如果服务器有更好的解决方案,就应该避免使用锁。如果服务器必须使用锁,那么则可以考虑锁的粒度,比如使用读写锁。当所有工作线程都只读取一块共享内存的内容时,读写锁并不会增加额外开销,只有当其中某一个工作线程需要写这块内存时,系统才会去上锁。