刷到一个挺有意思的话题,结合自己之前的经验,整理了一下核心要点。
**
🎬 个人主页:艾莉丝努力练剑
❄专栏传送门:《C语言》《数据结构与算法》《C/C++干货分享&学过程记录》
《Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享》
⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平
🎬 艾莉丝的简介:
这篇目录
1 ~> 原生Epoll服务器的核心痛点与设计缺陷 2 ~> Reactor模式的核心设计思想:“先描述,再组织” 3 ~> Reactor核心组件的实现细节 3.2 Listener:监听连接的实现 3.3 IOHandler:普通IO连接的框架 3.4 Poller:Epoll多路复用的封装 3.5 Reactor容器:连接的组织与管理 3.6 服务器启动流程 3.7 知识图谱 4 ~> 事件派发机制与异常统一处理 5 ~> LT与ET触发模式的底层原理与工程差异 5.2 边缘触发(ET)的开发机制 5.3 两种模式的工程取舍 6 ~> 代码框架和代码展示- 6.1 Reactor新增模块
- 公共:Common.hpp
- 针对套接字的二次封装:Connection.hpp
- IO处理器:lOHandler.hpp
- 连接管理器:Listener.hpp
- epoll模型:Poller.hpp(用了Epoll)
- 6.3.1 代码
- 6.3.2 Main.cc知识图谱
- 6.3.3 代码详解与设计要点
- 1. 命令行参数解析
- 2. 监听连接的创建与配置
- 3. Reactor容器的创建
- 4. 连接注册流程
- 5. 事件派发循环
- 6. 错误处理与资源清理
前言
一、 开头部分(框架引入)
整体学框架导入语
本文围绕基于epoll的Reactor反应堆模式展开,系统性拆解高性能网络服务器从原生epoll实现到模块化Reactor架构的完整演进路径。原生epoll代码虽能实现基础的多路IO转接,但存在连接管理混乱、缓冲区无归属、事件处理耦合度高、异常处理分散等工程化缺陷,无法支撑高并发场景下的稳定性与可维护性。Reactor模式通过“一切皆连接”的抽象思想,以多态封装不同类型的套接字行为,将事件监听、连接管理、IO处理分层解耦,是工业级网络服务器的标准设计范式。本文将从原始代码的痛点出发,逐层拆解Reactor的组件设计、事件派发逻辑、异常处理方案,并深入辨析LT与ET两种触发模式的底层差异,形成完整的知识闭环。
思维导图
Reactor模式核心知识体系
|-- 原生Epoll服务器的痛点
| |-- IOHandler缓冲区问题
| | |-- 栈缓冲区无法处理半包
| | `-- 多连接缓冲区无归属
| |-- 事件处理耦合
| | |-- 监听套接字与普通套接字逻辑混杂
| | `-- 异常处理分散在各处
| `-- 代码可扩展性差
|
|-- Reactor核心设计思想
| `-- 先描述,再组织
| |-- 描述:Connection抽象所有连接
| `-- 组织:容器统一管理所有连接
|
|-- 核心组件实现
| |-- Connection基类
| | |-- 纯虚接口:Recver/Sender/Excepter
| | |-- 公共成员:输入缓冲区/输出缓冲区/事件集合
| | `-- 公共方法:Sockfd/Events/SetEvents
| |
| |-- Listener(监听连接)
| | |-- 继承Connection基类
| | |-- 内部封装Tcp监听套接字
| | `-- Recver负责执行Accept获取新连接
| |
| |-- IOHandler(普通IO连接)
| | |-- 继承Connection基类
| | |-- 对应客户端通信套接字
| | `-- 实现完整的读写异常逻辑
| |
| |-- Poller(多路复用封装)
| | |-- 封装epoll_create/epoll_ctl/epoll_wait
| | |-- AddEvents:注册fd与事件到内核
| | `-- WaitEvents:获取就绪事件集合
| |
| `-- Reactor容器
| |-- 内部维护unordered_map连接表
| |-- AddConnection:注册连接到反应堆
| `-- Dispatcher:事件循环与派发
|
|-- 事件派发机制
| |-- 事件循环:epoll_wait阻塞等待
| |-- 读事件分发:调用对应连接的Recver
| |-- 写事件分发:调用对应连接的Sender
| `-- 异常统一处理:错误事件转读写事件
|
`-- LT与ET触发模式
|-- 水平触发(LT)
| |-- 有数据就持续通知
| |-- 编程简单,不易丢数据
| `-- 用户态内核态拷贝次数多
|
`-- 边缘触发(ET)
|-- 仅状态变化时通知一次
|-- 必须一次性读完所有数据
|-- 效率更高,减少通知次数
`-- 编程复杂度高,必须非阻塞
1 ~> 原生Epoll服务器的核心痛点与设计缺陷
1.1 原始IOHandler的实现与固有缺陷
原生epoll服务器的IO处理逻辑直接在栈上开辟缓冲区,单次recv后立即处理并回发,代码结构如下:
void IOHandler(int fd)
{
// 栈上临时缓冲区,存在本质缺陷
char buffer[1024];
ssize_t n = recv(fd, buffer, sizeof(buffer) - 1, 0);
if(n > 0)
{
buffer[n] = 0;
LOG(LogLevel::INFO) SetEvents(EPOLLIN | EPOLLET);
// 2. 创建Reactor反应堆实例
std::unique_ptr reactor = std::make_unique();
// 3. 将监听连接注册到反应堆中
reactor->AddConnection(conn);
// 4. 启动事件派发循环,服务器开始运行
reactor->Dispatcher();
return 0;
}
启动流程说明
- 第一步创建Listener对象时,内部会自动完成TCP套接字的创建、地址绑定与端口监听,完成服务器的端口监听准备。
- 通过SetEvents设置该连接需要被epoll监听的事件类型,监听套接字只需监听读事件,可根据性能需求选择LT或ET模式。
- 将监听连接注册到Reactor后,调用Dispatcher进入事件循环,服务器开始持续监听事件并处理。当有新连接到来时,Listener的Recver会被触发,创建新的IOHandler并注册到Reactor中,实现连接的动态接入。
3.7 知识图谱
3.7.1 关系类的先描述与多态设计:Connection类和Listener类
3.7.2 事件驱动引擎Poller模块的封装
4 ~> 事件派发机制与异常统一处理
4.1 事件派发的核心流程
Reactor的事件派发遵循“内核监听、用户态分发”的分层逻辑,内核态与用户态通过epoll协同开发:
1. 内核通过epoll红黑树管理所有被监听的fd,当fd上的事件就绪时,将其加入就绪队列。
2. 用户态调用epoll_wait获取就绪事件列表,获得一组就绪的fd与对应事件。
3. 遍历就绪事件列表,根据fd从连接表中找到对应的Connection对象。
4. 根据事件类型,调用Connection对象对应的Recver、Sender方法。
5. 处理完成后回到步骤2,进入下一轮事件等待。
整个过程中,派发层完全不需要感知连接的具体类型,所有差异都通过多态在子类中实现,这是Reactor模式解耦的核心体现。
4.2 异常事件的统一处理方案
epoll的异常事件包括EPOLLERR(套接字错误)与EPOLLHUP(对端挂断)。若单独为异常事件添加处理分支,会导致每个连接的异常逻辑分散在派发层与连接内部两处,维护成本高。 Reactor采用异常转读写的统一处理方案: 当检测到异常事件时,不直接调用Excepter,而是手动将事件标记为读+写就绪,强制触发读写逻辑。此时连接在执行读/写操作时必然会返回错误,进而在读写函数内部执行异常清理与连接释放。
这种设计的优势在于:
- 所有连接的退出逻辑都收敛在Recver/Sender内部,异常处理路径唯一,便于维护与排查问题。
- 派发层逻辑更简洁,无需单独处理异常分支,减少了代码冗余。
- 兼容各种异常场景,无论是读取时出错还是写入时出错,都能通过统一路径释放资源。
4.3 事件派发机制和异常处理的知识图谱
核心事件派发机制
Dispatcher多态派发回路的核心设计
异常流的内化转换与“统一异常处理”机制
5 ~> LT与ET触发模式的底层原理与工程差异
5.1 水平触发(LT)的工作机制
水平触发是epoll的默认工作模式,其核心特性是:只要文件描述符上还有未处理的数据,就会持续不断地向用户态发送就绪通知。 可以用“打电话”的类比搞懂:来电后只要未接通,电话就会持续响铃,直到接听并处理完毕。对应到编程中,只要接收缓冲区里还有数据,epoll_wait就会一直返回该fd的读就绪事件。
LT模式的核心特性
- 编程简单:用户不需要一次性读完所有数据,没读完的话下一次epoll_wait还会继续通知,不容易丢数据。
- 通知次数多:如果数据分多次慢慢读取,会触发大量的就绪通知,增加用户态与内核态的切换开销。
- 支持阻塞与非阻塞IO:既可以用阻塞方式读写,也可以用非阻塞方式,容错性高。
5.2 边缘触发(ET)的工作机制
边缘触发的核心特性是:只有当文件描述符的状态发生变化时,才会发送一次就绪通知。即便缓冲区里还有未处理的数据,只要没有新的事件发生,也不会再次通知。 对应“打电话”的类比:来电只响一声就停止,一定要在这一次通知内处理完所有数据,否则就会错过事件,直到下一次新数据到来才会再次触发。
ET模式的核心特性
- 通知次数少:无论缓冲区有多少数据,只在状态变化时通知一次,大幅减少内核态与用户态的切换次数,效率更高。
- 编程要求高:用户一定要在一次通知中把缓冲区里的所有数据全部读完,否则剩余数据不会再触发通知,会导致数据残留、连接假死。
- 一定要配合非阻塞IO:如果使用阻塞IO,循环读取到最后会因为无数据而阻塞在recv上,导致整个事件循环卡死。因此ET模式下所有套接字必须设置为非阻塞。
5.3 两种模式的工程取舍
对比维度水平触发(LT)边缘触发(ET)通知机制有数据就持续通知仅状态变化时通知一次编程复杂度低,不易出错高,必须循环读取且非阻塞性能开销通知次数多,切换开销大通知次数少,切换开销小数据安全性高,不会丢数据低,处理不当易丢数据适用场景并发量一般、追求稳定性的业务高并发场景、追求极致性能
在工业级实现中,监听套接字通常推荐使用ET模式,因为新连接事件属于状态变化,单次accept即可处理完毕;普通业务连接可根据团队技术储备与业务特性选择,LT模式更稳妥,ET模式性能更优。
6 ~> 代码框架和代码展示
6.1 Reactor新增模块
公共:Common.hpp
#pragma once
// 错误码放这里,统一进行管理
enum
{
SOCKET_ERR = 1,
BIND_ERR,
LISTEN_ERR,
EPOLL_ERR
};
针对套接字的二次封装:Connection.hpp
#pragma once
// 每一个fd,后续都对应一个connection连接 -- 针对套接字的二次封装
#include
#include
#include "InetAddr"
// “先描述” - 基类
class Connection
{
public:
Connection() : _events(0)
{}
// // sockfd下沉了,这里也注释掉
// Connection() : _sockfd(0)
// {}
// 有了Events和Sockfd,任何一个连接就可以获取对应的套接字和事件了
uint32_t Events()
{
return _events;
}
// 设置标志位
void SetEvents(uint32_t events)
{
_events = events;
}
virtual void Recver() = 0;
virtual void Sender() = 0;
virtual void Excepter() = 0;
~Connection(){}
protected:
// int _sockfd; // 套接字 -- 可能是listen套接字也可能是普通套接字 -- 挪地方,把sockfd沉下去,就不在这里写了
std::string _inbuffer; // 接收缓冲区,一个套接字一个
std::string _outbuffer; // 发送缓冲区
InetAddr _clientaddr; // client socket套接字对应客户端地址
uint32_t _events; // Connection关心什么事件
// TODO -- 除了这些,未来还需要什么再继续往下加
};
IO处理器:lOHandler.hpp
#pragma once
#include
#include
#include "Connection.hpp"
#include "Logger.hpp"
// =========> IO处理器 连接管理器(虽然这么叫,但是特别像当年的TcpServer.hpp)- 先描述 BuildSocketMethod(_port);
}
int Sockfd() override
{
return _listensock->Socketfd();
}
void Recver() override
{
// _listensock->Accepter(); // --> 未来,Listener的Recver就是底层调用一下Accepter,但是其它的套接字调用就是调用Recver
}
void Sender() override
{
}
void Excepter() override
{
}
// 析构这里不写了
private:
uint16_t _port;
std::unique_ptr _listensock;
};
epoll模型:Poller.hpp(用了Epoll)
Poller.hpp将来帮助我们监听所有的fd是否就绪!
```
pragma once
// 专门进行事件管理的epoll模型
// Poller.hpp将来帮助我们监听所有的fd是否就绪!
include
include
include
// exit的头文件include
// 退出码封装成Common.hpp(公共)类,包含一下include "Common.hpp"
include "Logger.hpp"
// // 封装一下IN(读)事件、OUT(写)事件,对事件做一下二次包装
// // 这样添加事件的时候就不用管什么EPOLLIN、EPOLLOUT什么的了,今天不做这个了,我直接把EPOLLIN、EPOLLOUT暴露出去
// #define IN EPOLLIN
// #define IN EPOLLOUT
static const int gsize = 128;
using namespace LogModule;
class Poller
{
Poller()
{
_epfd = epoll_create(gsize);
// epoll创建失败,不用玩了
if(_epfd < 0)
{
LOG(LogLevel::FATAL)
以上就是这次整理的全部内容,希望对你有所启发。如果有不同见解,欢迎在评论区交流讨论。
评论 (0)
暂无评论