目录
Reactor
概念
分类
单Reactor单线程
单Reactor多线程
多Reactor多线程
项目介绍
项目规划
模块关系
实现
TimerWheel -- 时间轮定时器
定时器系统调用
时间轮设计
通用类型Any
Buffer
Socket
Channel
Poller
EventLoop(核心)
eventfd
设计思路
Connection
Accept
LoopThread
LoopThreadPool
TcpServer
EchoServer
测试
性能测试
HTTPServer
Reactor
概念
Reactor 模式是一种事件驱动的设计模式,广泛应用于高并发、异步I/O场景下的系统设计。它通过将请求分发到适当的处理程序来管理输入/输出操作,并使得单线程能够高效地处理大量的连接。
核心组件
Reactor (反应器)
- 负责监听并接收所有类型的事件(如读就绪、写就绪等),然后将其分派给相应的处理器进行处理。它是整个模式的核心协调者。
Handlers (事件处理器)
- 每种具体的事件都有对应的Handler负责实际业务逻辑的执行。例如,“客户端数据到达”的事件由专门的数据读取Handler完成任务;而“数据发送完毕”则交由另一组Handler去清理资源或做其他后续工作。
Demultiplexer (多路复用器)
- 这是一个抽象的概念,在操作系统层面通常指代像select(), poll() 或 Linux 特有的 epoll() 等 API 。它的作用是从众多文件描述符里筛选出那些当前已经准备好可以进行 I/O 操作的对象集合交给上层应用继续加工。
工作流程
当一个新事件发生时,比如有新的网络连接进来或者是某个套接字变得可读了:
- Demultiplexer会检测到这个变化并将相关信息通知给Reactor;
- Reactor根据预先设定好的规则找到匹配此类型事件的那个特定handler实例;
- 最终把控制权转移过去让那个 handler 执行其职责范围内的事务。
这种架构非常适用于需要同时监控大量独立来源的情况之下,因为它避免了大量的阻塞等待时间浪费掉CPU周期数目的情况出现。
分类
单Reactor单线程
一个单独的工作线程配合一个反应器(Reactor),管理所有客户端连接和请求。
- 通过IO多路复⽤模型进⾏客⼾端请求监控
- 触发事件后,进⾏事件处理
- 如果是新建连接请求,则获取新建连接,并添加⾄多路复⽤模型进⾏事件监控。
- 如果是数据通信请求,则进⾏对应数据处理(接收数据,处理数据,发送响应)
在这个模型里,所有的I/O操作都是非阻塞(non-blocking)形式完成,并依赖于底层操作系统提供的事件通知功能(如select、poll或epoll等,我们使用的是epoll),当某个套接字(socket)准备就绪时会触发对应的读取(read) / 写入(write)回调函数来实际处理数据交换任务。由于只存在唯一的一条执行路径负责接收新来的链接以及服务于现存活跃连接上的交互动作。
优点:该架构相对简单易于理解和维护,同时也能保证较高的性能水平,特别是在高负载条件下表现尤为突出,因为避免了频繁创建销毁线程带来的开销,也无需考虑线程安全的问题。
缺点:在面对计算密集型业务场景下它的劣势便显现出来了——如果当前正在运行的任务耗用了过多CPU资源,则可能会导致其他等待服务队列中的项目延迟响应时间过长,并且无法及时获取新连接,用户体验差;此外对于多核处理器环境而言无法充分利用硬件并行运算能力也是其固有缺陷之一。
单Reactor多线程
- Reactor线程通过I/O多路复⽤模型进⾏客⼾端请求监控
- 触发事件后,进⾏事件处理
- 如果是新建连接请求,则获取新建连接,并添加⾄多路复⽤模型进⾏事件监控。
- 如果是数据通信请求,则接收数据后分发给Worker线程池进⾏业务处理。
- ⼯作线程处理完毕后,将响应交给Reactor线程进⾏数据响应
这是最基础的一种形式,主线程负责监听所有的事件源(如Socket连接),当有事件到达时将其分发到预先创建好的一组工作者线程池中去实际处理业务逻辑。
优点:这种方式能够充分利用现代CPU多核优势,并行化地完成复杂的计算任务。
缺点:在高并发场景下,单一的Reactor即使不进行业务处理,但是也可能来不及处理大量新连接,导致延迟增加。虽然IO操作是线程池处理的,但是单Reactor处理能力受限于主线程,当有大量的事件需要分发时,主线程可能无法及时处理,导致事件积压。这时候线程池的线程可能都处于空闲状态,而主线程却忙不过来,系统整体的吞吐量就被限制了。
另外,线程之间的切换开销也是一个问题。虽然工作线程池可以处理多个任务,但线程数量增多的话,上下文切换的成本也会上升,尤其是在高并发场景下,频繁的线程切换可能导致CPU资源被大量消耗,反而降低了效率。
多Reactor多线程
- 在主Reactor中处理新连接请求事件,有新连接到来则分发到⼦Reactor中监控
- 在⼦Reactor中进⾏客⼾端通信监控,有事件触发,则接收数据分发给Worker线程池
- Worker线程池分配独⽴的线程进⾏具体的业务处理
- ⼯作线程处理完毕后,将响应交给⼦Reactor线程进⾏数据响应
相较于第一种结构,此版本引入了专门用于接收新请求的“Acceptor”角色以及若干个独立运行的子级Reactors实例。每个次级单元各自维护着一部分已经建立起来的会话链接并对其发生的各类操作做出反应;同时为了均衡负载压力还可能涉及到动态调整分配比例等策略优化措施。这种分离机制不仅增强了可伸缩性和稳定性而且也便于模块化的管理与扩展升级等工作开展实施。
注意:
- 并不意味着线程越多,服务器效率越高,因为线程越多,CPU线程上下文的切换就会越多,锁资源竞争越激烈,串行多了,并发效率降低,并且CPU会因为大量的线程切换反而导致整体效率降低
- 所以有些设计是将业务线程去除,融合到从属的Reactor的IO事件监控线程中,即从属的Reactor需要IO事件监控、IO操作、业务处理三个任务
项目介绍
本项目中,采用的是多Reactor多线程模式,一个Reactor对应一个线程,主Reactor只负责新连接的获取,多个从属Reactor进行连接的IO事件监控与业务处理,没有采取线程池处理IO事件,主要是考虑线程数量过多反而导致CPU计算效率降低,频繁的上下文切换。
项目规划
整个项目划分为两个大模块:
- Server模块:实现基于事件驱动的主从Reactor模型的TCP服务器。
- 协议模块:对当前的Reactor服务器提供应用层协议支持
其中Server模块最关键,在这个模块中,主要是要对于所有的链接以及线程进行管理,管理的主要内容有下面的三个方面
- 监听连接管理:有新的连接到来时,要对于新的连接情况进行管理
- 通信连接管理:对于通信连接要进行管理
- 超时连接管理:当有连接处于超时状态时,要对于这些已经超时的连接进行管理
所以基于上面的这三个主要的管理方式,又可以构建出下面的一些更加细致化的模块
Buffer模块:缓冲区模块。
功能:用于实现C/S通信中服务端的用户态的接收缓冲区和发送缓冲区功能。
意义:在实际的TCP服务器进行通信的时候,有可能会出现读取上来的数据并不是一个完整的报文,在发送的时候可能TCP的发送缓冲区已满,所以要对于这些没有完全就绪的数据进行一个暂时的缓存管理的过程,所以要单独设计出一个Buffer模块,主要的功能就是要实现通信套接字的用户态缓冲区
Socket模块:套接字模块
功能:对套接字操作做封装的模块,有socket、bind、listen、connect、accept、recv、send、创建一个服务端连接、创建一个客户端连接等接口。
意义:使得程序中对套接字的各项操作更加简便。
Channel模块
功能:对一个描述符进行IO事件监控管理的模块,实现对描述符可读、可写、错误、等事件的管理操作,当事件就绪后,对就绪的监控事件的处理也是由Channel模块来负责的。
意义:对于描述符的监控事件在用户态更容易维护,以及触发事件后的操作流程更加简便(事件就绪后直接调用Channel类的HandlerEvent函数即可)
Poller模块
功能:对epoll系统调用做封装的模块,对外提供对epoll的IO事件监控的添加、修改、移除、获取就绪事件的接口。
意义:使我们在对描述符进行事件监控的操作更加简单。
TimerWheel模块:定时器模块
功能:向定时器添加一个任务后,该任务会在指定的时间后被执行,同时也可以刷新定时任务来延迟任务的执行事件。
设计:我们采用时间轮思想,即创建一个60个元素大小的数组(表示【0, 59】秒),并定义一个秒针变量(下标初始指向0),该秒针会一秒钟移动一个元素,移动到哪里就释放哪里的任务
意义:这个模块是对Connection对象的生命周期管理,一个连接在规定的标准时间内没发生任何时间,则该连接时非活跃连接,服务器没必要维护一个非活跃连接,这会浪费系统资源,所以超时后会自动释放连接。
EventLoop模块
功能:进行连接管理的整合模块(对连接的事件监控以及超时任务的管理都是由该模块提供接口),也就是我们所说的one thread one eventloop中的loop,即从属Reactor,这个模块与线程一一对应。
设计:EventLoop中会组合一个定时器模块、Poller模块,用来对描述符进行IO事件监控操作、定时任务操作,并且在模块中还会设置一个任务队列,该任务队列保证一个连接的所有操作都应该在同一个线程内部执行,所以对于需要在线程内部执行的函数,我们都封装为一个可调用对象,push到EventLoop的任务队列中,这样就保证了对一个链接的所有操作都在同一个线程内部执行费,避免了线程安全而不得为每一个连接加锁最终导致效率极低的问题。
由于任务队列中的人物都是在事件就绪后产生的,所以如果epoll中没有事件就绪,那么从属Reactor执行流就会阻塞在epoll_wait中,导致任务队列的任务迟迟得不到执行,为了避免发生这个问题,我们又创建了一个eventfd,将eventfd的读事件也加入事件监控中,一旦我们向任务队列中添加了一个任务,那么我们就需要向eventfd中写入一个数据,此时epoll_wait至少会因为eventfd的读事件就绪而返回就绪事件,在执行完就绪事件后,就可以执行任务队列中的任务了。
意义:对于服务器的所有事件都是由EventLoop模块完成的,每一个新获取上来的连接都会创建一个Connection对象,每个Connection对象都会绑定一个EventLoop模块和一个线程,这样就可以避免线程安全问题,因为一个连接的所有操作都只在一个线程内执行。
Any模块
功能:用于适配所有上层协议,不同的协议对应的上下文类型不同,我们需要提供接口供上层用户修改支持的协议
意义:供上层用户修改支持的协议
Connection模块
功能:对一个通信连接整体管理的模块,对一个连接的所有操作都是通过这个模块进行的,Connection类包含一个EventLoop的指针对象,可以通过该指针对连接进行监控管理、定时任务管理。在Connection实例化对象时,在构造函数中为对每一个连接设置读、写、错误、关闭、任意事件回调函数。
意义:是对一个已经被获取上来的连接做管理的模块,增加链接操作的灵活和便捷性。
Acceptor模块
功能:对监听套接字做管理的模块
意义:当获取了一个新连接的描述符之后,需要为该通信连接封装一个Connection对象,为该连接设置各种回调函数(读、写、错误、任意、多个阶段回调函数)。
Acceptor模块本身并不知道一个连接产生了某个事件后该如何处理,因此获取一个通信连接后,Connection对象的创建以及各个阶段回调函数设置都是在服务器模块进行的。
模块关系
项目规划中展示了各个模块的基本功能和大致设计,但是整体来说还是思维较乱,所以下面我用几张图来把这些模块之间的关系构建出来。
Connection模块的逻辑图
Connection通信模块图中包含四个大模块:
- Buffer缓冲区模块
- Socket套接字模块
- Channel描述符事件管理模块
- TcpServer设置的四个阶段回调函数
Connection通过组合Buffer类、Socket类以及Channel类对象,设计出连接的读回调、写回调、错误回调、关闭回调以及任意事件回调,并在构造函数中对Channel的五个事件回调函数进行bind设置,以达到每一个Connection对象在被实例化之后,都有对应的事件就绪的回调函数。
其中Socket在五个回调函数的实现中发挥了从TCP接收缓冲区读数据、向TCP发送缓冲区写数据、关闭套接字这三个作用。
Channel发挥了当需要向TCP发送缓冲区写入数据时监控连接的写事件,数据写完之后取消监控描述符的写事件,以及当连接关闭时移除连接的事件监控这几个作用。
Buffer则起到了缓冲数据的作用,因为从TCP接收缓冲区读数据时读上来的并不是一个完整的报文,所以此时需要用户缓存,当发送数据时TCP的发送缓冲区可能已经满了,此时写数据就会阻塞,所以也需要缓冲区等待写事件就绪。
并且Connection类中还有EventLoop指针指向一个EventLoop,所以Channel对象对外提供连接的监控接口实际上就是通过EventLoop组合的Poller类对象实现的,因为一个线程对应一个EventLoop,一个EventLoop对应一个计时器模块和一个epoll,如果一个连接属于该EventLoop,则该连接的事件监控就应该被设置在EventLoop的epoll中。所以Connection对外提供的事件监控操作、定时任务操作接口都是通过EventLoop指针间接调用的Poller接口、TimerWheel接口,例如启动、取消非活跃连接销毁,刷新活跃度这几个接口函数。
Acceptor模块逻辑图
对于Acceptor类来说,我们需要单独为其设置 一个读回调函数,因为这是一个监听连接,并不是一个通信连接,对于通信连接所设置的读回调是将TCP接收缓冲区数据拷贝到inbuffer中,而listen读事件就绪应该调用accept从全连接队列获取新连接,为新连接创建struct file等内核数据结构分配文件描述符,所以Acceptor类需要组合Channel来管理listen的文件描述符读事件,需要组合Socket类来调用accept接口。
但是Acceptor模块本身并不知道监听连接读事件就绪后该如何处理,因此获取一个通信连接后,Connection对象的创建以及各个阶段回调函数设置都是在服务器模块进行的,所以需要TcpServer模块传入OnConnected回调函数进行设置后续动作。
EventLoop模块逻辑图
在第一个Connection的逻辑图中我们已经讲到了EventLoop的组合关系:一个线程对应一个EventLoop,一个EventLoop对应一个计时器模块和一个epoll,各个从属Reactor互不干扰,避免了因线程安全而不得不加锁,从而导致整体效率降低问题。
Channel包含了EventLoop指针,Connection也包含了EventLoop指针,并且Connection又组合了Channel对象管理Connection对应连接的文件描述符的事件监控任务,Connection又对Channel设置了五个回调函数,所以Channel也相当于回指了Connection,至此
Channel :Connection :EventLoop = m : m : n (m >> n)
(m指通信连接的数量,n指从属Reactor的数量)
实现
TimerWheel -- 时间轮定时器
定时器系统调用
在进行TimerWheel 模块中,需要用到一个定时器的概念,而在之前的内容中没有对于这部分内容进行总结,因此这里对于这个定时器的内容进行学习,首先认识一下定时器的相关接口信息:
#include <sys/timerfd.h>
int timerfd_create(int clockid, int flags);
int timerfd_settime(int fd, int flags, const struct itimerspec *new_value, struct itimerspec *old_value);// 参数中包含的结构体
struct timespec {time_t tv_sec; /* Seconds */long tv_nsec; /* Nanoseconds */
};struct itimerspec {struct timespec it_interval; /* Interval for periodic timer */struct timespec it_value; /* Initial expiration */
};
timerfd_create函数,这个函数的功能是创建一个定时器
- 对于第一个参数clockid来说,有两个选项:
- CLOCK_REALTIME:表示的是以系统的时间为基准值,这是不准确的,因为如果系统的时间出现了问题可能会导致一些其他的情况出现
- CLOCK_MONOTONIC:表示的是以系统启动时间进行递增的一个基准值,也就是说这个时间是不会随着系统时间的改变而进行改变的
- 第二个参数是flag,也就是所谓的标记位,这里我们选择是0表示的是阻塞操作
函数的返回值是一个文件描述符,因为Linux下一切皆文件,所以对于这个函数来说其实就是打开了一个文件,对于这个定时器的操作就是对于这个文件的操作,定时器的原理其实就是在定时器的超时时间之后,系统会给这个描述符对应的文件定时器当中写入一个8字节的数据,当创建了这个定时器之后,假设定时器中创建的超时时间是3秒,那么就意味着每3秒就算是一次超时,那么从启动开始,每隔3秒,系统就会给描述符对应的文件当中写入一个1,表示的是从上一次读取到现在超时了1次,假设在30s之后才读取数据,那么会读上来的数据是10,表示的是从上一次读取到现在实践超出限制了10次
timerfd_settime函数,启动定时器
- 函数的第一个参数是第一个函数的返回值,这个文件描述符其实也是创建的定时器的标识符
- 第二个标记位表示的是使用的是相对时间,默认给0
- 后面的两个参数也很好理解,表示的是新的时间和旧的时间,不需要就置空即可
下面写一份实例代码:
#include <iostream>
#include <unistd.h>
#include <sys/timerfd.h>
using namespace std;int main()
{// 创建一个定时器int timerfd = timerfd_create(CLOCK_MONOTONIC, 0);struct itimerspec itm;// 设置第一次超时的时间itm.it_value.tv_sec = 1;itm.it_value.tv_nsec = 0;// 设置第一次超时后,每隔多长时间超时一次itm.it_interval.tv_sec = 1;itm.it_interval.tv_nsec = 0;timerfd_settime(timerfd, 0, &itm, nullptr);while(true){uint64_t tmp;int ret = read(timerfd, &tmp, sizeof(tmp));if(ret < 0)return -1;cout << "超时的次数:" << tmp << endl;sleep(3);}close(timerfd);return 0;
}
上面的例子,就是一个定时器的使用实例,借助这个使用的例子就可以进行判断出每隔1秒超时器就会超时一次,然后向文件中写入一个1,我们这里sleep了3秒,所以后面的读取数据中都是超时了3次 。
时间轮设计
在上述的例子当中,存在一个比较大的问题,每次超时都要把所有的连接遍历一次,这样的效率是比较底下的,所以衍生出了一个新的方案,时间轮:
现在定义一个二维动态数组(vector<vector<Task>>),其中包含一个秒针下标,秒针指向的是数组的起始位置,这个秒针每秒钟向后走一步,走到哪里就代表哪里的任务需要被执行了,那么如果想要定一个3秒之后的任务,那么只需要将任务添加到当前秒针的后三个元素的位置,让其每秒钟走一格,那么走到对应的位置就可以执行对应位置的任务即可。
但是时间轮的类型设计依旧不合适
如果vector<vector<Task>>只存放Task,那么秒针走到哪里就执行哪里的任务,这看起来并没有什么问题,但是关键点在于如何刷新任务?
如果要将旧的超时任务“拿出来”并根据当前秒针位置向后偏移超时时间,然后插入,看似简单但实际很难操作
- 如何知道旧的超时任务在哪里?我们是根据秒针的位置push_back到秒针所处的一个vector中,而秒针一直在移动(不能刻舟求剑吧)
- 知道了在哪个vector中,那么怎么找到哪个元素是我们的定时任务?大家类型都相同,都是一个没有参数的可调用对象
即使上面两个问题都能实现,那也定然需要使用更多的容器、内存来辅助,但是我们有一个更巧妙的方法:存放shared_ptr智能指针 vector<vector<std::shared_ptr<TimerTask>>>
存放一个管理超时任务对象资源的shared_ptr智能指针,这是因为如果一个非活跃的连接在即将超时时,突然事件就绪了,那么该连接的活跃度应该刷新,那么为了实现刷新功能,我们使用了shared_ptr智能指针的引用,如果有两个shared_ptr共同管理一个资源,那么只有两个shared_ptr都结束生命,最终才会delete管理的资源,并调用管理资源的析构函数。
我们将超时后发生的事件放在了TimerTask的析构函数中,将任务的执行与TimerTask对象的生命周期进行绑定,一旦TimerTask生命周期结束,那么自动调用析构函数,所以在析构的时候就会调用我们的超时事件,所以可以巧妙的与shared_ptr结合,实现任务的刷新功能,只需要将之前的shared_ptr拷贝一个shared_ptr,再添加到一个新的超时时间中,那么第一个shared_ptr销毁时由于引用计数不为0,并不会释放资源。
关键点:为了能在之后刷新时,找到上一个超时时间的shared_ptr智能指针,我们需要一个容器来存储事件对应的shared_ptr,这里又有一个关键,我们不能在容器中使用shared_ptr做管理,因为这会增加引用计数!所以需要使用weak_ptr,weak_ptr可以使用资源但是不会增加引用计数
最后的clear也是一个关键点,clear会删除容器内所有元素,删除的原理是vector的赋值运算符重载函数在赋值之前会delete释放自身的资源,这就会调用数据shared_ptr的析构函数(内部再判断--引用计数是否为0),从而deleteTimerTask,调用TimerTask的析构函数,进而执行我们期待的超时执行任务。
实现
TimerTask:
- 需要对外提供一个接口,用来设置析构时应该回调的函数。
- 因为TimerWheel中timers哈希表表示一个任务是否还在等待超时,如果TimerTask调用了析构函数,就表明没有shared_ptr管理TImerTask了,所以也需要将timers中记录的信息删除,我们将删除这一操作也放到TImerTask的析构函数中处理。
TimerWheel:
- capacity来指明时间轮的大小,即最大可以设置的超时时间,我们默认为60,因为一个连接一般最多非活跃60s,我们就需要释放连接了,否则干耗着资源不使用,降低服务器效率。
- 一个EventLoop包含一个计时器模块,所以EventLoop对外提供的计时器接口实际上是间接调用的计时器模块的接口
- 为了保证1s钟我们的秒针运动一次,我们使用内核的timerfd来控制,监控timerfd的读事件
- 为了管理timerfd我们需要一个channel对象
- 在构造函数中,直接bind设置timerfd的读回调为类内的OnTime成员函数,并监控它的读事件
- 我们在TcpServer中会使用id唯一标识一个连接,所以我们在定时器使用时只需要传入该连接的id、延迟时间以及超时任务即可
- 对外提供对conn_id的添加超时任务、刷新超时任务、删除超时任务、判断超时任务是否存在四个接口
如何实现1s钟我们的指针后移一位,也就是执行一次Run函数?
显然不能写一个死循环,Run之后sleep 1s。这一方面会阻塞执行流,另一方面并不能保证其他操作以及Run耗时为0。
这里就需要使用到我们上面讲解的 tiemrfd,我们将create一个timerfd,设置内核每1s向timerfd写入一个8字节大小的1,并通过channel对象回指的EventLoop,间接调用EventLoop中的Poller接口,将tiemrfd的读事件放到epoll中进行监控,那么一旦timerfd读事件就绪了,就表明距离上一次已经过去了1s或多s,会过去多s这是因为一个EventLoop的epoll会监控大量事件(一般一个从属Reactor能支持1w - 10w的并发量),所以timerfd的就绪事件可能在很靠后的位置,那么从属Reactor执行前面的就绪事件并进行业务处理可能会耗时多秒,但是没有关系,因为内核每1s都会向timerfd写入一个8字节的1,所以只要在timerfd的读回调中根据读到的timer次数,来决定秒针移动几步即可。
using TaskFunc = std::function<void()>;
using ReleaseFunc = std::function<void()>;
// 每一个超时任务
class TimerTask
{
private:uint64_t _id; // 标识任务iduint32_t _timeout; // 任务需要设置的延迟事件TaskFunc _task_cb; // 当超时后需要做的任务,void()类型函数,所以参数由用户自己bind传入ReleaseFunc _release; // 当该任务被执行时,需要将TimerWheel中的_timers哈希表中删除我这个任务,表明我任务完成了,不存在了bool _iscancel; // 是否取消了该任务,false 表示任务没有被取消,true表示任务被取消了
public:TimerTask(uint64_t id, uint32_t timeout, const TaskFunc &cb) : _id(id), _timeout(timeout), _task_cb(cb), _iscancel(false) {}~TimerTask() { if (_iscancel == false)_task_cb();_release();}// 对外提供设置release回调函数的接口void SetRelease(const ReleaseFunc& cb) { _release = cb;}void Cancel() { _iscancel = true; }uint32_t GetTimeout() { return _timeout; }
};// shared_ptr就是设计精髓,可以让计时的事件刷新
class TimerWheel
{
private:int _tick; // 秒针int _capacity; // 控制时间轮的长度,秒单位std::vector<std::vector<std::shared_ptr<TimerTask>>> _wheel; // 时间轮std::unordered_map<uint64_t, std::weak_ptr<TimerTask>> _timers; // 用来寻找之前的shared_ptr,但是需要用weak_ptr接收,不能让_timers参与引用计数int _timerfd; // 定时器描述符EventLoop* _loop; // 回指到EventLoop,使用EventLoop的接口设置监听事件std::unique_ptr<Channel> _timer_channel; // 管理timerfd的连接事件
private:void TimerErase(uint64_t id){auto it = _timers.find(id);if (it == _timers.end())return;_timers.erase(it);}static int CreateTimerfd(){// 创建int timerfd = timerfd_create(CLOCK_MONOTONIC, 0);if (timerfd < 0){ERR_LOG("timerfd create error!");abort();}struct itimerspec itime;// 设置超时时间itime.it_value.tv_sec = 1;itime.it_value.tv_nsec = 0;// 设置超时后的下一次超时时间itime.it_interval.tv_sec = 1;itime.it_interval.tv_nsec = 0;// 设置进内核timerfd_settime(timerfd, 0, &itime, nullptr);return timerfd;}// 当timerfd读事件就绪了,就把数据堵上来,不然的话epoll会一直就绪int ReadTimerfd(){uint64_t times;int ret = read(_timerfd, ×, sizeof(times));if (ret < 0){ERR_LOG("timerfd read error!");abort();}return times;}// 将秒针每秒加一,如果有任务就执行,实际动作就是clearvoid Run(){_wheel[_tick].clear(); // 释放空间意味着shared_ptr被调用析构函数,启动对应任务_tick = (_tick + 1) % _capacity; // 秒针往后走}// 1s时间到喽!内核会向timerfd写入一个1,所以此时epoll事件就绪,进行秒针 + 1void OnTime(){// 每次时间到了,把数据读一下,如果不读的话,epoll会一直就绪int times = ReadTimerfd();// 然后再执行超时任务,秒针后移
/* 因为有可能一个连接的业务处理时间很长,例如30s,那么在这30s内秒针应该走30次,但是由于从属线程一直在执行业务处理,而没空去读取timerfd,所以当真正秒针在移动并执行超时任务时,需要知道内核通知了几次,通知了几次就表明过去了几秒,也就意味着Run几次
*/for (int i = 0; i < times; i++){Run();}}// 向时间轮中添加超时事件void AddTimerTaskInLoop(uint64_t id, uint32_t delay, const TaskFunc& cb){std::shared_ptr<TimerTask> pt(new TimerTask(id, delay, cb));pt->SetRelease(std::bind(&TimerWheel::TimerErase, this, id));int pos = (_tick + delay) % _capacity;_wheel[pos].push_back(pt);_timers[id] = std::weak_ptr<TimerTask>(pt);} // 刷新超时时间void ReflushTimerTaskInLoop(uint64_t id){// 先判断之前在_timers中有没有记录auto it = _timers.find(id);if (it == _timers.end())return;// 有记录则获取上一次计时任务的shared_ptrstd::shared_ptr<TimerTask> pt = it->second.lock(); // 获取哈希表中存放的weak_ptr,使用lock函数获取weak_ptr共享的shared_ptr对象// 更新超时时间,放到新的时间轮内int pos = (_tick + pt->GetTimeout()) % _capacity;_wheel[pos].push_back(pt);}/* 取消一个TimerTask注意:不能直接从时间轮中删除,因为删除操作就意味着shared_ptr自动调用析构函数,就扭曲了我们的意愿,变为了提前执行超时时间,而不是取消一个超时时间所以,我们要在TimerTask中添加一个字段iscancel,表明该事件是否被取消了,如果被取消了,就不要在析构函数中执行方法*/void CancelTimerTaskInLoop(uint64_t id){auto it = _timers.find(id);if (it == _timers.end()) return;std::shared_ptr<TimerTask> pt = it->second.lock();if (pt) pt->Cancel();}
public:TimerWheel(EventLoop* loop) : _tick(0), _capacity(60), _wheel(_capacity) , _loop(loop), _timerfd(CreateTimerfd()), _timer_channel(new Channel(_loop, _timerfd)) {// 将tiemrfd加入事件监听,因为内核每1秒钟使timerfd超时一次,即事件就绪一次,所以就调用我们绑定的回调函数OnTimer,执行所有超时任务并将秒针+1_timer_channel->SetReadCallback(std::bind(&TimerWheel::OnTime, this));_timer_channel->EnableRead();}/*如果多个线程同时操作成员timers哈希表,那么一定会引发线程安全问题,我们当然可以加锁,但是加锁会导致效率降低,我们此时采用空间换时间的方式,也将定时器的所有操作任务都放到同一个EventLoop的任务队列中,在一个线程中串行执行任务不会引发线程安全问题*//*因为下面三个函数用到了_loop的成员函数,所以需要在EventLoop类的下面进行类外定义*/void AddTimerTask(uint64_t id, uint32_t delay, const TaskFunc& cb);void ReflushTimerTask(uint64_t id);void CancelTimerTask(uint64_t id);// 该定时任务是否存在bool HasTimer(uint64_t id){auto it = _timers.find(id);if (it == _timers.end())return false;return true;}
};
通用类型Any
对于Connection来说,它的工作任务之一是要对于连接进行管理,那么就意味着这个模块是会涉及到对于应用层协议的处理的,因此在Connection中要设置协议处理的上下文来控制处理节奏
应用层的协议是有很多的,平时使用最多的是http协议,不过也会有例如ftp协议这样的存在,而为了使得本项目可以支持的协议足够多,那么就意味着不能固定写死类型,而是可以存储任意协议的上下文信息,因此就需要设计一个通用类型来保存各种不同的数据
我们想要做成的效果是,这个Any类,可以接受各种类型的数据,例如有这样的用法:
Any a;
a = 10;
a = "abc";
a = 12.34;
...
那该如何设计这个通用类型Any?
这里参考了一种嵌套类型,在一个类中嵌套存在一个新的类,在这个类中存在模板,而进而对于类进行处理
class Any
{
private:class holder{// ...};template <class T>class placeholder : public holder{T _val;};holder *_content;
};
在这个Any类中,成员保存的是holder类的指针,当Any类容器需要保存一个数据的时候,只需要通过placeholder子类实例化一个特定类型的子类对象出来,让这个子类对象保存数据即可,具体原理为多态:
Any保存的是一个父类指针,那么子类重写父类的虚函数,通过父类指针调用虚函数时就会实现动态的多态,这就是Any的原理。
实现
注意:
- 拷贝构造、赋值运算符重载的现代写法
- 拷贝时需要借助placeholder提供的clone方法,因为拷贝构造的其实是placeholder
- 对外提供get方法,获取placeholder的对象,即协议上下文
class Any
{
private:class holder {public:virtual ~holder() {}virtual const std::type_info& type() = 0;virtual holder *clone() = 0;};template<class T>class placeholder: public holder {public:placeholder(const T &val): _val(val) {}// 获取子类对象保存的数据类型virtual const std::type_info& type() { return typeid(T); }// 针对当前的对象自身,克隆出一个新的子类对象virtual holder *clone() { return new placeholder(_val); }public:T _val;};holder *_content;
public:Any():_content(NULL) {}template<class T>Any(const T &val):_content(new placeholder<T>(val)) {}Any(const Any &other):_content(other._content ? other._content->clone() : nullptr) {}~Any() { delete _content; }Any &swap(Any &other) {std::swap(_content, other._content);return *this;}// 返回子类对象保存的数据的指针template<class T>T *get() {//想要获取的数据类型,必须和保存的数据类型一致assert(typeid(T) == _content->type());return &((placeholder<T>*)_content)->_val;}//赋值运算符的重载函数template<class T>Any& operator=(const T &val) {//为val构造一个临时的通用容器,然后与当前容器自身进行指针交换,临时对象释放的时候,原先保存的数据也就被释放Any(val).swap(*this);return *this;}Any& operator=(const Any &other) {Any(other).swap(*this);return *this;}
};
测试:
Buffer
作为缓冲区模块,Buffer是实现通信用户的接收缓冲区和发送缓冲区域的功能。我们采用的是vector容器来实现Buffer,并没有采用stirng,因为我们会频繁的在Buffer中存放数据,取出数据,如果使用string的substr,那么就会产生大量的数组移动,这是降低效率的,虽然vector添加或删除一部分数据也会发生大量的数组元素移动构,但是我们会使用读、写下标维护一个可读空间,取出数据是只需要移动读下标即可,string更偏向于字符串的操作,而对于缓冲区来说用数组来实现更为合适。
Buffer负责两个功能:
- 写入数据,从写下标开始,将需要写入输入缓冲区的数据拷贝到写下标开始的空间中
- 读取数据,从读下标开始,将用户需要的指定长度的数据返回,前提是读下标不能超过写下标
成员变量:
- vector
- 读下标
- 写下标
对外接口:
因为我们对已经无效的数据并不采取删除操作,当有新数据到来后直接覆盖写入即可,所以我们维护的两个下标会在vector中划分出三个区域:
[0, read_pos) [read_pos, write_pos) [write_pos, vector.size() - 1]
从缓冲区读取数据时从读下标开始读,向缓冲区写数据时从写下标开始。
向缓冲区写数据时,尾部的空闲空间可能不足,此时需要判断尾部空闲空间加上头部的空闲空间是否满足写入的数据大小需求,如果满足,则将读下标到写下标内的数据移动至0下标,然后写入数据。
如果头部空间空间+尾部空闲空间不能满足写入数据的大小,则不移动数据,字节resize扩容vector,这样就避免了频繁移动数组元素的效率问题。
- 获取当前写位置地址以及const版本
- 获取当前读位置地址以及const版本
- 尾部空闲空间大小
- 头部空闲空间大小
- 获取可写数据大小
- 获取可读数据大小
- 读下标后移
- 写下标后移
- 扩容函数
- 从缓冲区读数据
- 向缓冲区写数据
- 获取一整行数据。方便后期处理协议
- 清理功能
实现
class Buffer
{static const int default_size = 1024;private:std::vector<char> _buffer; // 缓冲区uint64_t _write_pos; // 写下标uint64_t _read_pos; // 读下标public:Buffer() : _buffer(default_size), _write_pos(0), _read_pos(0) {}void Clear() { _write_pos = _read_pos = 0; }// 返回写下标空间地址char *WritePostion() { return &*(_buffer.begin() + _write_pos); }// 返回读下标空间地址char *ReadPosition() { return &*(_buffer.begin() + _read_pos); }// const版本返回写下标空间地址const char *WritePosition() const { return &*(_buffer.begin() + _write_pos); }// const版本返回读下标空间地址const char *ReadPosition() const { return &*(_buffer.begin() + _read_pos); }// 尾部空闲大小uint64_t TailSize() const { return _buffer.size() - _write_pos; }// 头部空闲空间uint64_t HeadSize() const { return _read_pos; }// 获取可写数据大小uint64_t WriteAbleSize() const { return TailSize() + HeadSize(); }// 获取可读数据大小uint64_t ReadAbleSize() const { return _write_pos - _read_pos; }// 读下标后移void MoveReadPos(uint64_t len){// 这里只进行读下标移动,至于空间是否足够让Read函数自行判断assert(len <= ReadAbleSize());_read_pos += len;}// 写下标后移void MoveWritePos(uint64_t len){// 这里只进行写下标移动,至于空间是否足够让Read函数自行判断,程序到了这里空间必然足够assert(len <= TailSize());_write_pos += len;}// 扩容函数void Reserve(uint64_t len){if (len <= TailSize())return;else if (len <= WriteAbleSize()){uint64_t size = ReadAbleSize();// 将所有数据向前移动std::copy(_buffer.begin() + _read_pos, _buffer.begin() + _write_pos, _buffer.begin());_read_pos = 0;_write_pos = size;}else_buffer.resize(_write_pos + len);}// 从缓冲区读数据void Read(void *buf, uint64_t len){// 判断是否有足够数据可读assert(len <= ReadAbleSize());std::copy(ReadPosition(), ReadPosition() + len, static_cast<char*>(buf));// 更新读下标MoveReadPos(len);}// 重载Readstd::string Read(uint64_t len){assert(len <= ReadAbleSize());std::string str(len, '\0');// 注意:不能直接用c_str(),因为这是const char*,不允许通过指针修改内容Read(&str[0], len);// std::cout << "++++++++" << str << std::endl;return str;}// 获取一整行数据std::string Getline(){std::string tmp;uint64_t i = _read_pos;for (; i < _write_pos; ++i){if (_buffer[i] != '\n') tmp += _buffer[i];else break;}// 找不到\n,表明没有完整的一行数据,则返回空if (i == _write_pos) return "";// 否则返回一整行数据,包括\ntmp += _buffer[i++];MoveReadPos(i - _read_pos);return tmp;}// 向缓冲区写数据void Write(const void *data, uint64_t len){if (len == 0) return;if (!data) return;Reserve(len);std::copy(static_cast<const char*>(data), static_cast<const char*>(data) + len, WritePostion());MoveWritePos(len);}// 重载string类型的write函数void Write(const std::string &str){Write(str.c_str(), str.size());}// 重载本类类型的write函数void Write(const Buffer &buffer){Write(buffer.ReadPosition(), buffer.ReadAbleSize());}
};
Socket
对socket系统调用的封装模块,没有什么需要注意的,我们已经在网络部分联系很多次了
对外接口:
- Create创建套接字
- Bind绑定套接字
- Listen监听套接字
- Connect客户端发起连接
- Accept服务端从全连接队列获取新连接
- Recv从TCP的接收缓冲区读取数据
- Send向TCP的发送缓冲区写入数据
- CreateServer创建一个服务端连接
- CreateClient创建一个客户端连接
- NonBlock设置套接字非阻塞
- ReuseAddr设置端口复用,避免因服务器崩溃,导致服务器是先挥手的一方,从而进入TimeWait状态,此时会保留TCP连接的四元组,维持两个MSL时间,如果立即重启服务器进程会导致端口绑定失败,所以我们设置端口复用,可以使服务器进程退出后依然能立即重启
实现
class Socket
{// 全连接队列最大值static const int backlog = 1024;
private:int _sockfd; // 客户端:连接套接字。 服务端:监听套接字
public:Socket() : _sockfd(-1) {}Socket(int sockfd) : _sockfd(sockfd) {}~Socket() { Close(); }void Close(){if (_sockfd != -1){close(_sockfd);_sockfd = -1;}}int Fd() { return _sockfd; }// 创建套接字bool Create(){_sockfd = socket(AF_INET, SOCK_STREAM, 0);if (_sockfd < 0){ERR_LOG("Create socket Error!");return false;}return true;}// 服务端bind套接字,客户端不需要手动bind,避免app之间端口冲突,以及防止app非法绑定多个端口号bool Bind(const std::string& ip, uint16_t port){struct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(port);local.sin_addr.s_addr = inet_addr(ip.c_str());socklen_t addrlen = sizeof(local);if (bind(_sockfd, reinterpret_cast<const struct sockaddr*>(&local), addrlen) < 0){ERR_LOG("Bind socket Error!");return false;}return true;}// 服务器开启套接字监听状态,监听是否有连接请求,成功则放入全连接队列bool Listen(){if (listen(_sockfd, backlog) < 0){ERR_LOG("Listen socket Error!");return false;}return true;}// 服务端从全连接队列取建立成功的连接int Accept(){int newfd = accept(_sockfd, nullptr, nullptr);if (newfd < 0){ERR_LOG("Accept Error!");return -1;}return newfd;}// 客户端向 ip:port 发起TCP连接请求,内核自动完成三次握手bool Connect(const std::string ip, uint16_t port){struct sockaddr_in addr;memset(&addr, 0, sizeof(addr));addr.sin_family = AF_INET;addr.sin_port = htons(port);addr.sin_addr.s_addr = inet_addr(ip.c_str());socklen_t len = sizeof(addr);int n = connect(_sockfd, reinterpret_cast<const struct sockaddr*>(&addr), len);if (n < 0){ERR_LOG("Connect Error!");return false;}return true;}// 接收数据。同样要指明fdssize_t Recv(void* buf, size_t len, int flag = 0){ssize_t n = recv(_sockfd, buf, len, flag);if (n <= 0){// n == 0表示写端关闭,n < 0则错误码被设置if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)return 0;ERR_LOG("socket recv error!");return -1;}return n;}ssize_t NonBlockRecv(void* buf, size_t len){return Recv(buf, len, MSG_DONTWAIT);}// 发送数据。这里有问题:如果是服务端,要指明套接字fd!ssize_t Send(const void* buf, size_t len, int flag = 0){ssize_t n = send(_sockfd, buf, len, flag);if (n <= 0){if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)return 0;ERR_LOG("socket send error!");return -1;}return n;}ssize_t NonBlockSend(const void* buf, size_t len){if (len == 0) return 0;return Send(buf, len, MSG_DONTWAIT);}// 创建一个服务端连接bool CreateServer(uint16_t port, const std::string& ip = "0.0.0.0", bool block_flag = false){// 1. 创建套接字 2. 设置地址、端口复用 3. 绑定套接字 4. 监听套接字if (!Create() || !ReuseAddr() || !Bind(ip, port) || !Listen())return false;// 是否设置套接字非阻塞,即Reactor的ET或LT模式if (block_flag) NonBlock();return true;}// 创建一个客户端连接bool CreateClient(uint16_t port, const std::string& ip, bool block_flag = false){if (!Create() || !Connect(ip, port))return false;if (block_flag) NonBlock();return true;}// 设置套接字为非阻塞bool NonBlock(){int flag = fcntl(_sockfd, F_GETFL);if (flag < 0){ERR_LOG("SetNonBlock Error!");return false;}fcntl(_sockfd, F_SETFL, flag | O_NONBLOCK);return true;}// 设置端口复用,避免因服务端主动断开连接进入TIME_WAIT状态而在短时间内无法启动服务bool ReuseAddr(){int opt = 1;if (setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)) < 0){ERR_LOG("Setsockopt Error!");return false;}return true;}
};
测试
Channel
我们在项目规划中已经讲解,Channel是用来负责对一个描述符IO事件管理的模块,实现对描述符可读,可写,错误...事件的管理操作,以及Poller模块对描述符进⾏IO事件监控就绪后,根据不同的事件,回调不同的处理函数功能。
当epoll中事件就绪了,就执行Channel中提供的事件处理函数,根据相应的就绪事件来调用对应的回调函数。
对外接口:
- 监控读事件
- 监控写事件
- 关闭读事件监控
- 关闭写事件监控
- 在epoll中取消fd的事件监控
- 设置五个回调函数
- HandlerEvent事件就绪处理函数
实现
细节:一个连接只要触发了就绪事件,那么我们就应该调用一次任意事件回调函数,在任意事件回调函数中,我们会为该连接刷新活跃度,并调用上层设置的阶段回调函数(因为可能上层也需要这个业务场景)
那么如果 fd 对应的连接出现了错误,例如对端连接异常关闭,我们recv、send出错了,这时在Channel的HandlerEvent中,我们应该调用该连接的 close 回调函数,关闭这个异常连接,但是又因为在任意事件触发之后,我们都要调用任意事件回调函数,所以先关闭连接再调用任意事件回调会发生段错误,因为连接已经关闭,Connection对象已经销毁,而我们是在Connection中为Channel绑定的函数,连接关闭意味着程序会在HandlerEvent函数中解引用空指针并调用回调函数。
为了解决这个问题,我们当然可以先执行任意事件回调,再去执行关闭连接回调,但是由于后面也引出了一个问题(Connection的Release函数引发的问题),所以我们实际的close并不会直接关闭连接,而是会等待就绪事件处理完毕再释放连接,所以我们可以统一的将任意事件回调放在最后
class Poller;
class EventLoop;
class Channel
{using EventCallBack = std::function<void()>;
private:EventLoop* _loop; // 回指Poller类,从而使用EventLoop中封装的epoll接口进行事件监控int _fd; // 关心事件的文件描述符fduint32_t _events; // 关心的事件uint32_t _revents; // 就绪的事件EventCallBack _read_cb; // 可读事件触发后执行的回调函数 EPOLLINEventCallBack _write_cb; // 可写事件触发后执行的回调函数 EPOLLOUTEventCallBack _error_cb; // 错误事件触发后执行的回调函数 EPOLLERREventCallBack _close_cb; // 连接断开事件触发后执行的回调函数 EPOLLHUPEventCallBack _event_cb; // 任意事件触发后执行的回调函数
public:Channel(EventLoop* loop, int fd) : _fd(fd), _events(0), _revents(0), _loop(loop) {}int Fd() { return _fd; }// 外部获取关心的事件uint32_t GetEvents() { return _events; }// 外部设置就绪事件void SetRevents(uint32_t revents) { _revents = revents; }// 设置回调函数void SetReadCallback(const EventCallBack& cb) { _read_cb = cb; }void SetWriteCallback(const EventCallBack& cb) { _write_cb = cb; }void SetErrorCallback(const EventCallBack& cb) { _error_cb = cb; }void SetCloseCallback(const EventCallBack& cb) { _close_cb = cb; }void SetEventCallback(const EventCallBack& cb) { _event_cb = cb; }// 是否监控了fd的读、写事件bool ReadAble() { return _events & EPOLLIN; }bool WriteAble() { return _events & EPOLLOUT; }// 监控读事件void EnableRead() { _events |= EPOLLIN; Update(); }// 监控写事件void EnableWrite() { _events |= EPOLLOUT; Update(); }// 关闭读事件监控void DisableRead() { _events &= ~EPOLLIN; Update(); }// 关闭写事件监控void DisableWrite() { _events &= ~EPOLLOUT; Update(); }// 关闭监控fd的全部事件void DisableAll() { _events = 0; Update(); }// 在 epoll 中添加、修改、移除 _fd 对应的事件监控// 由于使用到了EventLoop类中封装的Poller类中的函数,而EventLoop类在下面才定义,所以需要将下面两个函数的定义放在EventLoop类下面void Remove();void Update();// 事件分配器,一旦触发了事件,就执行HandlerEvent,具体调用哪个函数由HandlerEvent决策void HandlerEvent(){// EPOLLIN -- 读事件就绪 EPOLLHUP -- 该连接关闭 EPOLLPRI -- 携带带外数据的高优先级事件就绪,这三者都需要进行读回调if ((_revents & EPOLLIN) || (_revents & EPOLLRDHUP) || (_revents & EPOLLPRI)){if (_read_cb) _read_cb();}// 有可能释放连接的操作,一次只能执行一次,避免错误发生if (_revents & EPOLLOUT){if (_write_cb) _write_cb();}else if (_revents & EPOLLERR) // EPOLLERR 表示错误{// 异常的情况连接、套接字都会关闭,所以任意事件回调需要在异常处理之前调用if (_error_cb) _error_cb();}else if (_revents & EPOLLHUP) // EPOLLHUP 表示连接还未建立{if (_close_cb) _close_cb();}// 任意事件回调if (_event_cb) _event_cb();}
};
Poller
同Socket模块一样,是对epoll三个系统调用的一个封装,便于我们后续程序中的调用。
成员:
- _channels,一个维护epoll所监控的所有事件的哈希表,管理文件描述符与对应事件管理的的Channel对象
- epfd,保存创建epoll返回到文件描述符,即epoll的操作句柄
- evs数组,用来保存已经就绪的事件的epoll_event结构体
对外接口:
- 添加或更新对描述符的事件监控
- 移除对描述符的事件监控
逻辑流程:
- 对描述符进行监控,但是需要监控描述符的什么事件,由Channel来提供
- 当描述符的事件就绪之后,应该调用描述符的哪个事件回调,也由Channel来负责
class Poller
{static const int num = 1024;
private:int _epfd; // epoll的fdstruct epoll_event _evs[num]; // 用于接收就绪事件的数组std::unordered_map<int, Channel*> _channels; // fd与对应Channel映射
private:// 对epoll的直接操作void Update(Channel* channel, int op){int fd = channel->Fd();struct epoll_event ev;ev.data.fd = fd;ev.events = channel->GetEvents();int ret = epoll_ctl(_epfd, op, fd, &ev);if (ret < 0)ERR_LOG("epoll_ctl error!");}// 判断一个Channel是否已经添加到了事件监控bool HasChannel(Channel* channel){auto it = _channels.find(channel->Fd());if (it == _channels.end()) return false;return true;}
public:Poller() : _epfd(-1) {_epfd = epoll_create(num);if (_epfd < 0){ERR_LOG("epoll_create error!");abort();}}// 添加或修改事件void UpdateEvent(Channel* channel){auto it = _channels.find(channel->Fd());if (it == _channels.end()){// 没有找到就是添加事件int fd = channel->Fd();_channels[fd] = channel;Update(channel, EPOLL_CTL_ADD);}else Update(channel, EPOLL_CTL_MOD); // 否则添加事件}// 移除监控void RemoveEvent(Channel* channel){auto it = _channels.find(channel->Fd());if (it != _channels.end()){epoll_ctl(_epfd, EPOLL_CTL_DEL, channel->Fd(), nullptr);_channels.erase(it);}}// 开始监控,返回活跃连接void Poll(std::vector<Channel*>* active){int n = epoll_wait(_epfd, _evs, num, -1);if (n < 0){if (errno == EINTR)return;ERR_LOG("epoll wait error!, error: %s", strerror(errno));abort();}// 将就绪的channel*返回for (int i = 0; i < n; i++){auto it = _channels.find(_evs[i].data.fd);assert(it != _channels.end());it->second->SetRevents(_evs[i].events);active->push_back(it->second);}}
};
EventLoop(核心)
EventLoop是我们服务器模块的统筹管理模块,因为我们所说的 one loop per thread 或 one thread one EventLoop,中的loop指的就是EventLoop,主Reactor、从属Reactor都需要独立的一个EventLoop。
eventfd
在对于EventLoop模块的学习前,要先看一下eventfd这个函数,它的核心功能就是一种事件的通知机制,简单来说,当调用这个函数的时候,就会在内核当中管理一个计数器,每当向eventfd当中写入一个数值,表示的就是事件通知的次数,之后可以使用read来对于数据进行读取,读取到的数据就是通知的次数
假设每次给eventfd写一个1,那么就表示通知了一次,通知三次之后再进行读取,此时读取出来的就是3,读取了之后这个计数器就会变成0
eventfd的应用场景在本项目中,是用于EventLoop模块中实现线程之间的事件通知功能的
返回值
一个文件描述符
参数
initval
- 计数初值
flags
- EFD_CLOEXEC,禁止进程复制
- EFD_NONBLOCK,启动非阻塞属性
读取操作也都是使用read、write,因为一切皆文件,但是注意在读写的时候要读写8字节大小
设计思路
EventLoop组合了一个Poller和一个计时器,并且EventLoop与线程一一对应。
为什么 一个线程要有一个对应的EventLoop?
如果我们不将EventLoop与一个线程一一对应,那么一个连接放在EventLoop的Poller中监控,一旦事件就绪,那么外接的线程池可能会争抢着处理该就绪事件,那么这就引发了线程安全问题,我们不得不进行加锁,而加锁就意味着服务器整体效率的降低,每一个连接都加一个锁这是不现实的,一台百万级并发量的服务器,如果为每一个连接都加锁,这个效率和内存消耗是很庞大的。
因此哦我们需要将一个连接的事件监控、连接的事件处理以及其他操作都放在同一个线程中进行,这样就避免了因加锁导致的效率问题。
如何保证一个连接的所有操作都在EventLoop对应的线程中?
为EventLoop添加一个任务队列,对连接的所有操作都进行一次封装,即对连接的操作并不直接执行,而是当做任务添加到任务队列中。
既然一个线程对应一个Connection,那么该线程不就是会串行的执行epoll返回的就绪事件吗?我这个连接也没有放在其他线程的EventLoop中监控啊,为什么会引发害怕多线程争抢搞一个任务队列,保证一个连接的所有事件都是在同一个线程内执行?
在muduo网络库中,EventLoop引入任务队列的主要目的是为了支持更复杂的并发场景以及解耦业务逻辑与事件循环本身。虽然在一个简单的模型下,每个线程确实只处理属于它的那些连接(即一个线程对应若干个Connection对象),但在实际应用中有以下几个原因促使需要实现任务队列:
- 跨线程操作:尽管当前连接绑定到特定的 EventLoop 和其所在的线程,但是可能存在从其他线程提交的任务希望影响该连接的行为。例如,某些控制指令可能来自另一个管理线程而不是直接由 IO 事件触发。
- 延后执行:有时候我们需要将一些非紧急的操作推迟到主事件循环中去完成,这可以避免阻塞关键路径上的工作。通过把任务加入队列交给 eventloop 来统一调度,就可以做到这一点。
- 保证顺序一致性:如果同一连接产生了多种不同类型的任务(如读取完成后写回响应),利用任务队列可以帮助保持这些任务按照正确的顺序被执行而无需担心乱序问题。
因此即便是在单一线程环境下运行某条连接的所有回调函数,使用任务队列仍然能提供灵活性和更好的结构化设计思路。所以可以说 RunInLoop
是围绕着多线程协作及内部任务调度这一目标展开的重要功能之一。
EventLoop处理流程
- 在线程中对描述符进行事件监控
- 事件就绪则进行事件处理
- 所有就绪事件处理完毕后再处理任务队列中的事件
注意:因为处理任务队列中的任务是在就绪事件之后的,也就会引发一个问题,如果epoll中没有就绪事件而阻塞在epoll_wait,那么任务队列中的任务就得不到执行,所以我们需要一个通知机制,来唤醒事件监控的阻塞,即eventfd。
我们还可以扩展从属Reactor,对从属Reactor外接线程池,将任务队列中的任务交给线程池来执行,所以为了日后方便接入线程池,我们对task进行加锁保护,当需要访问任务队列时,直接swap一个新的任务队列,这样就避免了每次取任务都要加锁的时间消耗。
实现
注意:
- 在EventLoop与定时器整合时,需要注意定时器中需要使用EventLoop中的RunInLoop函数,所以即使定时器类实现在EventLoop类之前,我们也必须将涉及到使用EventLoop中的RunInLoop函数的函数放在EventLoop定之后定义。
- 构造函数内添加对eventfd的读事件监控,一旦我们向任务队列push任务后,就向eventfd写入一个数据,这样就避免了epoll阻塞问题
- 在加锁时,我们可以使用unique_ptr智能指针管理锁,并配合{}限制智能指针的生命周期,从而达到出了作用域锁自动释放的操作
- 对外提供Start函数,启动从属Reactor的工作,函数内无限循环三个流程:
- 在线程中对描述符进行事件监控
- 事件就绪则进行事件处理
- 所有就绪事件处理完毕后再处理任务队列中的事件
class EventLoop
{using func = std::function<void()>;
private:std::thread::id _thread_id; // 标记当先线程的id,以便于后续区分一个连接的操作是否在同一个EventLoop中int _eventfd; // eventfd,当epoll阻塞时,向eventfd写入数据,此时epoll因事件就绪而返回std::unique_ptr<Channel> _event_channel; // 管理eventfd连接的事件监控Poller _poller; // 管理所有fd的事件监控,一个EventLoop对应一个线程、对应一个epollstd::vector<func> _tasks; // 任务队列,让所有的对同一个连接的操作放在同一个线程内部std::mutex _mutex; // 对任务队列加锁TimerWheel _timer_wheel; // 定时器模块public:// 执行_tasks中所有任务void RunTasks(){std::vector<func> t;{std::unique_lock<std::mutex> _lock(_mutex);_tasks.swap(t);}for (auto& f : t){f();}}static int CreateEventfd(){int efd = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK);if (efd < 0){ERR_LOG("create eventfd error!");abort();}return efd;}// eventfd的可读事件回调函数void ReadEventfd(){uint64_t val = 0;int n = read(_eventfd, &val, sizeof(val));if (n < 0){if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK)return;ERR_LOG("read eventfd error!");abort();}}// 向eventfd文件写入数据,使得eventfd的读事件就绪,从而使epoll脱离阻塞void WakeUpEventfd(){uint64_t val = 1;int n = write(_eventfd, &val, sizeof(val));if (n < 0){if (errno == EINTR)return;ERR_LOG("write eventfd error!");abort();}}
public:EventLoop():_eventfd(CreateEventfd()),_event_channel(new Channel(this, _eventfd)),_thread_id(std::this_thread::get_id()), _timer_wheel(this){// 设置eventfd的可读事件回调函数,并开始监听eventfd读事件_event_channel->SetReadCallback(std::bind(&EventLoop::ReadEventfd, this));_event_channel->EnableRead();}void Start(){while (true){// 1. 事件监控std::vector<Channel*> actives;_poller.Poll(&actives);// 2. 事件处理for (auto& channel : actives){channel->HandlerEvent();}// 3. 执行任务RunTasks();}}// 判断当前线程是否是EventLoop对应的线程bool IsInLoop() { return _thread_id == std::this_thread::get_id(); }void AssertInLoop(){assert(_thread_id == std::this_thread::get_id());}// 判断是否执行的任务是处于当前线程中,如果是则执行,不是则放入任务队列void RunInLoop(const func& cb){if (IsInLoop()) cb();else QueueInLoop(cb);}// 将对连接的操作放入任务队列void QueueInLoop(const func& cb){// 加锁,然后将回调函数放入任务队列{std::unique_lock<std::mutex> _lock(_mutex);_tasks.push_back(cb);}// 唤醒此时可能阻塞的epoll,其实就是向eventfd写入数据,从而使得eventfd的读事件就绪WakeUpEventfd();}// 添加、修改描述符的事件监控void UpdateEvent(Channel* channel){_poller.UpdateEvent(channel);}// 移除描述符的事件监控void RemoveEvent(Channel* channel){_poller.RemoveEvent(channel);}// 对外提供定时任务的添加、刷新、删除操作void AddTimerTask(uint64_t id, uint32_t delay, const TaskFunc& cb){_timer_wheel.AddTimerTask(id, delay, cb);}void ReflushTimerTask(uint64_t id){_timer_wheel.ReflushTimerTask(id);}void TimerCancel(uint64_t id){_timer_wheel.CancelTimerTask(id);}/* 是否存在某个定时任务,该函数存在线程安全问题,因为如果多线程调用,可能会引发数据不一致问题,所以只能在模块内,在对应的EventLoop线程内执行*/bool HasTimer(uint64_t id){return _timer_wheel.HasTimer(id);}
};
调用Channel的EnableRead流程:
- 先调用Channel内部的EnableRead
- 然后回调Update函数
- Update内又回调了EventLoop类的成员函数UpdateEvent函数,并传入channel指针
- EventLoop类的成员函数UpdateEvent函数又回调了成员变量Poller的成员函数UpdateEvent
- 至此我们就将一个fd的监控事件放入了epoll中
实际上是Channel回指了EventLoop,EventLoop组合了Poller,所以Channel可以根据EventLoop找到Poller,间接的调用Poller的接口将Channel管理的事件添加的epoll的监控中
添加一个定时任务
实际上就是将一个函数bind到定时器超时时执行的回调函数上
Connection
是对一个通信连接的全方位管理,我们对通信连接的所有操作都是通过这个整合了各个小模块后的Connection模块提供的
成员:
- 套接字管理,能够进行套接字的操作
- 连接事件的就绪后的回调函数,可读、可写、错误、挂断、任意事件共五种事件回调函数
- 缓冲区管理,便于socket数据的接收和发送
- 协议上下文的管理,记录请求数据的处理过程,反序列化
- 回调函数的设置
- 阶段处理函数共四个
- 服务端从TCP接收缓冲区收到数据后,数据该如何处理,需要用户决定,因此必须有业务处理回调函数
- 一个连接建立成功后,用户可以设定一些行为,因此有连接建立成功的回调函数
- 一个连接关闭前,用户可以设定一些行为,因此有连接关闭的回调函数
- 任意事件的产生后,用户可以设定一些行为,因此有任意事件回调函数
功能:
- 发送数据,实际上并不是直接发送数据,而是把数据写入到发送缓冲区中,然后启动写事件监控
- 关闭连接,实际上也不是直接关闭连接,而是在释放连接之前,看看输入输出缓冲区是否还有数据待处理
- 启动、取消非活跃连接的超时销毁功能,因此需要一个bool值来表明连接是否启动非活跃销毁功能
- 协议切换,一个连接收到数据后该如何进行业务处理,取决于上下文以及数据的业务处理回调函数
Connection是对连接管理的模块,对连接的所有操作都是通过这个模块完成的。
场景:当对连接进行多线程操作时,可能会出现连接已经被释放,但是后续又对连接进行了操作,导致内存访问已经被释放的空间,最终导致程序崩溃。
解决方案:使用智能指针shared_ptr对Connection对象进行管理,这样就能保证任意一个地方对Connection对象操作时,该对象依旧存在,即使其他地方释放了他的智能指针
实现
注意点:
- 需要组合我们前面编写的Socket、Buffer、Channel(需要回指EventLoop)模块,最终实现五个通信连接的事件回调函数
- 读回调:从TCP接收缓冲区读取数据,写入到我们的inbuffer缓冲区,如果inbuffer中有数据则调用上层设置的业务处理函数
- 写回调:一旦调用写回调就意味着可以向TCP发送缓冲区写数据了,所以我们直接写入即可,如果写入失败了则需要关闭连接,但是在关闭连接之前需要判断发送缓冲区是否还有数据,如果有数据,则再执行一次业务处理函数,然后进行关闭。如果写入成功,则判断outbuffer是否还有数据,如果没有就取消写事件监控,如果还处于关闭连接状态,则关闭连接不在判断发送缓冲区是否还有数据了。
- 挂断:判断一下inbuffer是否还有数据,如果有则调用一次业务处理函数,然后关闭连接。
- 错误:直接复用挂断回调,因为两者操作相同
- 任意回调:如果关心连接的活跃度则刷新超时任务,然后调用组件使用者提供的任意事件回调函数
- 在构造函数内直接对Channel进行事件回调函数的bind,以达到只要实例化一个Connection对象,该对象就有相应的五个事件回调函数
- 启动非活跃连接销毁功能,实际上就是将Connection中的Release函数作为了超时任务
特别注意:
因为一个从属Reactor对应一个线程、一个EventLoop,EventLoop中又含有一个独立的epoll,线程这个执行流会循环式的调用EventLoop的Start函数进行三个操作的无限循环,是串行执行的。当服务器达到了一个瓶颈,在一次业务处理中花费了大量时间,那么这就会导致在他之后的就绪事件直接超时,超时了那么定时器直接执行超时任务Release函数,移除事件监控、关闭套接字,如果有后续的定时任务则取消、调用上层的关闭回调函数(TcpServer中设置的RemoveConnection函数,会deleteConnection对象),所以在执行后面的就绪任务时就会因为Connection对象已经销毁而导致访问非法空间导致段错误。
虽然是服务器性能造成的一个业务处理事件过长而导致拖累了其他连接,导致其他连接也超过了超时时间,但这是服务器性能的问题,跟计时器无关,计时器只负责超时销毁,这是一种解耦合。
但是为了避免计时器直接执行超时任务,关闭连接导致本轮epoll后续的就绪事件在执行时Connection对象已经不存在了,所以我们要将Release也封装为一个任务压入从属线程的任务队列。
所以我们应该能体会为什么ReleaseInLoop中需要取消定时器中该连接对应的超时任务,因为会有这样一个风险,该连接的销毁任务已经被压入任务队列,但是本轮epoll后续的就绪事件在执行后会刷新定时任务,这会导致下一次秒针走到该定时任务处时,要执行超时任务释放连接时,Connection对象已经不在了,所以在ReleaseInLoop中我们需要判断定时器中是否还有我们的任务,如果有则取消。
typedef enum
{Disconnected, // 连接关闭状态Connecting, // 连接建立成功,待处理状态Connected, // 连接建立完成,各种设置已完成,可以通信Disconnecting // 待关闭状态
}ConnStatus;class Connection : public std::enable_shared_from_this<Connection>
{
/*使用智能指针shared_ptr对Connection对象进行管理,这样就能保证任意一个地方对*/
/*Connection对象操作时,该对象依旧存在,即使其他地方释放了他的智能指针*/using ConnectionCallback = std::function<void(const std::shared_ptr<Connection>&)>;using MessageCallback = std::function<void(const std::shared_ptr<Connection>&, Buffer*)>;using CloseCallback = std::function<void(const std::shared_ptr<Connection>&)>;using AnyEventCallback = std::function<void(const std::shared_ptr<Connection>&)>;
private:uint64_t _conn_id; // 唯一标识一个连接的IDint _sockfd; // 连接对应的fdbool _is_inactive_release; // 连接是否启动非活跃销毁标志位,默认false, 因为上层可能有长连接的需求ConnStatus _status; // 连接处于何种状态EventLoop* _loop; // 回指EventLoop,让所有操作都在loop对应的线程上进行操作,避免线程安全问题Channel _channel; // 对连接的事件管理Socket _socket; // 套接字管理Any _context; // 上层是何种协议Buffer _inbuffer; // 输入缓冲区Buffer _outbuffer; // 输出缓冲区// 某个阶段的回调函数,是上层需要的业务处理函数,是上层在服务器模块设置的ConnectionCallback _conn_cb;// 连接建立后,上层需要的业务处理函数MessageCallback _msg_cb; // 接收数据后,上层需要的业务处理函数CloseCallback _close_cb; // 连接关闭后,上层需要的业务处理函数AnyEventCallback _event_cb; // 任意事件触发,上层需要的业务处理函数// 因为在后面的服务器模块会使用shared_ptr保存所有连接记录,所以一旦有连接需要关闭// 那么就应该从管理的地方移除自己的信息,类似计时器模块那里的TimerTask的Release回调CloseCallback _server_close_cb;
private:// 连接事件就绪后,回调的五个函数void HandlerRead(){ char buffer[65536];ssize_t n = _socket.NonBlockRecv(buffer, 65535);if (n < 0){// 返回值小于0,表明一定是出现了连接大问题,没必要保留连接了// 但是不能直接关闭连接,因为inbuffer、outbuffer可能还有数据,需要处理完再关闭连接return ShutdownInLoop();}// 放入缓冲区_inbuffer.Write(buffer, n);// 缓冲区有数据,则调用MessageCallback进行业务处理if (_inbuffer.ReadAbleSize() > 0){// shared_from_this -- 从当前对象获取自身的shared_ptr,需要继承一个模板类return _msg_cb(shared_from_this(), &_inbuffer);}}// 触发写事件void HandlerWrite(){ssize_t n = _socket.NonBlockSend(_outbuffer.ReadPosition(), _outbuffer.ReadAbleSize());if (n < 0){// 返回值小于0,表明一定是出现了连接大问题,没必要保留连接了,// 最后再看一次inbuffer是否还有数据if (_inbuffer.ReadAbleSize() > 0)_msg_cb(shared_from_this(), &_inbuffer);// 进行实际的关闭,不再判断发送缓冲区是否还有数据了return ReleaseInLoop();}// outbuffer的读指针移动_outbuffer.MoveReadPos(n);if (_outbuffer.ReadAbleSize() == 0){// 如果outbuffer没有数据了,那就关闭写事件监控_channel.DisableWrite();// 如果此时还是连接关闭状态,那把链接也关闭了if (_status == Disconnecting)return Release();}}// 触发挂断事件void HandlerClose(){// 一旦连接挂断了,套接字什么都干不了了,因此有数据待处理就处理一下if (_inbuffer.ReadAbleSize() > 0)_msg_cb(shared_from_this(), &_inbuffer);// 进行实际的关闭,不再判断发送缓冲区是否还有数据了Release();}// 触发出错事件void HandlerError(){HandlerClose();}// 触发任意事件:1. 刷新连接的活跃度 2. 调用组件使用者提供的回调函数void HandlerEvent(){// 如果关心连接活跃度则刷新活跃度if (_is_inactive_release)_loop->ReflushTimerTask(_conn_id);if (_event_cb) _event_cb(shared_from_this());}/*为了线程安全,所以将对连接的所有操作都封装为一个任务,*/
/*push到loop的任务队列中,让loop对应的线程串行的执行任务*/void EstablishedInLoop(){// 1. 修改连接状态, 当前函数执行完则连接进入已完成连接状态assert(_status == Connecting);_status = Connected;// 2. 启动读事件监控_channel.EnableRead();// 3. 调用回调函数if (_conn_cb) _conn_cb(shared_from_this());}void ReleaseInLoop(){// 1. 修改连接状态_status = Disconnected;// 2. 移除连接的事件监控_channel.Remove();// 3. 关闭描述_socket.Close();// 4. 如果定时器中还有我们的定时销毁任务,则取消任务if (_loop->HasTimer(_conn_id))CancelInactiveReleaseInLoop();// 5. 调用关闭回调函数,如果先调用移除服务器管理的回调函数,那么再去调用_close_cb会访问已经释放的空间,所以先调用关闭回调函数if (_close_cb) _close_cb(shared_from_this());if (_server_close_cb) _server_close_cb(shared_from_this());}// 将数据放到缓冲区,启动可写事件监控void SendInLoop(Buffer& buffer) {if (_status == Disconnected) return;_outbuffer.Write(buffer);if (!_channel.WriteAble()) _channel.EnableWrite();}// 并非实际的连接释放操作,需要判断缓冲区是否还有残留void ShutdownInLoop(){// 设置为半关闭状态_status = Disconnecting;if (_inbuffer.ReadAbleSize() > 0){if (_msg_cb) _msg_cb(shared_from_this(), &_inbuffer);}// 要么写入数据的时候出错关闭,要么就是没有待发送的数据,直接关闭if (_outbuffer.ReadAbleSize() > 0){if (!_channel.WriteAble())_channel.EnableWrite();}if (_outbuffer.ReadAbleSize() == 0)Release();}void EnableInactiveReleaseInLoop(int time) {// 1. 将_is_inactive_release 置为 true_is_inactive_release = true;// 2. 如果当前定时销毁任务已经存在,则刷新任务if (_loop->HasTimer(_conn_id))_loop->ReflushTimerTask(_conn_id);// 3. 如果不存在,则新增定时任务else_loop->AddTimerTask(_conn_id, time, std::bind(&Connection::Release, this));}void CancelInactiveReleaseInLoop() {_is_inactive_release = false;if (_loop->HasTimer(_conn_id))_loop->TimerCancel(_conn_id);}void UpgradeInLoop(const Any& context, const ConnectionCallback& conn_cb, const MessageCallback& msg_cb, const CloseCallback& close_cb, const AnyEventCallback& event_cb){_context = context;_conn_cb = conn_cb;_msg_cb = msg_cb;_close_cb = close_cb;_event_cb = event_cb;}
public:Connection(EventLoop* loop, uint64_t conn_id, int sockfd) : _sockfd(sockfd), _socket(sockfd), _conn_id(conn_id), _loop(loop), _is_inactive_release(false), _status(Connecting) // 虽然内核三次握手了,但是我们还没有对连接进行各种回调设置,所以我们任务处于连接建立状态, _channel(loop, _sockfd){ _channel.SetReadCallback(std::bind(&Connection::HandlerRead, this));_channel.SetWriteCallback(std::bind(&Connection::HandlerWrite, this));_channel.SetCloseCallback(std::bind(&Connection::HandlerClose, this));_channel.SetErrorCallback(std::bind(&Connection::HandlerError, this));_channel.SetEventCallback(std::bind(&Connection::HandlerEvent, this));// 不能在这里直接监控可读事件,会因为定时任务出问题}~Connection() { DBG_LOG("release connection: %p", this); }int Fd() { return _sockfd; }uint64_t id() { return _conn_id; }// 连接是否处于Connected状态bool status() { return _status == Connected; }// 设置、获取上下文void SetContext(const Any& context) { _context = context; }Any* GetContext() { return &_context; }// 设置阶段的回调函数void SetConnectedCallback(const ConnectionCallback& cb) { _conn_cb = cb; }void SetMessageCallback(const MessageCallback& cb) { _msg_cb = cb; }void SetClosedCallback(const CloseCallback& cb) { _close_cb = cb; }void SetAnyEventCallback(const AnyEventCallback& cb) { _event_cb = cb; }void SetSvrClosedCallback(const AnyEventCallback& cb) { _server_close_cb = cb; }// 连接获取后,对连接的各种设置回调、启动事件监控void Established(){_loop->RunInLoop(std::bind(&Connection::EstablishedInLoop, this));}// 将数据写入发送缓冲区,然后启动写事件监听void Send(const char* data, size_t len) {// 因为外界传入的data可能是一个临时空间,而我们将send任务push到了任务池,该任务在执行前data空间可能就已经被销毁了// 因此我们需要构建一个变量保存data数据Buffer buf;buf.Write(data, len);_loop->RunInLoop(std::bind(&Connection::SendInLoop, this, std::move(buf)));}// 提供给组件使用者的接口,并不是真正关闭连接,需要检查缓冲区是否还有数据void Shutdown(){_loop->RunInLoop(std::bind(&Connection::ShutdownInLoop, this));}void Release() {_loop->QueueInLoop(std::bind(&Connection::ReleaseInLoop, this));}// 启动非活跃连接的超时自动销毁,传入超时时间timevoid EnableInactiveRelease(int time) {_loop->RunInLoop(std::bind(&Connection::EnableInactiveReleaseInLoop, this, time));}// 取消非活跃连接的超时自动销毁void CancelInactiveRelease() {_loop->RunInLoop(std::bind(&Connection::CancelInactiveReleaseInLoop, this));}// 协议切换 --> 重置上下文、阶段回调函数 --- 非线程安全void Upgrade(const Any& context, const ConnectionCallback& conn_cb, const MessageCallback& msg_cb, const CloseCallback& close_cb, const AnyEventCallback& event_cb){_loop->AssertInLoop();_loop->RunInLoop(std::bind(&Connection::UpgradeInLoop, this, context, conn_cb, msg_cb, close_cb, event_cb));}
};
Accept
这个模块的意义主要是对于监听套接字进行管理,主要的功能是:
- 创建一个监听套接字
- 启动读事件监控
- 事件触发后,获取新连接
- 调用新连接获取成功后的回调函数
- 为新连接创建Connection进行管理
对于新连接如何处理应该是服务器模块来管理的,即设置给listenfd的读回调函数用于处理新连接。因为主Reactor是专门负责获取新连接的,一个Reactor对应一个EventLoop,所以主线程作为主Reactor的专属线程,必然需要一个EventLoop对象支持listen的各种操作,所以需要外卖传入EventLoop指针
class Acceptor
{using AcceptCallback = std::function<void(int)>;
private:Socket _socket; // 监听套接字管理EventLoop* _loop; // 对监听套接字进行事件监控管理Channel _channel; // 监听套接字事件管理AcceptCallback _accept_cb; // 监听到新连接时的回调函数
private:// 监听到新连接时的读事件回调函数void HandlerRead(){int newfd = _socket.Accept();if (newfd < 0) return;if (_accept_cb) _accept_cb(newfd);}int CreateServer(int port){bool ret = _socket.CreateServer(port);assert(ret == true);return _socket.Fd();}
public:/*注意:启动读事件监控不能放在构造函数内!因为如果构造函数启动了读事件监控之后,立马来了一个连接*//*那么此时我们的AcceptCallback还没有设置,所以调用HandlerRead时不会调用_accept_cb,这样就会导致*//*新连接得不到服务,并且newfd泄露!*/Acceptor(EventLoop* loop, uint16_t port): _loop(loop), _channel(loop, _socket.Fd()), _socket(CreateServer(port)){_channel.SetReadCallback(std::bind(&Acceptor::HandlerRead, this));}void SetAcceptCallback(const AcceptCallback& cb) { _accept_cb = cb; }void Listen() { _channel.EnableRead(); }
};
LoopThread
该模块的功能主要是来把EventLoop模块和线程结合在一起,要形成的最终效果是,让EventLoop和线程是一一对应的。在EventLoop模块实例化的对象,在构造的时候就会初始化线程的id,而当后面需要运行一个操作的时候,就判断是否运行在EventLoop模块对应的线程中,如果是就代表是一个线程,不是就代表当前运行的线程不是EventLoop线程
不能直接在主线程内实例化三个EventLoop,因为此时EventLoop内的thread_id都会被设置为主线程的ID,所以必须在每个新线程内部进行实例化EventLoop,并且在构造EventLoop对象到设置新的线程id这个期间是不可控的
所以就要构造一个新的模块,LoopThread,这个模块的意义就是把EventLoop和线程放到一块,主要的思路就是创建线程,在线程中实例化一个EventLoop对象,这样可以向外部返回一个实例化的EventLoop
流程:
- 创建线程
- 在线程中实例化EventLoop对象
接口:
- 返回实例化的EventLoop
实现
class LoopThread
{
private:std::thread _thread; // 一个EventLoop对应一个线程EventLoop* _loop; // 从属Reactor管理所有fd事件std::mutex _mutex; // 为了保证获取_loop之前,loop已经被初始化,所以需要线程锁std::condition_variable _cond; // 线程同步控制获取已经初始化的_loop
private:// 线程函数执行例程void ThreadEntry(){// 实力化EventLoopEventLoop loop;{// 初始化EventLoop的时候,就别打断,所以加锁std::unique_lock<std::mutex> lock(_mutex);_loop = &loop;// 唤醒可能早已经调用GetLoop而等待的执行流_cond.notify_all();}loop.Start();}
public:LoopThread():_thread(std::thread(&LoopThread::ThreadEntry, this)), _loop(nullptr){}// 外部获取EventLoop*EventLoop* GetLoop(){EventLoop* loop = nullptr;{std::unique_lock<std::mutex> lock(_mutex);// 如果_loop为空,则等待_cond.wait(lock, [&](){ return _loop != NULL; });//loop为NULL就一直阻塞loop = _loop;}return loop;}};
细节:
- 在线程内初始化EventLoop
- GetLoop函数,在外部可以获取到EventLoop对象,方便新连接指向EventLoop
- 有可能线程创建了,但是EventLoop还没有实例化,但是外部调用了GetLoop函数,此时会获得nullptr,这就表明该连接会得不到服务,所以我们需要加锁,在获取Loop之前必须等待初始化。
- 我们不使用new创建堆上的EventLoop,而是在线程执行的例程中实例化一个局部对象,这样就会使EventLoop生命周期与ThreadLoop绑定,因为在线程执行的例程中,我们会执行loop的start函数,这个start是一个死循环,一旦出错了那么就是从属reactor取消了,相应的EventLoop也应该销毁
LoopThreadPool
主Reactor就是我们的主线程,只负责连接的获取。从属Reactor负责新连接的事件监控及处理。
设计思想:
- 针对LoopThread设计一个线程池,便于使用者对线程的控制
- 对所有的LoopThread进行管理及分配
功能:
- 线程数量可配置
- 根据用户传递的参数决定线程的数量,因此从属线程数量可能为0,也就是单Reactor模式的服务器,此时表示服务器是轻量级的,主线程既负责获取连接,又负责事件的处理。
- 对所有线程进行管理,就是管理 【0,n】 个 LoopThread 对象
- 提供线程分配的功能
- 如果有0个从属线程,则直接分配给主线程的EventLoop进行处理。
- 如果有n个从属线程,则采用轮转的思想,对新连接Connection进行从属Reactor线程分配,就是获取从LoopThread中获取轮转到的线程的EventLoop地址,将其设置给新连接的Connection。
实现
class LoopThreadPool
{
private:int _thread_count; // 从属线程的数量int _next_idx; // 用于控制轮转的下标EventLoop* _baseloop; // 当从属线程数量大于0,则baseloop只负责获取新连接。如果等于0,则既负责获取新连接,又负责事件处理std::vector<LoopThread*> _threads;// 保存各个从属线程std::vector<EventLoop*> _loops; // 保存各个线程的EventLoop指针
public:LoopThreadPool(EventLoop* baseloop): _baseloop(baseloop), _thread_count(0), _next_idx(0){}void SetCount(int count) { _thread_count = count; }void Create(){if (_thread_count > 0){_threads.resize(_thread_count);_loops.resize(_thread_count);for (int i = 0; i < _thread_count; ++i){// 创建线程后,在线程执行的例程内部实例化EventLoop,所以如果阻塞了,那么不会执行到下一条语句获取一个空的EventLoop_threads[i] = new LoopThread();_loops[i] = _threads[i]->GetLoop();}}}EventLoop* NextLoop(){if (_thread_count == 0)return _baseloop;_next_idx = (_next_idx + 1) % _thread_count;return _loops[_next_idx];}
};
TcpServer
整合所有模块,方便上层直接使用。不需要用户手动创建EventLoop对象baseloop,也不需要创建LoopThreadPool,只需要传入端口号、从属线程池的数量
管理:
- Acceptor对象,创建一个监听套接字
- EventLoop对象,即baseloop,实现对监听套接字的事件监控
- std::unordered_map<uint64_t, std::shared_ptr<Connection>> _conns,实现对所有新建连接的管理
- LoopThreadPool对象,创建loop线程池,对新建连接轮转分配EventLoop,实现从属Reactor的负载均衡
功能:
- 设置从属线程池中线程的数量
- 启动服务器
- 设置各种阶段回调函数(连接建立完成、业务处理、关闭、任意),是用户设置给TcpServer,TcpServer再设置给新获取连接的Connection
- 是否启动非活跃连接超时销毁功能
- 添加定时任务功能
流程:
- 在TcpServer中实例化一个Acceptor对象,以及一个EventLoop对象(baseloop)
- 将Acceptor挂到baseloop上进行事件监控
- 一旦Acceptor对象即listenfd读事件就绪,则执行读事件回调函数获取新连接
- 对新连接创建一个Connection对象进行统筹管理
- 将新连接对应的Connection挂到LoopThreadPool返回的EventLoop上进行事件监控管理
- 一旦Connection对应的连接就绪了可读事件,则执行可读事件的回调函数,读取数据然后再调佣TcpServer设置的阶段回调函数
实现
class TcpServer
{
private:uint64_t _conn_id; // 唯一标识连接的id,自动增长 int _timeout; // 非活跃连接的超时时间bool _is_inactive_release; // 是否启动非活跃销毁功能int _port; // 服务器要绑定的端口号EventLoop _baseloop; // 监听套接字对应的EventLoopAcceptor _acceptor; // 监听套接字管理LoopThreadPool _threadpool; // 从属线程管理std::unordered_map<uint64_t, std::shared_ptr<Connection>> _conns; // shared_ptr保存所有连接using ConnectedCallback = std::function<void(const std::shared_ptr<Connection>&)>;using MessageCallback = std::function<void(const std::shared_ptr<Connection>&, Buffer*)>;using CloseCallback = std::function<void(const std::shared_ptr<Connection>&)>;using AnyEventCallback = std::function<void(const std::shared_ptr<Connection>&)>;ConnectedCallback _conn_cb;// 连接建立后,上层需要的业务处理函数MessageCallback _msg_cb; // 接收数据后,上层需要的业务处理函数CloseCallback _close_cb; // 连接关闭后,上层需要的业务处理函数AnyEventCallback _event_cb; // 任意事件触发,上层需要的业务处理函数
private:// 监听套接字的读事件就绪回调void NewConnection(int fd){_conn_id++;std::shared_ptr<Connection> conn(new Connection(_threadpool.NextLoop(), _conn_id, fd));conn->SetConnectedCallback(_conn_cb); // 连接建立成功时,上层业务处理的回调函数conn->SetMessageCallback(_msg_cb); // 缓冲区有数据就绪,上层业务处理的回调函数conn->SetClosedCallback(_close_cb); // 连接关闭时,需要将_conns中对Connection记录shared_ptr删除的的回调函数conn->SetAnyEventCallback(_event_cb);conn->SetSvrClosedCallback(std::bind(&TcpServer::RemoveConnection, this, std::placeholders::_1));// 是否启动非活跃销毁if (_is_inactive_release) conn->EnableInactiveRelease(_timeout);// 就绪初始化conn->Established();_conns.insert(std::make_pair(_conn_id, conn));}void RemoveConnectionInLoop(const std::shared_ptr<Connection>& conn){if (!conn) return;auto it = _conns.find(conn->id());if (it != _conns.end()) {_conns.erase(it);}}void RunAfterInLoop(const TaskFunc& task, int delay){_conn_id++;_baseloop.AddTimerTask(_conn_id, delay, task);}// 从_connections中移除对应链接的shared_ptrvoid RemoveConnection(const std::shared_ptr<Connection>& conn){_baseloop.RunInLoop(std::bind(&TcpServer::RemoveConnectionInLoop, this, conn));}
public:TcpServer(int port): _port(port), _conn_id(0), _timeout(0), _is_inactive_release(false), _acceptor(&_baseloop, port), _threadpool(&_baseloop){// 不能直接在这里Create,因为我们还没有设置threadcount,所以默认为0,返回EventLoop时会出错,应该放在Start中// _threadpool.Create(); // 创建线程池中的从属线程_acceptor.SetAcceptCallback(std::bind(&TcpServer::NewConnection, this, std::placeholders::_1));_acceptor.Listen(); // 将监听套接字挂到baseloop上}// 设置从属线程数量void SetThreadCount(int count) { _threadpool.SetCount(count); }// 用于启动非活跃连接的销毁功能void EnableInactiveRelease(int timeout){_is_inactive_release = true;_timeout = timeout;}// 用于添加一个定时任务void RunAfter(const TaskFunc& cb, int delay){_baseloop.RunInLoop(std::bind(&TcpServer::RunAfterInLoop, this, cb, delay));}void Start() {// 注意!!!在没有设置threadcount前,不能调用Create_threadpool.Create();_baseloop.Start();}void SetConnectedCallback(const ConnectedCallback& cb) { _conn_cb = cb; }void SetMessageCallback(const MessageCallback& cb) { _msg_cb = cb; }void SetClosedCallback(const CloseCallback& cb) { _close_cb = cb; }void SetAnyEventCallback(const AnyEventCallback& cb) { _event_cb = cb; }
};
EchoServer
我们已经完成了所有的模块实现,现在通过搭建一个简单的回响服务器来观察整体效果
EchoServer.hpp
#include "../Server.hpp"class EchoServer
{
private:TcpServer _tcpsvr;
private:void OnConnected(const std::shared_ptr<Connection>& conn){DBG_LOG("new connection: %p", conn.get());}void OnMessage(const std::shared_ptr<Connection>& conn, Buffer *buf){if (!conn || !buf) return;if (buf->ReadAbleSize() == 0) return;DBG_LOG("%s", buf->ReadPosition());conn->Send(buf->ReadPosition(), buf->ReadAbleSize());buf->MoveReadPos(buf->ReadAbleSize());conn->Shutdown();}void OnClosed(const std::shared_ptr<Connection>& conn){DBG_LOG("Connection Destory");}
public:EchoServer(int port): _tcpsvr(port){_tcpsvr.SetThreadCount(3);_tcpsvr.EnableInactiveRelease(10);_tcpsvr.SetConnectedCallback(std::bind(&EchoServer::OnConnected, this, std::placeholders::_1));_tcpsvr.SetMessageCallback(std::bind(&EchoServer::OnMessage, this, std::placeholders::_1, std::placeholders::_2));_tcpsvr.SetClosedCallback(std::bind(&EchoServer::OnClosed, this, std::placeholders::_1));}void Start(){_tcpsvr.Start();}
};
main.cc
#include "echo.hpp"int main()
{EchoServer echo(8080);echo.Start();return 0;
}
client.cc
#include "../Server.hpp"int main()
{Socket client;client.CreateClient(8080, "127.0.0.1");int cnt = 5;while (cnt--){// 发std::string str = "hello Linux";client.Send(str.c_str(), str.size());// 收char buffer[1024] = {0};client.Recv(buffer, sizeof(buffer) - 1);DBG_LOG("%s", buffer);sleep(1);}while(true) sleep(1);return 0;
}
测试
可以看到客户端发送了5次数据后停止发送,服务端收到了五次数据然后回显五次,客户端也收到并打印了出来,并且在超时时间10s过后服务端主动关闭了连接。
性能测试
WebBench工具
WebBench是一个轻量级的网站压力测试工具,由红帽公司开发并维护。它主要用于评估HTTP服务器在高负载情况下的性能表现。通过模拟大量的并发用户访问请求,可以对目标站点的压力承受能力、响应时间和吞吐量等指标进行全面分析。
WebBench通过建立大量的客户端到服务器端之间的并行TCP连接来发起请求。这模仿了真实环境中众多用户的同时在线场景。
一旦建立了足够的连接数之后,WebBench将开始不断地向指定的目标URL地址发出GET或其他类型的HTTP查询指令(取决于配置设定),直到达到预设的最大次数或持续时间为止。
测试
由于云服务器的带宽很低,所以如果是不同云服务器之间进行高并发请求,那么效率很低,所以我们使用了本地环回,本地请求,这样就忽略了带宽影响,但是又有一个问题,本地服务器既响应,又多进程请求,他们会争夺CPU,会降低服务器效率,也不合理,所以我们分别在云服务器上测试一次,在虚拟机上测试一次
云服务器配置为2核1G,我们在云服务器上同时运行服务端与webbench进行4000并发量测试
虚拟机配置为4核4G,我们在虚拟机上同时运行服务端与webbench进行5000并发量测试
HTTPServer
对于这个模块,我将会在另一篇文章中讲解并测试