项目介绍
- 在linux环境下使用C++开发一个轻量级的服务器,通过线程池、epoll,以及模拟Proactor模型实现并发,采用状态机解析GET与POST请求,经Webbench压力测试可以实现上万的并发连接数据交换。
- 为什么要做这样一个项目:可结合测开实习经历,接触网络与架构较多,但一直没有较多实际深入上手的机会,同时做该项目也能够比较系统的巩固和完备c++,网络,操作系统相关知识。
线程池相关
简述io多路复用
服务器编程基本框架
- 单元与单元之前通过请求队列通讯。具体的,I/O处理单元与逻辑单元之间,用的一个双向链表维护的线程池。逻辑单元与数据库之间使用的一个list<MYSQL *>的数据库连接池。
- I/O单元用于处理客户端连接,读写网络数据;逻辑单元用于***处理业务涉及逻辑的线程***;网络存储单元指本地数据库和文件等。
- 同步I/O与异步I/O本质区别就是具体的I/O操作是由应用程序还是内核完成。比如同步I/O指内核向应用程序通知的是就绪事件,比如只通知有客户端连接,要求用户代码自行执行I/O操作。
- Proactor与Reacotor本质上的区别就是真正的读取和写入操作是由谁来完成的。Proactor中的事件处理器(线程池)只关注读完成事件,也就是说主线程中sockfd就绪后,主线程进行read,之后将读完的对象放入线程池以竞态获取资源。而reactor关注的是就绪时间,具体读写由事件处理器自己完成。
同步I/O模拟proactor模式
异步I/O并不成熟,实际中使用较少,这里使用同步I/O模拟实现proactor模式:
- 主线程充当异步线程,负责监听所有socket上的事件
- 若有新请求到来,主线程接收之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件
- 如果连接socket上有读写事件发生,主线程从socket上接收数据,并将数据封装成请求对象插入到请求队列中
- 所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(互斥锁)获得任务的接管权,响应了逻辑处理(同步模式的工作线程业务逻辑处理)。
下面两张图 proactor和reactor
线程同步机制
- 信号量:主要操作sem_wait和sem_post,信号量为0时,sem_wait阻塞,信号量大于0时,唤醒调用sem_post的线程。可以很好维护一个线程池资源队列。
- 条件变量:通常和互斥锁连起来同,以确保独占式访问。当进入关键代码段,获得互斥锁将其加锁;离开关键代码段,唤醒等待该互斥锁的线程。关键api:pthread_cond_wait。
- 互斥锁:项目中 互斥锁 + 信号量实现一个线程池
- 你的线程池工作线程处理完一个任务后的状态是什么?
(1)继续阻塞(队列为空)
(2)竞争互斥锁(队列有任务)
- 如果同时1000个客户端进行访问请求,线程数不多,怎么能及时响应处理每一个呢?
任务放入请求队列、线程进行detach,同时对线程while循环,不断进行操作
while (!m_stop)//工作线程就是不断地等任务队列有新任务,然后就加锁取任务->取到任务解锁->执行任务,所以执行完任务后会阻塞在信号量wait上
{
m_queuestat.wait();//信号量等待,在这里阻塞住, m_queuestat.post()后唤醒 75 行 ,这里只涉及信号量 还不涉及条件变量。互斥锁必须是谁上锁就由谁来解锁,而信号量的wait和post操作不必由同一个线程执行
m_queuelocker.lock();//被唤醒后先加互斥锁
if (m_workqueue.empty())
{
m_queuelocker.unlock();
continue;
}
T *request = m_workqueue.front();
m_workqueue.pop_front();//取出任务队列头
m_queuelocker.unlock();
if (!request)
continue;
connectionRAII mysqlcon(&request->mysql, m_connPool);//从连接池中取出一个数据库连接
request->process();//process(模板类中的方法,这里是http类)进行处理
}
如果不循环,8个线程用完就没了。
- 如果一个客户请求需要占用线程很久的时间,会不会影响接下来的客户请求呢,有什么好的策略呢?
会影响接下来的客户请求,因为线程池内线程的数量时有限的,如果客户请求占用线程时间过久的话会影响到处理请求的效率,当请求处理过慢时会造成后续接受的请求只能在请求队列中等待被处理,从而影响接下来的客户请求。
应对策略:
我们可以为线程处理请求对象设置处理超时时间, 超过时间先发送信号告知线程处理超时,然后设定一个时间间隔再次检测,若此时这个请求还占用线程则直接将其断开连接。在项目中设置的定时器是针对非活跃描述符,客户端(这里是浏览器)与服务器端建立连接后,若长时间不交换数据,会直接关闭文件描述符从而释放资源。 具体实现:双向链表结构按事件序储存sockfd,主进程周期进行alarm,后扫描链表,发现超时就close关闭。
- detach() 和 join()
t.join()主线程等待子线程运行完之后才可结束。
t.detach()分离线程函数,使用detach()函数会让线程在后台运行,即说明主线程不会等待子
线程,通常称分离线程为守护线程(daemon threads)。
- 手写线程池(根据项目简略)
#include<bits/stdc++.h>
using namespace std;
template <typename T>;
class ThreadPool{
public:
ThreadPool(int num):thread_number(num){};
~threadpool();
void start();
void append(T *request);
private:
static void *worker(void *arg);
void run();
int thread_number;
list<T *> m_workqueue;
sem_t m_queuesem;
pthread_mutex_t m_queuelocker;//保护请求队列的互斥锁
pthread_t *m_thread;
}
template <typename T>
void threadpool<T>::start{
m_thread = new pthread_t[thread_number]
for(int i =0;i<thread_number;++i){
pthread_create(m_thread+i,NULL,worker,this);
pthread_detach(m_thread[i]);//创建后detach,脱离主线程,运行完自己回收资源,阻塞。
}
}
template <typename T>
void threadpool<T>::append(T *request){
pthread_mutex_lock(&m_queuelocker);
m_workqueue.push_back(request);
pthread_mutex_unlock(&m_queuelocker);
sem_post(&m_queuesem);//更详细一点,应该把sem封装类,加上初始化操作
}
template <typename T>
void *threadpool<T>::worker(void *arg){
threadpool *pool = (threadpool *)arg;//把参数转化为类指针,pthread_create第三个参数为静态成员的锅
pool->run();//传this指针->类型转化为类,静态成员函数调用调类非静态员方法
return;
}
template <typename T>
void threadpool<T>::run(){
while (true)//子线程要么运行,要么阻塞,等待信号
{
sem_wait(&m_queuesem);
pthread_mutex_lock(&m_queuelocker);//争夺互斥锁
if (m_workqueue.empty())
{
pthread_mutex_unclock(&m_queuelocker);
continue;
}
T *request = m_workqueue.front();
m_workqueue.pop_front();
pthread_mutex_unlock(&m_queuelocker);
request->process();//处理相应业务逻辑
//....
}
}
并发模型相关
- 简单说一下服务器用的并发模型
主线程epoll多路复用实现并发充当异步线程,通过epoll多路复用实现并发。主线程监听到新请求后,读取数据放入请求队列,线程池子线程同步处理事务逻辑,是用同步I/O模拟实现proactor模式。
- 比较epoll与其他复用方式(select poll)
每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
同时每当事件就绪时(1)fd_set又从内核拷贝到用户态,同时也需要轮循fd_set结构看哪个事件就绪,开销较大。
poll从本质上与select没差别,只是pollfd结构没有最大文件描述符数量的限制。
epoll是事件驱动,而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,事件挂载到红黑树上,若有事件就绪,则双向链表中不为空,复制相应的事件状态给用户即可,高效,且相较于select开销少。
epoll内核和用户空间mmap同一块内存实现
epoll改善select三点:
(1)效率低
(2)内存开销大
(3)并发连接数限制
- eopll的et(边缘触发)模式,epoll_wait检测到文件描述符有事件发生,则将其通知给应用程序,应用程序必须立即处理该事件,必须要一次性将数据读取完,使用非阻塞I/O(否则read会一直阻塞在最会一次读),读取到出现eagain。
- EPOLLONESHOT:
一个线程读取某个socket上的数据后开始处理数据,在处理过程中该socket上又有新数据可读,此时另一个线程被唤醒读取,此时出现两个线程处理同一个socket。
我们期望的是一个socket连接在任一时刻都只被一个线程处理,通过epoll_ctl对该文件描述符注册epolloneshot事件,一个线程处理socket时,其他线程将无法处理,当该线程处理完后,需要通过epoll_ctl重置epolloneshot事件。
HTTP报文解析相关
- 使用状态机的好处?
主要可以将组合逻辑和时序逻辑分开,利于综合器分析优化和程序维护。 - get和post
get:请求行,请求头,空行,请求体为空。
post:get:请求行,请求头,空行,请求体
HTTP相应:状态行、消息报头、空行和响应正文
post相对更安全,且主要用于向服务器提交数据,get有传输的数据限制。
-状态转移图
从状态机负责读取报文的一行,主状态机负责对该行数据进行解析(状态码,http版本,资源位置等等),主状态机内部调用从状态机,从状态机驱动主状态机。**由于在HTTP报文中,每一行的数据由\r\n作为结束字符,空行则是仅仅是字符\r\n。**因此,可以通过查找\r\n将报文拆解成单独的行进行解析,项目中便是利用了这一点。
从状态机负责读取buffer中的数据,将每行数据末尾的rn置为00,并更新从状态机在buffer中读取的位置m_checked_idx,以此来驱动主状态机解析。
主状态机初始状态是CHECK_STATE_REQUESTLINE,通过调用从状态机来驱动主状态机,在主状态机进行解析前,从状态机已经将每一行的末尾换行符号改为00,以便于主状态机定位直接取出对应字符串进行处理。process_read通过while循环,将主从状态机进行封装,对报文的每一行进行循环处理。在循环体中从状态机读取数据,同时将读取到的数据间接赋给text缓冲区,然后利用主状态机来解析text中的内容。