
🔥小叶-duck:个人主页
❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》
《Linux系统从入门到实践》《Linux网络从入门到实践》
✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游
目录
[一、原生 epoll 服务代码存在的架构缺陷](#一、原生 epoll 服务代码存在的架构缺陷)
[1.1 :重构核心思路:Reactor 模型的整体改造方案](#1.1 :重构核心思路:Reactor 模型的整体改造方案)
[二、Connection 基类 --- 先描述:统一所有连接的抽象](#二、Connection 基类 — 先描述:统一所有连接的抽象)
[三、连接实体的两种类型:Listener 与 Connector](#三、连接实体的两种类型:Listener 与 Connector)
[2.1 Listener:监听连接的封装](#2.1 Listener:监听连接的封装)
[2.2 Connector:普通 IO 连接的封装](#2.2 Connector:普通 IO 连接的封装)
[4.1 模块前置:统一错误码 Common.hpp](#4.1 模块前置:统一错误码 Common.hpp)
[4.2 Poller 类框架实现](#4.2 Poller 类框架实现)
[五、Reactor 反应堆核心 --- 再组织:连接管理与事件派发](#五、Reactor 反应堆核心 — 再组织:连接管理与事件派发)
前言
在前序章节中,我们依次学习了五种 IO 模型、IO 多路复用,并且深入剖析了 select、poll、epoll 的底层原理、LT/ET 触发模式,也完成了基础 epoll 回声服务端的编码实战。我们会发现,直接基于原生 epoll 写出来的服务代码,业务逻辑与事件处理耦合在一起,fd 管理零散,分支判断繁多,代码难以维护、扩展能力弱,并不适合工程级高并发服务器开发。
从本篇开始,我们就针对原生 epoll 代码的架构缺陷,从零动手重构,落地经典的 Reactor 反应堆事件驱动模型。我们会先抽象出 Connection 基类,区分 Listener 监听连接与 Connector 普通 IO 连接两类实体,封装 Poller 完成 epoll 接口的统一管理,最后实现 Reactor 核心,完成连接的统一收纳与事件派发。
本篇文章重点完成 Reactor 底层框架的基础搭建,把连接抽象、多路复用封装、事件分发机制全部落地,为下一篇继续完善事件回调、读写缓冲区与业务逻辑做好铺垫。
一、原生 epoll 服务代码存在的架构缺陷
在初期的多路转接代码设计也就是上篇文章我们所写的基于epoll的回显服务器中,事件处理器往往采用如下朴素的单文件描述符直接读取方式。这种设计模式存在极其严重的架构和安全缺陷。以下为上篇文章 IO 处理器 HandlerRead函数的代码设计:
cpp
// IO处理器:处理读事件就绪
void HandlerRead(int sockfd)
{
// 到这里,读取数据的时候会不会阻塞?不会!
// 因为如果接收缓冲区没有数据,那么读事件就不会就绪,也就不会调用这个函数
// 而只有接收缓冲区有数据就绪了,才会调用这个函数进行处理,read/recv就不会阻塞了!
std::cout << "处理读取操作..." << std::endl;
// 缺陷1:由于TCP是面向字节流的协议,这里就会存在两个致命问题:
// (1)我们调用一次HandlerRead怎么保证我们读取的数据就是完整的?如果不完整,好,大不了就循环读取,
// 但是我们怎么判断一个完整数据从哪开始从哪结束?如何解决?---> 引入自定义协议、序列化反序列化。
// (2)使用局部变量buffer作为接收缓冲区,也就说明buffer无法持久化,这个问题在之前学习自定义协议的时候已经遇到过了:
// 由于为了保证数据的完整性,在自定义协议中我们设计了DeCode用于判断buffer中的报文是否完整,如果不完整则返回继续拼接读取
// 而由于函数退出后局部变量生命周期也随之结束,下次调用HandlerRead时buffer就被清空了,就会导致后续读取的报文就不可能是完整的从而导致死循环
char buffer[1024];
int n = read(sockfd, buffer, sizeof(buffer));
if (n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
std::string echo_string = "echo# ";
echo_string += buffer;
// 缺陷2:直接调用原始send,若内核发送缓冲区已满,将导致单线程同步阻塞,破坏我们的非阻塞设计初衷
send(sockfd, echo_string.c_str(), echo_string.size(), 0);
}
else if (n == 0)
{
// 对端关闭连接
}
else
{
// 读取出错
}
}
我们所设计的这个 IO 处理器当然能实现我们前面回显的要求,但是却存在明显架构缺陷:
- 局部缓冲区带来的 TCP 粘包、半包问题
原始代码使用函数内局部数组buffer[1024]作为接收缓冲区。局部变量存储在栈上 ,当 IOHandler 函数执行完毕,栈内存会被直接释放销毁 。
TCP 是面向字节流的协议,不存在天然报文边界。单次recv调用不一定能读取到一条完整业务报文。如果本次只读到报文的一部分(半包),函数退出后局部 buffer 直接销毁,这部分半包数据会永久丢失 ;下一次读事件就绪、再次调用 IOHandler 时,新接收的数据会写入全新的 buffer,无法和上一次残留的半包拼接,最终报文解析逻辑直接失效。
同时多连接并发场景下,多个 fd 交替触发 IO 事件,所有连接共用这套函数内局部 buffer,不同连接的数据会发生时序错乱,相互干扰。
- 直接调用 send 存在阻塞风险
原始代码直接调用send函数进行回显发送。如果内核的发送缓冲区 已经被填满 ,在非阻塞模式设计不完善的情况下,send会发生同步阻塞 ,直接卡住单线程事件循环,破坏 Reactor 非阻塞的核心设计。
- 硬编码分支,扩展性差
事件派发 Dispatcher 函数逻辑需要依靠大量**if-else分支** 来手动 区分监听 fd 与普通连接 fd。所以这就会导致后续如果存在新的连接类型需要添加,我们就需要对每种连接类型都进行判断逻辑,违背了面向对象的开闭原则,后期维护成本很高。
- 错误处理逻辑分散,容易资源泄漏
由于连接类型的不同,我们就需要解耦成多个处理函数甚至封装成多个模块,这就会导致连接关闭、读写异常等处理代码散落于各个分支中 。编码时很容易出现忘记从 epoll 中删除 fd、忘记调用 close 关闭文件描述符,造成 fd 资源泄漏。
1.1 :重构核心思路:Reactor 模型的整体改造方案
想要解决上面的 TCP 字节流半包、粘包问题,核心思路:每一个文件描述符 fd,都需要配套一对专属、生命周期独立的接收缓冲区 inbuffer 与发送缓冲区 outbuffer,缓冲区生命周期要长于单次 IO 函数调用。
当读事件就绪时,应用层非阻塞读取内核缓冲区数据,追加到当前 fd 对应的inbuffer尾部。之后协议解析器扫描inbuffer:
- 如果缓冲区数据长度不足一条完整报文 ,不执行业务逻辑,保留缓冲区内容 ,等待下一次读事件到来继续追加数据;
- 如果缓冲区中存在一条或多条完整报文 ,截取完整报文 交给业务层处理,剩余半包数据继续保留在
inbuffer等待后续数据。
这套方案本质是空间换状态 ,把字节流的分片数据暂存起来,彻底解决 TCP 字节流任意切片带来的报文解析失败问题。在 C++ 实现中,我们一般用std::string作为inbuffer,持续拼接接收的数据。
基于上面的缺陷分析,Reactor 事件驱动模型给出了完整的重构方案,从三个维度改造原有代码:
- fd 专属缓冲区:为每一个连接 fd 分配独立的输入缓冲区 inbuffer、输出缓冲区 outbuffer。报文没有读完就暂存,等待下一次读事件就绪后继续拼接数据。
- 面向对象抽象与多态 :将监听套接字、普通 IO 套接字统一抽象为
Connection类 ,监听套接字、普通 IO 套接字作为子类 继承于Connection类,后续如果有新的连接类型添加,直接继承Connection类即可。这样,事件派发时不再使用 if/else 硬编码区分 fd 类型,直接调用虚函数 ,依靠多态 完成不同类型连接的事件处理,符合开闭原则。 - 统一异常处理逻辑 :把底层的错误事件统一转化为读写事件。所有连接的关闭、异常回收逻辑在读写处理流程内统一由一种函数处理,减少分支,规避 fd 泄漏问题。
二、Connection 基类 --- 先描述:统一所有连接的抽象
"先描述" 的第一步,就是定义所有连接的公共基类Connection。不管是监听套接字还是普通客户端套接字,本质上都是**"被 epoll 监听、具备读写异常处理能力、持有缓冲区"**的连接对象。
cpp
#ifndef CONNECTION_HPP
#define CONNECTION_HPP
#include <iostream>
#include <string>
#include "InetAddr.hpp"
// 封装fd,保证给每一个fd一套缓冲
class Reactor;
using handler_t = std::function<void(std::string &, std::string &)>;
// 【核心注释】连接的抽象基类 (Reactor模型中的核心组件)。
// 采用"先描述"的理念,将底层的套接字(sockfd)、专属缓冲区、以及对各类事件的处理方法封装在一起。
// 借助多态特性,使得上层(如 TcpServer) 在派发事件时,无需关心具体的连接类型是 Listener 还是普通的 Client。
// 基类! -- 先描述
class Connection
{
public:
// 默认构造,初始状态下该连接暂未绑定(关心)任何 epoll 事件
Connection()
: _sockfd(-1), _events(0), _owner(nullptr)
{
}
// 构造函数重载
Connection(int sockfd)
: _sockfd(sockfd), _events(0), _owner(nullptr)
{
}
// 设置文件描述符
void SetSockFd(const int &sockfd)
{
_sockfd = sockfd;
}
// 获取底层的 socket 文件描述符
int GetSockFd()
{
return _sockfd;
}
// 设置当前连接需要关心的 epoll 事件 (如 EPOLLIN | EPOLLOUT | EPOLLET 等)
void SetEvent(const uint32_t &events)
{
_events = events;
}
// 获取当前连接关心(注册)的 epoll 事件集合
uint32_t GetEvent()
{
return _events;
}
// 【核心注释】纯虚函数:读事件的统一处理接口。(由子类实现具体逻辑)
// 当 epoll 检测到该 fd 有 EPOLLIN 事件就绪时被调用。
// 如果对象是 Listener,实则执行 accept() 获取新连接;如果对象是 IOHandler,实则执行 recv() 读取报文。
virtual void Recver() = 0;
// 纯虚函数:【核心注释】写事件的统一处理接口。(由子类实现具体逻辑)
// 当 epoll 检测到该 fd 有 EPOLLOUT 事件就绪时被调用,一般用于将输出缓冲区中的数据通过 send() 发送至网络。
virtual void Sender() = 0;
// 【核心注释】纯虚函数:异常事件的统一处理接口。(由子类实现具体逻辑)
// 统一处理由于断开连接或网络波动引发的错误,避免外部业务层到处写 if-else 进行错误捕获。
virtual void Excepter() = 0;
// 虚析构函数
virtual ~Connection()
{
// 析构函数体:通常为空
}
protected: // 保护成员
// 设为保护成员,是为了让派生类(Listener、IOHandler)能够直接访问并操作下面这些核心数据
// 文件描述符:不同的子类对象表示不同的含义
int _sockfd;
// 保存该连接实际在 epoll 中注册的事件掩码组合
uint32_t _events;
//【核心注释】专属的应用层接收缓冲区。
// 这是解决 TCP 面向字节流带来的"粘包"和"半包"问题的关键。它保证了即便一次未能读完完整报文,
// 剩余数据保存在缓冲区,下次读事件就绪时继续读取,直到拼成完整报文再处理。
std::string inbuffer;
//【核心注释】专属的应用层发送缓冲区。
// 当系统的底层 socket 发送缓冲区满时,将待发送的数据暂存于此。等 epoll 下次触发可写事件时再继续发送。
std::string outbuffer;
};
#endif
代码解读:
- 核心设计:纯虚函数实现多态
Recver/Sender/Excepter
三个纯虚函数定义了所有连接的统一行为规范。监听套接字子类的Recver负责执行accept接收新连接;普通客户端套接字子类的Recver负责读取业务数据。上层事件派发代码调用时,完全不用区分当前是监听 fd 还是业务 fd,直接调用虚函数,依靠多态自动执行对应逻辑,这就是 Reactor 模型的核心优势。 - protected 成员:子类共享,外部不可见 文件描述符、事件掩码、输入输出缓冲区全部放在 protected。子类可以直接访问,外部代码无法随意修改,实现封装。
这里的inbuffer和outbuffer就是解决 TCP 粘包半包的关键:单次 read 没有读完完整报文,剩余数据保留在inbuffer,等待下一次读事件到来继续拼接解析;当内核 socket 发送缓冲区打满,待发送数据存入outbuffer,等待 epoll 触发 EPOLLOUT 可写事件再继续发送。 - 事件管理
每个连接对象自己维护需要关心的 epoll 事件掩码,注册到 epoll 时直接从对象读取事件。
三、连接实体的两种类型:Listener 与 Connector
有了基类,我们就可以借助继承派生出两种最核心的连接类型:负责接收新连接 的 Listener ,和负责普通客户端 IO 的 Connector。
2.1 Listener:监听连接的封装
Listener 本质上就是对监听套接字的包装 ,它只关心读事件 ------ 读事件就绪就代表有新连接到来,需要执行 accept 。目前我们先搭框架,方法体留空,后续会填充具体的 accept逻辑。它不需要写事件和主动异常处理,所以 Sender 和 Excepter 暂时为空实现。
cpp
#ifndef LISTENER_HPP
#define LISTENER_HPP
#include <iostream>
#include <memory>
#include "Epoller.hpp"
#include "Socket.hpp"
#include "Common.hpp"
#include "Connection.hpp"
#include "Connector.hpp"
using namespace SocketModule;
static const int defaultport = -1;
// 【核心注释】Listener 是 Connection 体系中的一个特殊派生类。
// 在构建大型网络应用(例如一个音乐播放客户端的后端服务端)时,Listener 就扮演着"迎宾员"的角色。
// 它的唯一职责就是站在指定的端口上等待并接收新客户端的接入请求,而不是像普通连接那样去解析和收发具体的业务报文。
class Listener : public Connection
{
public:
// 【核心注释】构造函数:完成监听套接字的初始化
// 传入指定的端口号,并通过智能指针实例化一个 TcpSocket 类型的底层套接字对象。
Listener(int port = defaultport)
: _port(port), _listensock(std::make_unique<TcpSocket>(port))
{
// BuildSocketMethod 内部通常封装了底层的 socket()创建、bind()绑定端口、listen()声明监听等基础网络调用。
// 执行完这一步,该套接字就正式开始在 _port 端口上守候客户端连接了。
_listensock->BuildTcpSocketMethod(port);
}
~Listener()
{
// 析构函数体:通常为空
}
// 【核心注释】重写基类的 Recver 方法,专门用于处理新连接
// Listener 的"读"事件:本质是 Accept 接收新连接
// 当 epoll 发现 _listensockfd 触发了 EPOLLIN(可读事件)时,说明有新的客户端发起了三次握手。
// 所以这里的 Recver 内部逻辑并非调用 recv() 读数据,而是要调用 accept() 获取新客户端的 fd,
// 随后将这个新的 fd 包装成普通的业务连接对象(如 Connector),再交由 Reactor 进行统一管理。
void Recver() override
{
}
// Sender 为空实现:监听套接字主要负责"接入",通常不需要主动向客户端发送普通数据,所以写事件处理一般为空,或者仅用于特定的状态日志记录。
void Sender() override
{
// 空实现
}
// Excepter 为空实现:处理监听套接字自身出现的异常情况(如底层文件描述符耗尽等极端情况)。
void Excepter() override
{
// 空实现
}
private:
// 端口号
int _port;
// 【核心注释】底层监听套接字对象
// 采用 std::unique_ptr 管理,符合 C++ 的 RAII(资源获取即初始化)思想。
// 确保 Listener 对象被销毁时,底层的 Socket 资源能被自动且安全地释放,避免文件描述符泄漏。
std::unique_ptr<Socket> _listensock;
};
#endif
代码解读:
- 类的定位:继承 Connection,专门处理连接接入 Listener 公有继承自
Connection,复用基类的事件管理、回调接口规范。它是 Reactor 模型里的 "迎宾角色",不处理业务数据收发,只负责等待客户端 TCP 连接请求。当 epoll 检测到监听 fd 可读,就调用Recver,在这里执行accept拿到新连接。 - 构造函数:一站式完成监听套接字创建 构造函数接收端口号,创建
TcpSocket智能指针对象,调用BuildTcpSocketMethod,内部封装 socket、bind、listen 整套流程。 使用unique_ptr管理 socket 对象,RAII 自动管理资源,对象生命周期结束自动关闭 socket,防止 fd 泄漏。 - 重写Connection基类虚函数,完成多态适配
Recver():核心重写函数。普通 Connector 的 Recver 是读业务数据;Listener 的 Recver 语义完全不同,可读事件代表新连接到来,内部调用 accept 获取新 fd,将新 fd 封装为 Connector 连接对象,交给 Reactor 注册进 epoll。
2.2 Connector:普通 IO 连接的封装
Connector 对应每一个 accept 出来的客户端连接,负责真正的业务数据收发。目前我们先搭框架,方法体留空,后续会填充具体的 recv/send逻辑、缓冲区管理和业务处理。
cpp
#ifndef CONNETOR_HPP
#define CONNETOR_HPP
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <memory>
#include <functional>
#include "Common.hpp"
#include "Connection.hpp"
#include "Log.hpp"
#include "InetAddr.hpp"
using namespace LogModule;
#define SIZE 1024
// 【核心注释】IOHandler 是 Connection 体系下的常规业务连接处理类。
// 与 Listener 专门负责"接客"(获取新连接)不同,IOHandler 专门负责处理已经建立好连接的客户端数据流。
// 在开发诸如仿 QQ 音乐客户端的后端通信模块等实际应用时,这里就是处理具体业务逻辑的"前线":
// 比如负责接收客户端发来的"播放"、"暂停"或"拉取歌单"等请求报文,并将响应数据安全地发送回去。
class Connector : public Connection
{
public:
// 构造函数:通常在实际完善时,这里需要传入由 Listener 接收(accept)所产生的新文件描述符,并对基类进行初始化。
Connector(int sockfd, const InetAddr &client)
: Connection(sockfd), _client_addr(client)
{
// 新获取到的连接套接字
// 1.初始化事件event为可读和ET模式
SetEvent(EPOLLIN | EPOLLET);
// 2.初始化设置连接套接字为非阻塞状态
SetNonBlock(_sockfd);
}
// 问题1:怎么保证我把本轮数据读取完毕? while 循环 --- 本层只解决IO问题 --- done
// 问题2:即便你把本轮数据读完,你怎么知道数据有完整的报文。如果不完整呢?如果是多个报文呢?粘报问题?反序列化 --- 引入协议的
// 【核心注释】IOHandler 的"读"事件处理逻辑:负责真正的报文读取。
// 当 epoll 检测到该 _sockfd 触发了 EPOLLIN(可读事件)时被调用。
// 这里的核心工作是调用 recv(),将内核接收缓冲区中的数据读取出来,并持续追加到从基类继承下来的 inbuffer(应用层接收缓冲区)中。
// 只有在 inbuffer 中积攒了完整的协议报文后,才会向上层业务交付,从而彻底解决 TCP 的粘包/半包问题。
void Recver() override
{
}
// 【核心注释】IOHandler 的"写"事件处理逻辑:负责真正的数据发送。
// 当 epoll 检测到该 _sockfd 触发了 EPOLLOUT(可写事件)时被调用。
// 核心工作是调用 send(),将从基类继承下来的 outbuffer(应用层发送缓冲区)中的待发数据刷入系统内核。
// 如果底层的发送窗口满了导致一次没发完,剩余数据会继续留在 outbuffer 中,等待下一次 epoll 触发可写事件时自动继续发送,实现真正的异步非阻塞。
void Sender() override
{
}
// 【核心注释】IOHandler 的"异常"统一收口点。
// 当 Reactor 发现该连接遇到 EPOLLERR 或 EPOLLHUP,亦或是由于对端意外断开、网络波动引发了读写错误时,会集中跳转到这里进行处理。
// 它的主要职责是进行资源清理:关闭底层的 _sockfd,并将自身从 epoll 的监控树和服务器的连接哈希表中安全摘除。
void Excepter() override
{
}
~Connector()
{
// 析构函数体:通常为空
}
private:
// 【核心注释】专属的应用层接收缓冲区。
// 这是解决 TCP 面向字节流带来的"粘包"和"半包"问题的关键。它保证了即便一次未能读完完整报文,剩余数据也不会丢失,留待下次拼接。
std::string _inbuffer; // 输入缓冲区
// 【核心注释】专属的应用层发送缓冲区。
// 当系统的底层 socket 发送缓冲区满时,将待发送的数据暂存于此。等 epoll 下次触发可写事件时再继续发送,这是实现异步非阻塞的核心。
std::string _outbuffer; // 输出缓冲区
// 记录对端的网络地址信息(IP和端口号),方便后续的日志记录、调试或鉴权
InetAddr _client_addr;
};
#endif
代码解读:
- 类定位:继承 Connection,处理客户端业务 IO Connector 公有继承
Connection,复用基类的事件管理、虚函数接口。Listener 只负责接收新连接,Connector 才是真正处理客户端业务收发的对象。每一个客户端 TCP 连接,都会对应一个独立 Connector 实例。
构造函数接收 accept 返回的sockfd与客户端地址,完成两件关键初始化:注册EPOLLIN | EPOLLET,采用 ET 边沿触发模式;并且把套接字设置非阻塞,这是 ET 模式强制要求。 - 重写虚函数,完成多态接口
Recver(): 读事件处理。ET 模式下需要 while 循环一次性把内核缓冲区数据全部读完,读取的数据追加到_inbuffer。在缓冲区中解析报文边界,只有组装出完整业务报文之后,才交给上层业务回调处理,解决 TCP 字节流粘包半包。Sender(): 写事件处理。当 epoll 触发 EPOLLOUT,代表内核发送缓冲区有空间。从_outbuffer取出待发送数据调用 send 发送;如果一次发送不完,剩余数据留在_outbuffer,等待下一次可写事件继续发送。- **
Excepter():**异常统一处理。对端关闭、网络异常、fd 出错时触发,在这里执行资源回收:关闭 socket、从 epoll 移除 fd、销毁 Connector 对象,防止 fd 与内存泄漏。
到这里,我们就完成了**"先描述"** :所有连接都有了统一的抽象和具体的实现类 。接下来就要进入**"再组织"** 阶段 ------ 用一个容器把所有连接管起来,再配合事件循环做派发。
四、Poller:多路复用接口的统一封装
在写 Reactor 主体之前,我们先把 epoll 的系统调用也封装一层,叫Poller。
为什么我们是针对 epoll 进行封装,而却叫做 Poller 呢?这样做的好处是:在我们这份代码中虽然是基于 epoll 多路转接服务器,但是后续如果我们还想基于 select/poll 来实现服务器,我们就不需要在额外封装新模块了。
上层 Reactor 只关心 "添加事件""等待事件",不用管底层到底是 epoll、select 还是 poll,后续想换多路复用模型,只需要新增子类即可。
4.1 模块前置:统一错误码 Common.hpp
先补充一个公共头文件 Common.hpp ,把所有错误退出码统一管理 ,方便后续定位问题,Common.hpp 不仅仅只有这个功能,后续我们还会继续添加。
cpp
#pragma once
#include <iostream>
#include <string>
#include <memory>
#include <cstdlib>
#include <unistd.h>
#include <functional>
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <strings.h>
enum ExitCode
{
OK = 1,
USAGE_ERR,
SOCKET_ERR,
BIND_ERR,
LISTEN_ERR,
CONNECT_ERR,
FORK_ERR,
OPEN_ERR,
EPOLL_CREATE_ERR,
EPOLL_CTL_ERR,
ACCEPT_DONE,
ACCEPT_CONTINUE,
ACCEPT_ERROR
};
4.2 Poller 类框架实现
cpp
#ifndef POLLER_HPP
#define POLLER_HPP
#include <iostream>
#include <unistd.h>
#include <sys/epoll.h>
#include "Common.hpp"
#include "Log.hpp"
using namespace LogModule;
// 【核心注释】Poller 类负责封装底层的 I/O 多路转接 API(此处特指 epoll)。
// 它是整个 Reactor 模型的"侦察兵"。Reactor 本身不直接和内核打交道,
// 而是通过 Poller 来监听并收集系统中哪些文件描述符 (fd) 的哪些事件(如可读、可写)已经就绪。
class Poller
{
public:
// 【核心注释】构造函数:在内核中创建 epoll 模型。
Poller() : _epfd(-1)
{
// 调用 epoll_create 在内核中申请构建 epoll 专用的数据结构(红黑树 + 就绪队列)。
// gsize 在较新的 Linux 内核中只要大于 0 即可,内核会自动分配。
_epfd = epoll_create(128);
if (_epfd < 0)
{
// epoll模型创建失败
LOG(LogLevel::FATAL) << "epoll_create error";
// 创建失败属于致命错误,直接利用 Common.hpp 中统一定义的错误码退出进程。
exit(EPOLL_CREATE_ERR);
}
LOG(LogLevel::INFO) << "epoll_create success, _epfd: " << _epfd;
}
// 【核心注释】epoll_ctl 通用辅助函数,统一封装 ADD / MOD 操作。
// 参数 sockfd:待管理的文件描述符
// 参数 events:需要监听的事件集合,例如 EPOLLIN、EPOLLET
// 参数 oper:epoll_ctl 的操作类型,EPOLL_CTL_ADD / EPOLL_CTL_MOD
void ModEventHelper(int sockfd, uint32_t events, int oper)
{
struct epoll_event ep;
ep.events = events;
ep.data.fd = sockfd;
int n = epoll_ctl(_epfd, oper, sockfd, &ep);
if (n < 0)
{
LOG(LogLevel::ERROR) << "epoll_ctl error";
return;
}
LOG(LogLevel::INFO) << "epoll_ctl success, sockfd: " << sockfd;
}
// 【核心注释】向epoll实例中添加待监听的fd与事件。
// 底层调用 ModEventHelper,执行 EPOLL_CTL_ADD,将fd注册进内核epoll红黑树。
void AddEvent(int sockfd, uint32_t events)
{
ModEventHelper(sockfd, events, EPOLL_CTL_ADD);
}
// 【核心注释】从epoll实例中移除文件描述符,不再监听该fd的任何事件。
// 底层调用 EPOLL_CTL_DEL,将fd从内核epoll红黑树删除。
void DelEvent(int sockfd)
{
int n = epoll_ctl(_epfd, EPOLL_CTL_DEL, sockfd, nullptr);
if (n < 0)
{
LOG(LogLevel::ERROR) << "EPOLL_CTL_DEL error";
return;
}
LOG(LogLevel::INFO) << "已成功从内核移除, sockfd: " << sockfd;
}
// 【核心注释】修改已注册fd的监听事件。
// 底层调用 ModEventHelper,执行 EPOLL_CTL_MOD,更新内核中该fd对应的事件掩码。
void ModEvent(int sockfd, uint32_t events)
{
ModEventHelper(sockfd, events, EPOLL_CTL_MOD);
}
// 【核心注释】事件收集接口:等待并获取底层已就绪的网络事件。
// 这是 Reactor 的核心驱动力。Reactor 的 Dispatcher 死循环中调用的就是此接口。
// 参数 revs[]: 输出型参数,用户态传入一个数组,内核会将就绪事件逐个填充进这个数组交还给上层。
// 参数 maxevents: 数组的最大容量,防止内核填充越界。
// 参数 timeout: 超时时间(毫秒级)。0 表示非阻塞,-1 表示死等阻塞,>0 表示带超时的阻塞。
int WaitEvents(struct epoll_event revs[], int maxnum, int timeout)
{
// 调用 epoll_wait,如果没事件则阻塞等待;有事件则将就绪队列中的节点提取到 revs 数组中。
// 返回值 n 表示实际有多少个 fd 的事件已经就绪。
int n = epoll_wait(_epfd, revs, maxnum, timeout);
if (n < 0)
{
LOG(LogLevel::WARNING) << "epoll_wait error";
}
else if (n == 0)
{
LOG(LogLevel::WARNING) << "timeout...";
}
return n;
}
~Poller()
{
}
private:
// 保存在内核空间创建出的 epoll 模型的专属文件描述符句柄
int _epfd;
};
// 【核心注释】面向对象的设计进阶:多路转接模型的抽象化。
// 上面的 Poller 是硬编码死绑了 epoll。但一个真正成熟的网络库(如 muduo)会支持多种多路转接机制。
// 下面的注释代码展示了如何利用面向对象的"多态"进一步解耦:定义一个纯虚基类 Poller,
// 然后派生出 SelectPoller、PollPoller 和 EpollPoller,上层只需拿着基类指针即可,具体使用哪种模型可以在运行时决定。
// 下面这种形式也是可以的, 这样就可以分别实现select,poll,epoll版本的了
// 帮我们监听所有的fd是否就绪!!
// class Poller
// {
// public:
// virtual bool Create() = 0;
// virtual bool Destroy() = 0;
// virtual void GetEvents() = 0;
// virtual void SetFdEvent() = 0;
// };
// class SelectPoller : Poller
// {
// };
// class PollPoller:Poller
// {
// };
// class EpollPoller: Poller
// {
// };
#endif
本次我们实现的服务器代码是基于 epoll 版本的,所以针对 select/poll 的派生类实现我们预留了扩展空间。后续想做 Poller 基类派生出 SelectPoller、PollPoller 也非常方便,符合开闭原则。
五、Reactor 反应堆核心 --- 再组织:连接管理与事件派发
Reactor 反应堆核心是整个框架的真正核心。它的职责就两个:管理所有 Connection 连接 ,循环等待事件并派发。
cpp
#ifndef REACTOR_HPP
#define REACTOR_HPP
#include <iostream>
#include <memory>
#include <unordered_map>
#include "Poller.hpp"
#include "Connection.hpp"
#include "Log.hpp"
using namespace LogModule;
// 【核心注释】Reactor(反应堆)类:网络通信框架的中枢神经。
// 它是"再组织"理念的具体体现。无论是高并发的后台服务,还是类似仿 QQ 音乐等客户端底层的网络通信模块,
// Reactor 的作用都是将"多路转接(Poller)"和"所有的连接对象(Connection)"强力粘合在一起。
// 它的核心运行机制是:死等事件到来 -> 取出哪个 fd 就绪 -> 查哈希表找到对应对象 -> 依据多态派发给对应的处理逻辑。
class Reactor
{
static const int revs_num = 128;
private:
// 【核心注释】安全防线:判断连接是否还存活。
// 在真正派发读写事件前检查哈希表,防止在一个循环周期内,由于某些异常导致该 fd 已经被注销,避免访问野指针。
bool IsConnectionExists(int sockfd)
{
auto iter = _connections.find(sockfd);
if (iter == _connections.end())
{
return false;
}
return true;
}
// 【核心注释】判断连接哈希表是否为空。
// 用于辅助判断服务器当前是否存在任何客户端连接,可用于空闲状态检测、资源回收判断。
bool IsConnectionEmpty()
{
return _connections.empty();
}
// 【核心注释】事件分发引擎(主循环)。
// 程序跑起来后就会卡在这个死循环里,它是整个异步非阻塞网络库的心脏。
void Dispatcher(int n) // n 是就绪事件的数量
{
for (int i = 0; i < n; i++)
{
int sockfd = _revs[i].data.fd;
uint32_t events = _revs[i].events;
// 1. 将所有的异常处理,统一转化成IO错误 2. 所有的IO异常,统一转换成为一个异常处理函数
// 也就是说即使文件描述符就绪的事件存在问题,我们也不需要立刻处理,因为这样我们就需要在这里针对每个错误都写一份处理代码
// 我们的做法就是不管什么问题都转换成IO错误,最后都会调用recv/send并且失败返回,所有问题最终就能在同一个异常处理函数进行处理了。
if (events & EPOLLERR)
{
events |= (EPOLLIN | EPOLLERR); // 将所有的异常处理,统一转化成IO错误
}
if (events & EPOLLHUP)
{
events |= (EPOLLIN | EPOLLHUP); // 将所有的异常处理,统一转化成IO错误
}
// 不管是读事件还是写事件就绪,我们还需要对上述的异常额外判断吗?不需要
if (events & EPOLLIN)
{
// 当读事件就绪时,如果读取异常了我们就需要进行异常处理,对异常的connection进行移除,
// 那么如果此时写事件也就绪时,那么就会访问不存在的sockfd出现问题
// 所以,不管是读事件就绪还是写事件就绪我们都需要判断connection是否还存在 ------> 增加代码健壮性和鲁棒性
if (IsConnectionExists(_connections[sockfd]->GetSockFd()))
{
// 当读事件就绪时,我们还需要通过sockfd额外使用if判断是监听套接字还是普通套接字吗?不需要了
// 因为unordered_map我们填入sockfd返回的就是对应的Connection,借助多态,就可以针对子类对象在不同的子类中调用Recver/Sender
_connections[sockfd]->Recver();
}
}
if (events & EPOLLOUT)
{
if (IsConnectionExists(_connections[sockfd]->GetSockFd()))
{
_connections[sockfd]->Sender();
}
}
}
}
public:
// 初始化时自动实例化底层的 epoll 模型
Reactor()
: _isrunning(false), _epoller_ptr(std::make_unique<Poller>())
{
}
// 【核心注释】连接接入接口:将新对象注册到系统中。
// 无论是启动时初始化的 Listener,还是后续 accept 产生的新 Connector,都要通过它接入网络框架。
void AddConnection(std::shared_ptr<Connection> &conn)
{
// 0. 去重:检查连接节点是否已存在,已存在则无需插入
if (IsConnectionExists(conn->GetSockFd()))
{
LOG(LogLevel::WARNING) << "conn is exists: " << conn->GetSockFd();
return;
}
// 1. 拿到fd和事件, 写透到内核中
// 【核心注释】向下(内核态)注册:调用底层的 epoll_ctl,让系统内核帮你盯着这个 fd 的这些事件。
int sockfd = conn->GetSockFd();
uint32_t events = conn->GetEvent();
// 1.1 设置到内核
_epoller_ptr->AddEvent(sockfd, events);
// 2. 设置当前 conn 的拥有者回指指针,回指向 conn 处于的Reactor容器的起始地址
conn->SetOwner(this);
// 3. 放到_connections 里面管理起来这个连接
// 【核心注释】向上(用户态)注册:把指向具体业务对象的智能指针塞进哈希表。
// 这一步非常关键,建立起 fd 到 Connection 对象的映射。等系统底层事件就绪时,我们就能立刻找回这个对象。
_connections[sockfd] = conn; // 使用unordered_map重载的方括号进行存放
LOG(LogLevel::INFO) << "成功将 sockfd: " << sockfd << " 添加到_connections中并且放入内核";
}
// 【核心注释】连接删除接口:从反应堆框架移除一条连接,完成完整资源回收。
// 参数 sockfd:待删除套接字;client:对端客户端地址,用于日志打印。
void DelConnection(int sockfd, InetAddr &client)
{
// 1. epoll移除的时候,sockfd必须是合法的
_epoller_ptr->DelEvent(sockfd);
// 2. 从_connections移除自己
_connections.erase(sockfd);
LOG(LogLevel::INFO) << "client quit..., client: " << client.StringAddress();
// 3. 关闭不要的sockfd
close(sockfd);
}
// 析构函数
~Reactor()
{
// 析构函数体:通常为空,智能指针会自动释放资源
}
private:
// 1. epoll模型
std::unique_ptr<Poller> _epoller_ptr;
// 2. 是否启动
bool _isrunning;
// 3. 管理所有的connection。本质是维护管理未来所有获取到的fd以及相关属性字段
// 【核心注释】对象池:以 sockfd 为键,统一保存所有的 Connection 派生类实例。这是"再组织"的核心数据结构。
// fd : Connection
std::unordered_map<int, std::shared_ptr<Connection>> _connections;
// 4. 就绪的所有事件
// 每次调用 WaitEvents 时传给底层内核的就绪事件接收数组。
struct epoll_event _revs[revs_num];
};
#endif
核心设计解读:
1. 连接管理:哈希表实现连接统一收纳
底层采用 unordered_map<int, shared_ptr<Connection>> 哈希容器,以文件描述符 fd 作为键,保存全部连接对象。新增、删除、查找连接的时间复杂度均为 O (1)。 这正是 Reactor "再组织" 思想的落地:原本分散独立的 Listener 监听连接、Connector 业务连接实例,全部收纳在同一个哈希容器中集中管控。Reactor 可以统一完成连接注册、销毁、检索,实现连接生命周期的集中管理。
2. 事件派发:利用多态屏蔽不同连接的行为差异
事件派发逻辑不再需要大量if分支区分监听 fd 与普通业务 fd。 不管是监听套接字还是客户端 IO 套接字,当读事件就绪,统一调用Recver();写事件就绪,统一调用Sender()。底层依靠 C++ 虚函数多态机制,自动匹配对应类的实现:Listener 的 Recver 执行 accept 接收新连接,Connector 的 Recver 执行业务数据读取。 这种写法消除大量分支判断,代码逻辑简洁,后续新增连接类型也无需修改派发主逻辑,扩展性很强。
3. 统一异常处理:简化异常分支的设计技巧
框架没有单独开辟分支去调用Excepter()做异常处理,而是将EPOLLERR、EPOLLHUP异常事件,统一转化为读写事件。 当异常事件触发后,会进入Recver的处理流程:在recv读取数据时,返回 0 或者 - 1 就可以识别连接关闭 / IO 错误,在 Recver 内部完成连接销毁与资源回收。 优势在于异常处理逻辑只需要在 Recver 一处编写,不需要在事件派发层、业务读写层重复编写异常分支,减少冗余代码,同时避免多处异常处理逻辑不一致、遗漏释放资源的问题。
4. 健壮性保障:事件派发前的连接存在性校验
在调用Recver()、Sender()回调之前,会先校验当前 fd 对应的连接对象是否还保存在哈希表容器中。 原因:在一轮事件循环处理多个就绪 fd 时,处理前一个事件的过程中,有可能已经将该连接从哈希表删除并释放资源。如果不做存在性校验,后续继续访问哈希表内该 fd 对应的对象,会触发野指针访问、越界访问,直接导致程序崩溃。存在性检查就是一道安全防护,保证 Reactor 框架运行稳定。
本文小结
到这里,我们 Reactor 大致的核心骨架就全部搭建完成了。虽然相关函数我们还没有实现,并且针对后续的实际读写数据操作我们还需要对各个模块额外添加接口,但是大体的核心逻辑就是如上面讲解。回顾一下本篇的核心设计思想:
- 先描述:用 Connection 基类抽象所有连接,通过多态统一不同套接字的行为;
- 再组织:用哈希表管理所有连接,Reactor 统一负责事件注册与派发;
- 分层解耦:Poller 封装多路复用,Connection 负责业务 IO,Reactor 只管调度,各层职责单一。
结束语
到这里,Reactor 反应堆框架的基础骨架已经全部搭建完成。我们从原生 epoll 代码的架构痛点出发,抽象出统一的 Connection 基类,区分监听套接字 Listener 与业务 IO 套接字 Connector,封装 Poller 隔离 epoll 底层调用,最后实现 Reactor 核心模块,依靠哈希表管理全部连接、借助多态完成事件派发,完成了事件驱动服务器最核心的 "再组织" 设计。
这套框架解决了原生 epoll 代码中 fd 零散管理、大量 if 分支、异常处理逻辑分散等工程问题。当然当前版本只是基础骨架,还缺少读写缓冲区、事件回调注册、连接销毁时的资源清理等工程化能力。
在下一篇文章中,我们将继续完善 Reactor 框架,补充缓冲区模块,优化事件回调机制,进一步打磨服务器的健壮性,实现一个完整可用于业务的 Echo 服务。