Qver - 用于练手的服务器程序
2021-09-14 09:48:50
利用 C++ 11 编写一个简单的 Linux HTTP 服务器,主要用于静态页面的部署
- 使用非阻塞 socket + epoll + 半同步/半反应堆模式 + 线程池来处理客户连接
- 通过有限状态机处理 HTTP 请求报文(GET)
项目地址:xQmQ/Qver: C++ 11 实现的简单 HTTP server
函数
httpEvent(int fd)
:推入到线程池任务队列的处理函数,通过fd
创建HttpEvent
类型对象,调用相应的处理函数,完成对 HTTP 请求的处理
类
HttpEvent
:HTTP 请求处理类
ThreadPool
:线程池实现,通过ThreadPool::submitWork()
推送任务并唤醒工作线程处理
Socket
:注册并绑定 socket 连接到服务器特定端口,接受所有客户连接
Epoller
:epoll
的封装实现,用于接收socket
接受的连接并监听其是否有读写请求;通过定时器剔除非活动连接
Timer
:定时器,用于处理非活动连接
处理流程
主线程
- 主线程建立
Socket
对象,建立ThreadPool
对象 - 非阻塞的
Socket
对象源源不断接收连接并注册到Epoller
监听,并插入Timer
定时器 Epoller
监听到就绪读请求时,获取对应fd
并通过httpEvent(int fd)
注册到线程池任务队列,由线程池唤醒工作线程并处理;同时从Timer
中剔除链接- 通过
Timer
定期处理非活动连接,从Epoller
中剔除并关闭 - 监听外部信号,关闭线程池,结束进程
工作线程
- 线程池唤醒工作线程并从任务队列中获取
httpEvent(int fd)
- 工作线程建立
HttpEvent
对象,绑定fd
HttpEvent
获取fd
中的数据到读缓冲区,通过有限状态机解析 HTTP 请求,并准备相应资源写入写缓冲区,并发送给客户
两种事件处理模式
半同步/半反应堆模式
流程:
- 建立线程池
- 主线程监听 socket,当接收到一个客户端 connect 时,accept 并将其注册到 epoll 内核事件表
- 通过
epoll_wait()
获得客户端的读写请求,将读写请求插入任务队列 - 工作线程通过申请互斥锁,获得读写请求,并处理,返回结果到客户端
缺点:
- 主线程向请求队列添加事件或工作线程从请求队列取出事件,即生产者-消费者问题,需要通过互斥锁完成同步。加锁和解锁浪费 CPU
- 当请求队列中的读写事件过多时,工作线程较少,无法及时处理,会减慢客户端响应时间
高效半同步/半异步模式
流程:
- 建立线程池,初始化线程,各线程新建 epoll 内核事件表,新建私有请求队列
- 主线程监听 socket,当接收到一个客户端 connect 时,accept 连接
- 主线程轮询线程池中所有工作线程的状态,挑选负载最小的工作线程并传递 fd 到工作线程的私有请求队列中
详情:《Linux 多线程服务端编程》9.1.2 小节
- 工作线程轮询请求队列,如果有 fd 则注册到自己的 epoll 内核事件表中,通过
epoll_wait()
获得客户端的读写请求 - 工作线程处理读写请求,返回结果到客户端;查看请求队列是否还有事件需要处理
遇到的问题
- 任务队列的
empty()
和size()
都设置为const
,与函数中上锁的操作发生了冲突 - 类
ThreadPool
中定义了一个返回类型推导的任务提交函数submitWork()
,类中的函数声明与定义分离在Threadpool.h
和ThreadPool.cpp
中,但是模板函数的声明和定义不可以分离。我的解决办法是将submitWork()
定义在Threadpool.h
中 - 第一版的
ThreadPool
可能存在线程不安全的情况。在构造函数中,创建工作线程并向工作线程暴露了ThreadPool
的this
指针,如果ThreadPool
初始化到一半,其他线程会访问这个半成品;析构函数中也存在问题,在代码中存在唤醒工作线程执行余下的任务这样的操作,这些工作线程会调用ThreadPool::conditional_mutex_
,如果析构函数销毁了互斥锁,对于未结束的工作线程来说会破坏互斥环境《Linux 多线程服务端编程》1.2,1.3
- 线程池设计思路出现问题。线程池的关闭应当由
shutdown()
来定义,不应该放在析构函数中,何时执行shutdown()
?考虑到这是一个服务器,不用关闭,需要关闭的时候应当由外部信号来决定是否调用shutdown()
- epoll 检测事件时需要关注,socket 是否被客户端主动关闭,通过
addEvent()
时对检测的事件调整为EPOLLET | EPOLLIN | EPOLLRDHUP
;且处理EPOLLIN
之前需要先close()
对端关闭的 socket - epoll 对于
EPOLLRDHUP
和EPOLLIN | EPOLLOUT
的处理需要注意:应当优先处理EPOLLRDHUP
,然后再处理其他操作 - epoll 对于
EPOLLOUT
的触发问题。高位表示缓冲区可写,低位表示不可写。在水平触发LT
中,由低位转高位和处于高位这两种情况都会触发EPOLLOUT
;在边缘触发ET
中,只有低位转高位才会触发EPOLLOUT
。一般来说,在连接建立时,缓冲区是可写的,这时就会触发,所以处于ET
时,后续操作中会存在无法触发EPOLLOUT
导致无法执行写操作,可以通过epoll_ctl()
中EPOLL_CTL_MOD
重新设置一次EPOLLOUT
- 当客户端主动关闭一个 socket(操作一),并且紧接着申请一个新的 socket 连接时(操作二)(两个操作中间没有其他客户端的操作),会导致操作二继承操作一的文件描述符。当操作一发生后,文件描述符并没有从定时器链表中去除;操作二继承的文件描述符会继续以操作一的定时来确定是否是非活动连接,这里需要注意处理。
- 对于检测信号中的结构
struct sigaction.sa_handler
,其记录的是信号触发时的处理函数,以回调函数的形式表示。且此处理函数只能有一个参数,为信号值。在原本的设计中,计划将这些涉及到信号处理的函数封装成类,但是如果封装成类,处理函数作为成员函数时,无法作为回调函数绑定在struct sigaction.sa_handler
(此时有两个参数,一个是隐藏参数this
)
参考
- 《Linux 高性能服务器编程》
- 《Linux 多线程服务端编程》
- 《Linux / Unix 系统编程手册》
- 《HTTP 权威指南》
- 《C++ 服务器开发精髓》