Socket编程(五)

高性能服务器程序框架

Posted by Gavin on December 3, 2019

但使主人能醉客

不知何处是他乡

前言

这是服务器程序的总览,服务器一般可解构为如下三个主要模块:

  • I/O处理单元(四种I/O模型,两种高效事件处理模式)
  • 逻辑单元(两种高并发模式,有限状态机)
  • 存储单元

服务器模型

  • C/S模型

    TCP/IP协议在设计和实现上并没有区分服务端和客户端,在通信过程中每个机器都是对等的。但是由于资源一般是被垄断的,因此几乎所有网络程序都采用了C/S模型(区块链就不是):所有客户端通过访问服务端来获取所需的资源。
    C/S模型的逻辑很简单,服务器启动后,首先创建一个或多个监听socket,并调用bind函数将其绑定到服务器相应的端口上,然后调用listen函数等待用户连接,服务器运行稳定后,客户端就可以调用connect函数向服务器发起连接了。由于客户请求是随机到达的异步事件,服务器需要某种I/O模型来监听这一事件。I/O模型有很多,下图采用的I/O复用技术是select系统调用。当监听到连接请求后,服务器调用accept函数接受它,并分配一个逻辑单元为新的连接服务,逻辑单元可以是新创建的子进程、子线程或其他。下图中服务器给客户端分配的逻辑单元是由fork系统调用创建的子进程。逻辑单元读取客户请求,处理请求,将结果返回给客户端。
    PS:服务器同时监听多个客户请求是通过select系统调用实现的

  • P2P模型

    P2P模型摈弃了服务端的中心地位,让所有主机回归对等地位,使得每台机器在消耗服务的同时也在给别人提供服务。但是P2P模型有一个很麻烦的问题,主机之间难以互相发现,因此P2P模型通常会带有一个专门的发现服务器,提供发现目标主机或服务的功能。

服务器编程框架

虽然服务器应用架构繁多,但抽象起来看,框架大多一样,不同之处在于逻辑处理单元。

上图既可描述单机,也可描述集群

模块 单机 集群
I/O处理单元 处理客户连接,读写网络程序 接入服务器,负载均衡
逻辑单元 业务进程或线程 逻辑服务器
网络存储单元 本地数据库、文件或缓存 数据库服务器
请求队列 各单元之间通信方式 各服务器之间连接

I/O模型

socket创建时默认是阻塞的,我们可以将其设置为非阻塞的。阻塞和非阻塞的概念能对应所有的文件描述符,我们将阻塞的文件描述符称为阻塞I/O,非阻塞的叫非阻塞I/O

针对阻塞I/O执行的系统调用可能因为无法立即完成而被操作系统挂起,直到等待的事件发生。可能被阻塞的系统调用有:accept、send、recv、connect。

针对非阻塞I/O执行的系统调用则总是立即返回,而不管事件是否已经发生。如果事件没有立即发生,就返回-1,因此我们需要根据errno来区分这种情况,对accept、send、recv来说,事件未发生时errno通常被设置为EAGAN(再来一次)或者EWOULDBLOCK(期望阻塞);对connect来说,errno则是EINPROGRESS(在处理中)。

显然,我们只有在事件已经发生的情况下操作非阻塞I/O(读、写)才能提高程序的效率,因此,非阻塞I/O通常要和其他通知机制一起使用,比如I/O复用和SIGIO信号。

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

PS:I/O复用函数本身是阻塞的,它们能提高程序效率在于它们具有同时监听多个I/O事件的能力

SIGIO信号也可用于报告I/O事件,我们可以为一个目标文件描述符指定宿主进程,被指定的宿主进程将捕获到SIGIO信号,那么当目标文件描述符上有事件发生时,SIGIO信号的信号处理函数将被触发,我们也就可以在信号处理函数中对目标文件描述符进行非阻塞I/O操作了。

PS:阻塞I/O、非阻塞I/O、信号驱动I/O都是同步I/O模型

I/O模型 特点
阻塞I/O 程序阻塞于读写函数
I/O复用 程序阻塞于I/O复用系统调用,但可以同时监听多个I/O事件,对I/O本身的读写操作是非阻塞的
SIGIO 信号触发读写就绪事件,用户程序执行读写操作,程序没有阻塞阶段
异步I/O 内核执行读写操作并触发读写完成事件,程序没有阻塞阶段

事件处理模式

  • 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

高效并发模式

  • 半同步/半异步模式 同步线程用于处理客户逻辑,异步线程用于处理I/O事件。异步线程监听到客户请求后,将其封装成请求对象并插入请求队列,请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。
  • 领导者/追随者模式
    多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意事件点,程序都仅有一个领导者线程,负责监听I/O事件,其他线程都是追随者,休眠在线程池中等待成为新领导者。当前领导者检测到I/O事件,首先从线程池中推选出新的领导者线程,然后处理I/O事件,此时新的领导者监听I/O事件,原来的领导者处理I/O事件,实现了并发。

池是一组资源的合集,在服务器启动之初就被创建好并初始化,即静态资源分配。当服务器进入正式运行阶段,也就是开始处理用户请求的时候,如果需要资源,直接从池中取得,无须动态分配。

  • 内存池
    用于socket的接收缓存的发送缓存,对于某些长度有限的客户请求,比如HTTP,预先分配一个大小足够的接收缓存很合理。当客户请求长度超过接收缓冲区大小时,可以丢弃请求或者动态扩大接收缓冲区
  • 进程/线程池
    无须动态调用fork或pthread_create
  • 连接池
    连接池是服务器预先和数据库建立的一组连接的集合,需要使用时,直接取得一个连接实体即可