Linux高性能服务器编程阅读2:第8章核心框架

发布时间 2023-08-29 16:07:53作者: zxkic

服务器基础框架:

I/O处理单元:处理客户连接,读写网络数据

逻辑单元:业务进程或线程,分析并处理客户数据,将结果传递给I/O处理单元或者客户端。

网络存储单元:本地数据库、文件或缓存

队列:各单元之间的通信方式的抽象。

I/O模型:

socket 创建的时候默认是阻塞的,可以传递参数设置成非阻塞。

针对阻塞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,我们将在第9章详细讨论它们。需要指出的是,I/O复用函数本身是阻塞的,它们能提高程序效率的原因在于它们具有同时监听多个I/O事件的能力。

SIGIO信号也能用来报告I/O事件。当目标文件描述符上有事件发生时,SIGIO信号的信号处理函数被触发,那么我们就可以在SIGIO的信号处理函数中对目标文件描述符进行非阻塞I/O操作。

阻塞I/O,I/O复用和SIGIO信号都是同步I/O。同步I/O要求用户代码自行执行I/O操作,但是异步I/O由系统内核执行I/O操作。

 

事件处理模式:

服务器中有三种事件:I/O事件,信号及定时事件。

存在两种事件处理方式:Reactor和Proactor

Reactor:

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

以epoll_wait()为例:

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

 Proactor:

异步 I/O 模型则用于实现 Proactor 模式。与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。因此,Proactor模式更符合图8-4所描述的服务器编程框架。

  1. 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例,详情请参考sigevent的man手册)。
  2. 主线程继续处理其他逻辑。
  3. 当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
  4. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序(仍然以信号为例)。
  5. 主线程继续处理其他逻辑。
  6. 当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
  7. 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。

  主线程中的epoll_wait()仅用于监听socket上的事件,而不能检测socket上的读写事件。

  

 模拟Proactor:

  • 使用同步方式模拟 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上写入服务器处理客户请求的结果。

并发模式

  并发模式指的是I/O处理单元和多个逻辑单元之间协调完成任务的方法。

  半同步/半异步和领导者/追随者模式

  在I/O模型中,同步和异步区分的是内核向应用程序通知的是哪种类型的I/O事件,以及由谁来完成I/O读写(内核还是应用程序)

  但是在并发模式中,同步指的是程序完全按照代码顺序执行,异步指程序的执行需要由事件驱动,如中断,信号

  半同步/半异步模式:同步线程用来处理客户逻辑,异步线程处理I/O事件,异步线程监听到客户请求之后,将其封装成请求对象,放到请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。

   变体:半同步/半反应堆模式,主线程为异步线程,其他工作线程为同步线程,睡眠在请求队列上,通过竞争获得任务的接管权。

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

  该模式包含句柄集(HandleSet),线程集(ThreadSet),事件处理器(EventHandler)和具体的事件处理器(ConcreteEventHandler)

  Handle表示I/O资源,在linux下通常为文件描述符。句柄集采用wait_for_event()来监听这些句柄上的I/O事件。

  事件处理器包含多个回调处理函数,用于处理业务对应的事件逻辑。

  

 

  

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

  示例:HTTP请求的读取和分析。

 

    HTTP头部结束的标志是遇到连续的\r\n(一对回车,换行符)

 

    主状态机有两种:正在分析请求行,正在分析头部。

    从状态机为行的读取状态,有三种:读取到完整的一行,行出错和行数据不完整。

    HTTP_CODE为服务器处理HTTP请求的结果:请求不完整、获得完整的HTTP请求、客户请求有语法错误、客户没有访问权限、服务器内部错误、客户端已经关闭连接。

 

提高服务器性能的建议

  • 池(pool):空间换时间,预先创建并且初始化资源。内存池、进程池、线程池、连接池
  • 用户复制:减少不必要的数据复制,尤其是在用户代码和内核之间,零拷贝函数。工作进程之间应该考虑共享内存而不是管道或者消息队列。
  • 上下文切换和锁:进程或者线程切换开销;共享资源加锁保护导致服务器效率低下(减小锁的粒度)