【仿Mudou库one thread per loop式并发服务器实现】项目介绍+前置技术知识点
HTTP协议模块实现
- 1. 项目实现的目标
- 2. 项目储备知识
- 2.1 HTTP服务器
- 2.2 Reactor模型
- 3. 功能模块划分
- 3.1 SERVER模块
- 3.1.1 Buffer模块
- 3.1.2 Socket模块
- 3.1.3 Channel模块
- 3.1.4 Poller模块
- 3.1.5 EventLoop模块
- 3.1.6 Connection模块
- 3.1.7 7. Acceptor模块
- 3.1.8 TimerQueue模块
- 3.1.9 通信模块总结
- 3.1.10 TcpServer模块
- 3.2 HTTP协议模块
- 3.2.1 Util模块
- 3.2.2 HttpRequest模块
- 3.2.3 HttpResponse模块
- 3.2.3 HttpContext模块
- 3.2.4 HttpServer模块
- 4. 项目前置知识技术点
- 4.1 C++11中的bind
- 4.2 简单的秒级定时任务实现
- 4.3 正则库的简单使用
- 4.5 通用类型any类型的实现
点赞👍👍收藏🌟🌟关注💖💖
你的支持是对我最大的鼓励,我们一起努力吧!😃😃
1. 项目实现的目标
仿muduo库One Thread One Loop式主从Reactor模型实现高并发服务器这个项目是通过咱们实现的高并发服务器组件,可以简洁快速的完成一个高性能的服务器搭建。并且,通过组件内提供的不同应用层协议支持,也可以快速完成一个高性能应用服务器的搭建。在这里,要明确的是咱们要实现的是一个高并发服务器组件,因此当前的项目中并不包含实际的业务内容。
2. 项目储备知识
2.1 HTTP服务器
HTTP(Hyper Text Transfer Protocol),超文本传输协议是应用层协议,是一种简单的请求-响应协议(客户端根据自己的需要向服务器发送请求,服务器针对请求提供服务,完毕后通信结束)。
协议细节在linux网络部分有详细介绍,这里不在赘述。但是需要注意的是HTTP协议是一个运行在TCP协议之上的应用层协议,这一点本质上是告诉我们,HTTP服务器其实就是个TCP服务器,只不过在应用层基于HTTP协议格式进⾏数据的组织和解析来明确客户端的请求并完成业务处理。
因此实现HTTP服务器简单理解,只需要以下几步即可
- 搭建一个TCP服务器,接收客户端请求。
- 以HTTP协议格式进⾏解析请求数据,明确客户端目的。
- 明确客户端请求目的后提供对应服务。
- 将服务结果以HTTP协议格式进行组织,发送给客户端
实现一个HTTP服务器很简单,但是实现一个高性能的服务器并不简单,这个项目中将讲解基于Reactor模式的高性能服务器实现。
当然准确来说,因为我们要实现的服务器本身并不存在业务,咱们要实现的应该算是一个高性能服务器基础库,是一个基础组件。
2.2 Reactor模型
Reactor 模式,是指通过一个或多个输入同时传递给服务器进行请求处理时的事件驱动处理模式。
服务端程序处理传入多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式也叫Dispatcher 模式。
简单理解就是使用 I/O多路复用 统一监听事件,收到事件后分发给处理进程或线程,是编写高性能网络服务器的必备技术之一。
分类:
单Reactor单线程:单I/O多路复用+业务处理
- 通过IO多路复用模型进行客户端请求监控
- 触发事件后,进行事件处理
-
- a. 如果是新建连接请求,则获取新建连接,并添加至多路复用模型进行事件监控。
-
- b. 如果是数据通信请求,则进行对应数据处理(接收数据,处理数据,发送响应)。
优点:所有操作均在同⼀线程中完成,思想流程较为简单,不涉及进程/线程间通信及资源争抢问题。
缺点:无法有效利用CPU多核资源,很容易达到性能瓶颈。
适用场景:适用于客户端数量较少,且处理速度较为快速的场景。(处理较慢或活跃连接较多,会导致串行处理的情况下,后处理的连接长时间无法得到响应)。
单Reactor多线程:单I/O多路复用+线程池(业务处理)
- Reactor线程通过I/O多路复用模型进行客户端请求监控
- 触发事件后,进行事件处理
-
- a. 如果是新建连接请求,则获取新建连接,并添加至多路复用模型进行事件监控。
-
- b. 如果是数据通信请求,则接收数据后分发给Worker线程池进行业务处理。
-
- c. 工作线程处理完毕后,将响应交给Reactor线程进行数据响应
优点:充分利用CPU多核资源
缺点:多线程间的数据共享访问控制较为复杂,单个Reactor 承担所有事件的监和响应,在单线程中运行,高并发场景下容易成为性能瓶颈。
多Reactor多线程:多I/O多路复用+线程池(业务处理)
- 在主Reactor中处理新连接请求事件,有新连接到来则分发到子Reactor中监控
- 在子Reactor中进行客户端通信监控,有事件触发,则接收数据分发给Worker线程池
- Worker线程池分配独立的线程进行具体的业务处理
-
- a. 工作线程处理完毕后,将响应交给子Reactor线程进行数据响应
优点:充分利用CPU多核资源,主从Reactor各司其职
咱们要实现的是主从Reactor模型服务器,也就是主Reactor线程仅仅监控监听描述符,获取新建连接,保证获取新连接的高效性,提高服务器的并发性能。
主Reactor获取到新连接后分发给子Reactor进行通信事件监控。而子Reactor线程监控各自的描述符的读写事件进行数据读写以及业务处理。
One Thread One Loop的思想就是把所有的操作都放到一个线程中进行,一个线程对应一个事件处理的循环。
当前实现中,因为并不确定组件使用者的使用意向,因此并不提供业务层工作线程池的实现,只实现主从Reactor,而Worker工作线程池,可由组件库的使用者的需要自行决定是否使用和实现。
3. 功能模块划分
基于以上的理解,我们要实现的是一个带有协议支持的Reactor模型高性能服务器,因此将整个项目的实现划分为两个大的模块:
- SERVER模块:实现Reactor模型的TCP服务器;
- 协议模块:对当前的Reactor模型服务器提供应用层协议支持
3.1 SERVER模块
SERVER模块就是对所有的连接以及线程进行管理,让它们各司其职,在合适的时候做合适的事,最终 完成高性能服务器组件的实现。
而具体的管理也分为三个方面:
- 监听连接管理:对监听连接进行管理。
- 通信连接管理:对通信连接进行管理。
- 超时连接管理:对超时连接进行管理。
基于以上的管理思想,将这个模块进行细致的划分又可以划分为多个子模
3.1.1 Buffer模块
Buffer模块是一个缓冲区模块,用于实现通信中用户态的接收缓冲区和发送缓冲区功能。
3.1.2 Socket模块
Socket模块是对套接字操作封装的一个模块,主要实现的socket的各项操作。
3.1.3 Channel模块
Channel模块是对一个描述符需要进行的IO事件管理的模块,实现对描述符可读,可写,错误…事件的管理操作,以及Poller模块对描述符进行IO事件监控就绪后,根据不同的事件,回调不同的处理函数功能。
3.1.4 Poller模块
Poller模块是对epoll进行封装的一个模块,主要实现epoll的IO事件添加,修改,移除,获取活跃连接功能。
3.1.5 EventLoop模块
EventLoop模块可以理解就是我们上边所说的Reactor模块,它是对Poller模块,TimerQueue模块,进行所有描述符的事件监控。
EventLoop模块是一个EventLoop对象对应一个线程的模块,线程内部的目的就是运行EventLoop的启动函数。
EventLoop模块为了保证整个服务器的线程安全问题,因此要求使用者对于Connection的所有操作一定要在其对应的EventLoop线程内完成,不能在其他线程中进行(比如组件使用者使用Connection发送数据,以及关闭连接这种操作)。
EventLoop模块保证自己内部所监控的所有描述符,都要是活跃连接,非活跃连接就要及时释放避免资源浪费。
- EventLoop模块内部包含有一个eventfd:eventfd其实就是linux内核提供的一个事件fd,专门用于事件通知。
- EventLoop模块内部包含有一个Poller对象:用于进行描述符的IO事件监控。
- EventLoop模块内部包含有一个TimerQueue对象:用于进行定时任务的管理。
- EventLoop模块内部包含有一个PendingTask队列:组件使用者将对Connection进行的所有操作,都加入到任务队列中,由EventLoop模块进行管理,并在EventLoop对应的线程中进行执行。
- 每一个Connection对象都会绑定到一个EventLoop上,这样能保证对这个连接的所有操作都是在一个线程中完成的。
具体操作流程:
- 通过Poller模块对当前模块管理内的所有描述符进行IO事件监控,有描述符事件就绪后,通过描述符对应的Channel进行事件处理。
- 所有就绪的描述符IO事件处理完毕后,对任务队列中的所有操作顺序进行执行。
- 由于epoll的事件监控,有可能会因为没有事件到来而持续阻塞,导致任务队列中的任务不能及时得到执行,因此创建了eventfd,添加到Poller的事件监控中,用于实现每次向任务队列添加任务的时候,通过向eventfd写⼊数据来唤醒epoll的阻塞。
3.1.6 Connection模块
Connection模块是对Buffer模块,Socket模块,Channel模块的一个整体封装,实现了对一个通信套接字的整体的管理,每一个进行数据通信的套接字(也就是accept获取到的新连接)都会使用Connection进行管理。
-
Connection模块内部包含有三个由组件使用者传入的回调函数:连接建立完成的回调,任意事件的回调,新数据来到的回调,关闭连接的回调。
-
Connection模块内部包含有两个组件使用者提供的接口:数据发送接口,连接关闭接口
-
Connection模块内部包含有两个用户态缓冲区:用户态接收缓冲区,用户态发送缓冲区
-
Connection模块内部包含有一个Socket对象:完成描述符面向系统的IO操作
-
Connection模块内部包含有一个Channel对象:完成描述符IO事件就绪的处理
具体处理流程如下:
- 实现向Channel提供可读,可写,错误等不同事件的IO事件回调函数,然后将Channel和对应的描述符添加到Poller事件监控中。
- 当描述符在Poller模块中就绪了IO可读事件,则调用描述符对应Channel中保存的读事件处理函数,进行数据读取,将socket接收缓冲区全部读取到Connection管理的用户态接收缓冲区中。然后调用由组件使用者传入的新数据到来回调函数进行处理。
- 组件使者者进行数据的业务处理完毕后,通过Connection向使用者提供的数据发送接⼝,将数据写入Connection的发送缓冲区中。
- 启动描述符在Poll模块中的IO写事件监控,就绪后,调用Channel中保存的写事件处理函数,将发送缓冲区中的数据通过Socket进行面向系统的实际数据发送。
3.1.7 7. Acceptor模块
Acceptor模块是对Socket模块,Channel模块的一个整体封装,实现了对一个监听套接字的整体的管理。
- Acceptor模块内部包含有一个Socket对象:实现监听套接字的操作
- Acceptor模块内部包含有一个Channel对象:实现监听套接字IO事件就绪的处理
具体处理流程如下:
- 实现向Channel提供可读事件的IO事件处理回调函数,函数的功能其实也就是获取新连接
- 为新连接构建一个Connection对象出来
3.1.8 TimerQueue模块
TimerQueue模块是实现固定时间定时任务的模块,可以理解就是要给定时任务管理器,向定时任务管理器中添加一个任务,任务将在固定时间后被执行,同时也可以通过刷新定时任务来延迟任务的执行。
这个模块主要是对Connection对象的生命周期管理,对非活跃连接进行超时后的释放功能。
TimerQueue模块内部包含有一个timerfd:linux系统提供的定时器。
TimerQueue模块内部包含有一个Channel对象:实现对timerfd的IO时间就绪回调处理。
3.1.9 通信模块总结
通过Accpect模块创建一个监听套接字listsock,并给listsock对应的channel设置读事件回调,然后添加到主EventLoop线程中的Poller添加读事件监控,当读事件就绪后,通过Accpect模块给listsock设置读回调函数获取新连接,获取新连接之后,然后在调用主EventLoop线程给它设置的新连接获取之后的回调函数,创建一个Connection对象,它内部也有对应的Channel对象,创建Connection对象后它构造函数内部对应的Channel对象已经设置好对应的读、写、错误、挂断事件的回调,所以就不用管。然后设置给这个Connection对象分配给某个从EventLoop线程,并且给它设置调用者设置的连接建立好后的回调、新数据来了后的回调,任意事件的回调、关闭连接的回调。再看是否启动非活跃连接,然后给Connection启动读事件监控。
如果该Connection对应的fd读事件就绪后,就可以通过管理该Connection的从EventLoop中的Poller模块中拿到,然后调用给它内部Channel设置读事件回调,从Connection中的Socket模块读取数据到Buffer模块接收缓存区,有新数据就调用设置好的新数据来了之后的回调函数。如果业务处理好之后,把返回的数据写到Buffer输出缓存区,然后将该fd写事件的关心添加到管理它的从EventLoop中的Poller中,等下次写事件就绪之后,就调用对应Channel写事件回调将Buffer输出缓存区的数据通过Connection中的socket发送给对方。
此时就完成了一次往返通信的过程。
3.1.10 TcpServer模块
这个模块是一个整体Tcp服务器模块的封装,内部封装了Acceptor模块,EventLoopThreadPool模块。
-
TcpServer中包含有一个EventLoop对象:以备在超轻量使用场景中不需要EventLoop线程池,只需要在主线程中完成所有操作的情况。
-
TcpServer模块内部包含有一个EventLoopThreadPool对象:其实就是EventLoop线程池,也就是子Reactor线程池
-
TcpServer模块内部包含有一个Acceptor对象:一个TcpServer服务器,必然对应有⼀个监听套接字,能够完成获取客⼾端新连接,并处理的任务。
-
TcpServer模块内部包含有一个std::shared_ptr的hash表:保存了所有的新建连接对应的Connection,注意,所有的Connection使用shared_ptr进行管理,这样能够保证在hash表中删除了Connection信息后,在shared_ptr计数器为0的情况下完成对Connection资源的释放操作。
具体操作流程如下:
- 在实例化TcpServer对象过程中,完成BaseLoop(主Reactor)的设置,Acceptor对象的实例化,以及EventLoop线程池的实例化,以及std::shared_ptr的hash表的实例化。
- 为Acceptor对象设置回调函数:获取到新连接后,为新连接构建Connection对象,设置Connection的各项回调,并使用shared_ptr进行管理,并添加到hash表中进行管理,并为Connection选择一个EventLoop线程,为Connection添加一个定时销毁任务,为Connection添加事件监控,
- 启动BaseLoop进行监听套接字事件监控。
3.2 HTTP协议模块
HTTP协议模块用于对高并发服务器模块进行协议⽀持,基于提供的协议支持能够更方便的完成指定协议服务器的搭建。
而HTTP协议支持模块的实现,可以细分为以下几个模块
3.2.1 Util模块
这个模块是一个工具模块,主要提供HTTP协议模块所用到的一些工具函数,比如url编解码,文件读写…等。
3.2.2 HttpRequest模块
这个模块是HTTP请求数据模块,用于保存HTTP请求数据被解析后的各项请求元素信息。
3.2.3 HttpResponse模块
这个模块是HTTP响应数据模块,用于业务处理后设置并保存HTTP响应数据的的各项元素信息,最终会被按照HTTP协议响应格式组织成为响应信息发送给客户端。
3.2.3 HttpContext模块
这个模块是一个HTTP请求接收的上下文模块,主要是为了防止在一次接收的数据中,不是一个完整的HTTP请求,则解析过程并未完成,无法进行完整的请求处理,需要在下次接收到新数据后继续根据上下文进行解析,最终得到⼀个HttpRequest请求信息对象,因此在请求数据的接收以及解析部分需要一个上下文来进行控制接收和处理节奏。
3.2.4 HttpServer模块
这个模块是最终给组件使用者提供的HTTP服务器模块了,用于以简单的接口实现HTTP服务器的搭建。
HttpServer模块内部包含有⼀个TcpServer对象:TcpServer对象实现服务器的搭建
HttpServer模块内部包含有两个提供给TcpServer对象的接口:连接建立成功设置上下文接口,数据处理接口。
HttpServer模块内部包含有一个hash-map表存储请求与处理函数的映射表:组件使用者向HttpServer设置哪些请求应该使用哪些函数进行处理,等TcpServer收到对应的请求就会使用对应的函数进行处理。
4. 项目前置知识技术点
4.1 C++11中的bind
bind (Fn&& fn, Args&&... args)
我们可以将bind接口看作是一个通用的函数适配器,它接受一个函数对象,以及函数的各项参数,然后返回一个新的函数对象,但是这个函数对象的参数已经被绑定为设置的参数。运行的时候相当于总是调用传入固定参数的原函数。
但是如果进行绑定的时候,给与的参数为 std::placeholders::_1, _2...
则相当于为新适配生成的函数对象的调用预留一个参数进行传递。
#include
#include
#include
#include
void print(const std::string &str, int num)
{
std::cout << str << num << std::endl;
}
int main()
{
using Task = std::function<void()>;
std::vector<Task> arry;
arry.push_back(std::bind(print, "hello", 10));
arry.push_back(std::bind(print, "leihou", 20));
arry.push_back(std::bind(print, "nihao", 30));
for (auto &f:arry) {
f();
}
return 0;
}
基于bind的作用,当我们在设计一些线程池,或者任务池的时候,就可以将将任务池中的任务设置为函数类型,函数的参数由添加任务者直接使用bind进行适配绑定设置,而任务池中的任务被处理,只需要取出一个个的函数进行执行即可。
这样做有个好处就是,这种任务池在设计的时候,不用考虑都有哪些任务处理方式了,处理函数该如何设计,有多少个什么样的参数,这些都不用考虑了,降低了代码之间的耦合度。
4.2 简单的秒级定时任务实现
在当前的高并发服务器中,我们不得不考虑一个问题,那就是连接的超时关闭问题。我们需要避免一个连接长时间不通信,但是也不关闭,空耗资源的情况。
这时候我们就需要一个定时任务,定时的将超时过期的连接进行释放。
Linux提供给我们的定时器
#include
#include
#include
#include
//linux下的定时器
//创建定时器
// int timerfd_create(int clockid, int flags);
//clockid:
//CLOCK_REALTIM -- 以系统时间作为记时基准值(如果系统时间发生改变就会出问题)
//CLOCK_MONOTONIC -- 以系统启动时间进行递增的一个基准值(定时不会随着系统时间改变而改变)
//flags:对定时器读取操作是阻塞/非阻塞 0 默认阻塞操作
//linux下一切皆文件,定时器的操作跟文件操作没什么区别
//定时器定时原理,每隔一段时间(定时器的超时时间),系统就会给这个文件描述符对应的定时器写入一个8字节的数据
//没到时间就阻塞,到时间就可以读取成功了
//启动定时器
//int timerfd_settime(int fd, int flags,
// const struct itimerspec *new_value,
// struct itimerspec *_Nullable old_value);
//fd: timerfd_create返回值
// flags: 0-相对时间, 1-绝对时间;默认设置为0即可.
// new: ⽤于设置定时器的新超时时间
// old: ⽤于接收原来的超时时间
// struct timespec {
// time_t tv_sec; /* Seconds */ 秒
// long tv_nsec; /* Nanoseconds */ 纳秒
// };
//
// struct itimerspec {
// struct timespec it_interval; /* 第⼀次之后的超时间隔时间 */
// struct timespec it_value; /* 第⼀次超时时间 */
// };
int main()
{
int timefd = timerfd_create(CLOCK_MONOTONIC,0);
if(timefd < 0)
{
std::cout<<"open file fail"<<std::endl;
return -1;
}
struct itimerspec item;
item.it_value.tv_sec = 1;
item.it_value.tv_nsec = 0; //第一次超时时间
item.it_interval.tv_sec = 1;
item.it_interval.tv_nsec = 0;//第⼀次之后的超时间隔时间
timerfd_settime(timefd,0,&item,nullptr);
//sleep(10);
while(1)
{
uint64_t cnt = 0;
int ret = read(timefd,&cnt,8);
if(ret < 0)
{
std::cout<<"read fail"<<std::endl;
break;
}
std::cout<<"超时了,距离上次超时次数: "<<cnt<<std::endl;
}
close(timefd);
return 0;
}
上边例子,是一个定时器的使用示例,是每隔1s钟触发一次定时器超时。
基于这个例子,我们可以实现每隔n秒,检测一下哪些连接超时了,然后将超时的连接释放掉。
时间轮思想
上述的例子,存在一个很大的问题,每次超时都要将所有的连接遍历一遍,如果有上万个连接,效率无疑是较为低下的。
这时候大家就会想到,我们可以针对所有的连接,根据每个连接最近一次通信的系统时间建立一个小根堆,这样只需要每次针对堆顶部分的连接逐个释放,直到没有超时的连接为止,这样也可以大大提高处理的效率。
上述方法可以实现定时任务,但是这立给大家介绍另一种方案:时间轮
时间轮的思想来源于钟表,如果我们定了一个3点钟的闹铃,则当时针走到3的时候,就代表时间到了。
同样的道理,如果我们定义了一个数组,并且有一个指针,指向数组起始位置,这个指针每秒钟向后走动一步,走到哪里,则代表哪里的任务该被执行了,那么如果我们想要定一个3s后的任务,则只需要将任务添加到tick+3位置,则每秒中走一步,三秒钟后tick走到对应位置,这时候执行对应位置的任务即可。
但是,同一时间可能会有大批量的定时任务,因此我们可以给数组对应位置下拉一个数组,这样就可以在同一个时刻上添加多个定时任务了。
当然,上述操作也有一些缺陷,比如我们如果要定义一个60s后的任务,则需要将数组的元素个数设置为60才可以,如果设置一小时后的定时任务,则需要定义3600个元素的数组,这样无疑是比较麻烦的。
因此,可以采用多层级的时间轮,有秒针轮,分针轮,时针轮, 60