整理:【Linux网络】多路转接epoll(三)Reactor模式:基于epoll的高性能网络服务器设计与实现(上)代码框架

刷到一个挺有意思的话题,结合自己之前的经验,整理了一下核心要点。

**

🎬 个人主页:艾莉丝努力练剑

❄专栏传送门:《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.2 日志类配套封装类 6.3 主函数:Main.cc 6.3.4 启动流程时序图6.3.5 扩展建议 6.4 编译链接模块:Makefile6.5 网络和本地socket转换的类:InetAddr.hpp 7 ~> 总结 结尾

前言

一、 开头部分(框架引入)

整体学框架导入语

本文围绕基于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)

暂无评论