简介:本系列文章参考游双大佬的《Linux高性能服务器编程》一书编写(一本十分好的书,强烈推荐购买),写此系列文章的目的就是当作是此书的读书笔记。由于本人水平有限(准备以此项目入门网络编程),文章中难免会有错误,欢迎批评指正。
注意:此项目用C++编写,如果图片看不太清可以在页面顶部右边开启白天模式。
此章内容为编写高性能服务器需要了解的基本知识和本服务器的框架。
服务器框架
服务器程序种类繁多,但是基本框架都一样,不同之处在于逻辑处理。
服务器基本框架,如下图所示。该图既能用来描述一台服务器,也能用来描述一个服务器机群。
I/O处理单元
I/O处理单元是服务器管理客户端连接的模块。它通常完成以下工作:
- 等待并接受新的客户链接。
- 接收客户数据。
- 将服务器响应的数据返回给客户端。
注意: 数据的收发不一定在I/O处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式。
对于一个服务器机群来说,I/O处理单元是一个专门的接入服务器。它实现负载均衡,从所有逻辑服务器中选取负荷最小的一台来为新客户服务。
逻辑单元
一个逻辑单元通常是一个进程或线程。它分析并处理客户数据,然后将结果传递给I/O处理单元或者直接发送给客户端(具体使用哪种方式取决于事件处理模式)。
对服务器机群而言,一个逻辑单元本身就是一台逻辑服务器。服务器通常拥有多个逻辑单元, 以实现对多个客户任务的并行处理。
网络存储单元
网络存储单元可以是数据库、缓存和文件,甚至是一台独立的服务器。
它不是必须的,例如:ssh、telnet等登录服务器就不需要这个单元。
请求队列
请求队列是各个单元之间通信方式的抽象。I/O处理单元接收到客户请求时,需要以某种方式来通知一个逻辑单元来处理该请求。同样,多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来协调处理竞态条件。请求队列通常被实现为池的一部分。
对于服务器机群而言,请求队列是各台服务器之间预先建立的、静态的、永久的TCP链接。这种TCP连接能提高服务器之间交换数据的效率,因为它避免了动态建立TCP导致的额外系统开销。
各个模块总结
I/O模型
I/O模型对比
I/O模型 | 读写操作和阻塞阶段 |
---|---|
阻塞I/O | 程序阻塞于读写函数 |
I/O复用 | 程序阻塞于I/O复用系统调用,但可以同时监听多个I/O事件。对I/O本身的读写操作是非阻塞的 |
SIGIO信号 | 信号触发读写就绪事件,用户程序执行读写操作。程序没有阻塞阶段 |
异步I/O | 内核执行读写操作并触发读写完成事件。程序没有阻塞阶段 |
两种高效的事件处理模式
服务器程序通常需要处理三类事件:I/O事件,信号及定时事件。有两种高效的事件处理模式。
Reactor模式
要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生(可读、可写),若有,则立即通知工作线程(逻辑单元),将socket可读可写事件放入请求队列,交给工作线程处理。
使用同步I/O模型(以epoll_wait为例)实现的Reactor模式的工作流程如下:
- 主线程往epoll内核事件表中注册socket上的读就绪事件。(监听socket与连接socket成功建立连接后,以下socket都指的是连接socket)
- 主线程调用epoll_wait等待socket上有数据可读。
- 当socket上有数据可读时,epoll_wait通知主线程。主线程则将socket可读事件放入请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,它从连接socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件。
- 主线程调用epoll_wait等待scoket可写。
- 当socket可写时,epoll_wait通知主线程。主线程将socket可写事件放入请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果。
Proactor模式
将所有的I/O操作都交给主线程和内核来处理(进行读、写),工作线程仅负责处理逻辑。
使用异步I/O模型(以aio_read和aio_write为例)实现的Proactor模式的工作流程如下:
- 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例,详情请参考sigevent的man手册)。
- 主线程继续处理其它逻辑。
- 当socket上的数据被读入用户缓冲区后,内核用户向应用程序发送一个信号,以通知应用程序数据已经可用。
- 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操完成时如何通知应用程序(仍以信号为例)。
通常使用同步I/O模型(如epoll_wait)实现Reactor,使用异步I/O(如aio_read和aio_write)实现Proactor。但在此项目中,我们使用的是同步I/O模拟的Proactor事件处理模式。其原理是:主线程执行数据读写操作,读写完成后,主线程向工作线程通知这一“完成事件”。从工作线程的角度来看,它们直接就获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。
使用同步I/O模型(仍以epoll_wait为例)模拟出的Proactor模式的工作流程如下(其中socket为连接socket):
- 主线程往epoll内核事件表中注册socket上的读就绪事件。
- 主线程调用epoll_wait等待socket上有数据可读。
- 当socket上有数据可读时,epoll_wait通知主线程。主线程从socket上循环读取数据,将读取到的数据封装成一个请求对象并插入到请求队列中。
- 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核表中注册socket上的写就绪事件。
- 主线程调用epoll_wait等待socket可读。
- 当socket可写时,epoll_wait通知主线程。主线程往socket上写入服务器处理客户请求的结果。
两种高效的并发模式
并发模式是指I/O处理单元和多个逻辑单元之间协调完成任务的方法。
半同步/半异步模式
在并发模式中的同步和异步与I/O模型中的同步异步是完全不同的概念。在并发模式中,同步指程序按照代码顺序执行;异步指程序依赖系统事件来驱动,常见的系统事件包括中断、信号等。下图描述了同步读操作(图右)和异步读操作(图左)。
按照同步(异步)方式运行的线程称为同步(异步)线程。显然,异步线程的执行效率更高,实时性强,但编写以异步方式运行的程序相对复杂,且难以调式和扩展,而且不适用于大量的并发。而同步线程虽然效率低,实时性差,但逻辑简单。因此对服务器这种既要求较好的实时性 ,又要求能同时处理多个客户请求的应用程序,就应同时采用同步线程和异步线程来实现,即采用半同步/半异步模式来实现。
在半同步/半异步模式中,同步线程用于处理客户逻辑,相当于逻辑单元;异步线程用于处理I/O事件,相当于I/O处理单元。异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列。请求队列将通知某个工作线程来读取并处理该请求对象。半同步/半异步模式的工作流程如下图所示:
考虑将两种事件处理模型,与几种I/O模型结合在一起,则半同步/半异步模式就存在多种变体。其中一种为半同步/半反应堆模式,如下图所示:
异步线程只有一个由主线程充当。它负责监听所有socket上的事件。如果有新的连接请求到来,主线程就接受该请求以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件。如果连接socket上有I/O事件发生:
- 在Reactor模式下,主线程就将该连接socket插入请求队列
- 在Proactor模式下,主线程完成数据读写,将应用程序数据、任务类型等信息封装成一个任务对象,然后将其插入请求队列
睡眠在请求队列上的工作线程通过竞争的方式获得任务的管辖权。
半同步/半反应堆模式存在以下缺点:
- 异步线程和同步线程共享请求队列。主线程往请求队列添加任务,工作线程从请求队列取出任务都需要对请求队列加锁保护
- 工作线程同一时间只能处理一个任务。任务处理量很大或者任务处理存在一定的阻塞时,任务队列将会堆积,客户端的响应速度将越来越慢。不能简单地考虑增加工作线程来处理该问题,线程数达到一定的程度,工作线程的切换也将消耗大量的CPU资源。
下图描述了一种相对高效的半同步/半异步模式,它的每个工作线程都能同时处理多个客户的连接。
在此图中,主线程只管理监听socket,连接socket由工作线程来管理。当有新的连接到来时,主线程接受连接后将新获得的连接socket派发给某个工作线程,此后该新的连接socket上的任何I/O操作都由被选中的工作线程来处理,直到客户端关闭连接。
主线程向工作线程派发socket的最简单方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道有数据可读时,就分析是否是有新的客户连接请求到来。如果是,则把该新socket上的读写事件注册到自己的epoll内核事件表中。在此模式下每个线程都维持自己的事件循环,它们各自独立地监听不同的事件。因此它并非严格意义上的半同步/半异步模式(每个线程都工作在异步模式)。
领导者/追随者模式
领导者/追随者模式是多个工作线程轮流进行事件监听、事件分发、处理事件的模式。在此模式下,任意时间点,都仅有一个领导者线程 ,负责I/O事件监听。而其他线程都是追随者,休眠在线程池等待成为新的领导者。
领导者/追随者模式的工作流概述如下:
- 当前Leader Thread1监听到就绪事件后,从Follower 线程集中推选出 Thread 2成为新的Leader
- 新的Leader Thread2 继续事件I/O监听
- Thread1继续处理I/O就绪事件,执行完后加入到Follower 线程集中,等待成为Leader
从上诉描述可知,Leader/Follower模式的工作线程存在三种状态,工作线程同一时间只能处于一种状态,这三种状态为:
- Leader:线程处于领导者状态,负责监听I/O事件
- Processing:线程处理就绪I/O事件
- Follower:等待成为新的领导者或者可能被当前Leader指定处理就绪事件
Leader监听到I/O就绪事件后,有两种处理方式:
- 推选出新的Leader后,并转移到Processing处理该I/O就绪事件
- 指定其他Follower 线程处理该I/O就绪事件,此时保持Leader状态不变
下图显示了三种状态的转移:
有限状态机
有限状态机,也称为FSM(Finite State Machine)是逻辑单元内部的一种高效编程方法,其在任意时刻都处于有限状态集合中的某一状态。当其获得一个输入字符时,将从当前状态转换到另一个状态,或者仍然保持在当前状态。任何一个FSM都可以用状态转换图来描述,图中的节点表示FSM中的一个状态,有向加权边(方向表示从一个初态转换到次态,权表示事件)表示输入字符时状态的变化。如果图中不存在与当前状态与输入字符对应的有向边,则FSM将进入“消亡状态(Doom State)”,此后FSM将一直保持“消亡状态”。
状态转换图中还有两个特殊状态:状态1称为起始状态,状态6称为结束状态。
在启动一个FSM时,首先必须将FSM置于“起始状态”,然后经过一段触发时间后,最终,FSM会到达“结束状态”或者“消亡状态”。
本项目服务器框架
- 使用 线程池 + 非阻塞socket + epoll(ET和LT均实现) + 事件处理(Reactor和模拟Proactor均实现) 的并发模型
- 使用状态机解析HTTP请求报文,支持解析GET和POST请求
- 访问服务器数据库实现web端用户注册、登录功能,可以请求服务器图片和视频文件
- 实现同步/异步日志系统,记录服务器运行状态