文章目录
- Reactor模型的典型分类
- 单Reactor单线程
- 单Reactor多线程
- 多Reactor多线程
- 本项目中实现的主从Reactor One Thread One Loop
- 各模型的优点与缺点
- 项目分解
- Reactor服务器模块
- Buffer
- Socket
- Channel
- Epoller
- TimerWheel
- EventLoop
- Any
- Connection
- Acceptor
- LoopThread
- LoopThreadPool
- TcpServer
- HTTP服务器模块
- Util
- Request和Response
- Response
- Context
- HttpServer
本篇博客是对自己实现的主从Reactor高并发服务器的总结。
Reactor模型的典型分类
单Reactor单线程
单Reactor多线程
多Reactor多线程
本项目中实现的主从Reactor One Thread One Loop
各模型的优点与缺点
单Reactor单线程
- 优点:实现简单,不涉及到进程/线程间通信以及资源争抢;
- 缺点:由于所有操作均在单线程中串行执行,一旦有任务处理较慢或者请求较多时,容易导致后面的任务处理或者请求得不到响应。并且由于是单线程,没有充分利用好CPU多核资源,最终非常容易达到性能瓶颈。
单Reactor多线程
- 优点:利用了CPU多核资源;
- 缺点:单个Reactor线程不仅处理了新建连接请求,而且还处理了数据通信请求,也就是管理了所有的fd上的一切事件,在高并发场景下也非常容易达到性能评价。
多Reactor多线程
- 优点:充分利用了CPU多核资源,主Reactor只负责获取连接,副Reactor负责已获取的连接,各司其职,解决了前面两种模型的性能问题;
- 缺点:实现复杂。
主从Reactor One Thread One Loop
由于也采用了主从Reactor模式,所以性能不差,但为了服务器的实现更简单,放弃了线程池的实现。
项目分解
本项目共分为两大模块:Reactor服务器模块和基于Reactor服务器模块实现的HTTP服务器模块。
下面的项目分解只是简单的说明了一下各模块的功能,项目源码中有详细的注释讲解,所以强烈建议搭配项目源码一起食用。
Reactor服务器模块
服务器模块共有以下子模块
Buffer
recv并不能够保证读取到一个完整的协议数据,所以必须要将读取到的数据先暂存起来,然后上层检查数据完整性,若完整则拿走数据,不完整则一直等读取到一个完整的协议数据时再拿走数据,那么这时就需要一个缓冲区能暂时存放recv读取到的数据。并且写入数据时,也不能直接调用send,因为fd是要被epoll监控的,但用户又不知道什么时候调用,所以用户可以直接将数据写入缓冲区中,当fd上的写事件触发时,会自动将缓冲区中的数据send到fd中。
本模块就实现的是这样的一个缓冲区。
缓冲区结构如下:
Socket
封装系统调用socket,使对于socket的各项操作更加方便。
Channel
Channel模块是对一个fd进行事件监控管理以及事件回调管理的!
功能大概有:
-
开启/关闭fd的事件监控(读、写);
-
关闭fd的所有事件监控;
-
判断fd的事件监控是否被开启了;
-
设置事件触发后的回调函数(读事件、写事件、错误事件、关闭事件、任意事件);
-
调用已经触发的事件回调函数。
但要注意,关于fd的开启/关闭事件监控并不是真正在Channel模块执行的,而是在Epoller模块执行的。Channel模块只是将fd的相关监控操作和相关事件回调整合在了一起。
Epoller
Epoller模块是对epoll系列操作进行的封装,让对fd的事件监控操作更加简单。
通过传入一个Channel指针,获取到fd需要监控的事件,然后Epoller模块就把这些事件进行监控,而当有事件触发时,Epoller模块就把已经触发的事件通过Channel传出,再由Channel内部调用事件回调。
功能大概有:
- 添加/更新事件监控;
- 移除事件监控;
- 开始事件监控。
TimerWheel
TimerWheel是一个定时任务管理模块。
大致思想就是:将任务封装到TimerTask的析构函数中,然后用shared_ptr管理起来放入TimerWheel中的vector里,每隔一秒就清空一下vector里的元素,此时调用析构函数,就会调用定时任务了。
每隔一秒,step_就前进一步,step_走到哪里,就清空哪里,然后当最后一个shared_ptr调用析构函数时,就会调用定时任务。
step_的每秒移动是根据timerfd技术来实现的。
创建一个timerfd,让内核每隔一秒写入一次,然后用Channel管理timerfd,注册一个读事件,在读事件里++step_,这样内核每隔一秒写入一次,就触发一次读事件,就会++step_一次。
EventLoop
EventLoop模块就是副Reactor模块,封装了Epoller模块和TimerWheel模块,并且一个EventLoop就是一个线程。
大致功能有:
- 更新/移除事件监控(调用Epoller接口);
- 添加/刷新/取消/移除定时任务;
- 添加任务到任务队列中;
- 启动事件监控(调用Epoller接口),调用事件回调(调用Channel接口),执行任务队列中的任务。
关于任务队列,要详细说一下:
对于一个连接,用户所有关于连接的操作都是线程不安全的,比如在某个事件回调执行过程中,用户开辟了一个线程池,这个线程池都是共享这个连接的,那么假设有若干个线程同时对定时任务进行操作,就会出现线程安全问题。所以用户所有的对于连接的操作都是非线程安全的,但是又不能给每个连接的接口都添加锁,这样效率就太低了。于是就有了一个解决办法,在EventLoop模块里创建一个任务队列,所有的连接的接口在调用时都进行一下判断(接口内部判断),若是副Reactor线程就直接执行接口,若是其它线程,就将该任务压入队列中,由副Reactor线程统一执行。这样就避免了多线程对于连接访问的线程安全问题。
上面功能的第四点是在同一函数中执行的,那么就会出现一种情况,任务队列中有任务了,但此时没有事件触发,epoll_wait被阻塞,最终导致任务队列中的任务得不到及时执行。所以这里用了eventfd技术解决。eventfd用Channel管理起来,注册一个读事件,然后在将任务添加到任务队列后往eventfd里写入数据,此时就会触发读事件,epoll_wait不会被阻塞,任务队列中的任务也就能够被及时执行了。
Any
Any模块是模仿C++17中的any类实现的。
TCP服务器并不知道上层要运用什么协议,也就无法用一个特定类型保存上层的上下文信息,所以用一个Any类来保存上层的上下文信息。
实现思路
要实现一个类,能够存放任意类型的数据,那么该类必定不能是模版类,模版类不能自动推演类型,并且模版类在实例化之后就只能存放单一类型的数据了。但是函数模版可以自动推演类型,于是就想到将类的构造函数设置成模版函数,成员变量为void *指针,但是void *太不安全了。于是又想到,在Any类的内部创建一对父子类,子类是模版类,成员变量为父类指针,在Any的构造函数中new一个子类对象用父类指针管理,就能够实现简易的Any类。
Connection
Connection模块是子模块中最复杂的模块,是对Buffer、Socket、Channel、Any、模块的整合,还关联了EventLoop模块。
大致功能就是:
- 设置任务回调函数(连接创建成功的回调,消息到来的回调,任意事件回调 . . . . . .);
- 发送/读取数据;
- 开启/关闭非活跃连接销毁;
- 关闭连接;
- 切换协议。
Connection模块所有的对外提供的接口在调用时都要判断是否和副Reactor线程是同一个线程,是则直接执行,不是则压入队列。但是对于关闭连接的操作,无需进行判断,应该直接压入队列,关闭连接必须要在所有的事件触发函数执行完之后,在队列中执行。
假设有一种场景:非活跃连接销毁时间是10s,1、2、3、4、5号都有事件触发,1号事件执行了20s,那么timerfd就超时了20次,假设2、3、4中有一个就是timerfd事件,然后指针走了20下,再然后后面还没来得及执行的事件的连接就被销毁了,此时再去执行触发事件就会发生错误。所以关闭连接的操作必须要在触发事件全部调用完之后,在任务队列中执行。
Acceptor
Acceptor模块也就是主Reactor模块,负责获取新连接,内部有一个EventLoop和一个Channel来管理监听套接字。
LoopThread
该模块将EventLoop和线程强绑定在了一起。为什么非要这么做呢?
因为EventLoop模块在初始化的时候获取当前线程ID,那么用户可能在一个线程内部创建好几个EventLoop,然后再将这几个EventLoop分配给其它线程,这时虽然一个EventLoop占一个线程,但此时EventLoop内部的线程ID和实际所处的线程ID是不一样的。
LoopThreadPool
将LoopThread模块封装成一个线程池,更加方便了服务器对于LoopThread数量的掌控。
TcpServer
是对所有模块的整合,但主要的成员也就是一个主Reactor(一个EventLoop和一个Acceptor)、一个LoopThreadPool。
主要功能有:
- 设置任务回调函数(连接创建成功的回调,消息到来的回调,任意事件回调 . . . . . .);
- 设置LoopThreadPool的线程数量;
- 开启非活跃连接销毁;
- 添加定时任务。
HTTP服务器模块
Util
该模块提供了一些工具函数,比如字符串分割函数、读文件、写文件、编码、解码等。
Request和Response
该模块存放了解析后的Http请求报文数据,并且还提供了一些方法能够快速获取Request数据。
Response
该模块存放了解析后的Http响应报文数据,并且还提供了一些方法能够快速获取Response数据。
Context
该模块是接收Request的上下文模块,服务端接收到的数据有可能并不是一个一条完整的Http报文,所以需要该模块来记录下接收Http报文的过程(上下文)。
HttpServer
对上面所有模块的整合,并且设置了不同的Http请求与回调方法的映射。