【Linux网络】多路转接epoll(四)Reactor模式:基于epoll的高性能网络服务器设计与实现(中)代码细节

🎬 个人主页艾莉丝努力练剑
专栏传送门 :《C语言》《数据结构与算法》《C/C++干货分享&学习过程记录
Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享

⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平


🎬 艾莉丝的简介:


文章目录

  • 前言
  • [1 ~> AddEvents(Poller.hpp)](#1 ~> AddEvents(Poller.hpp))
    • [1.1 Connection基类抽象](#1.1 Connection基类抽象)
      • [1.1.1 基类核心定义](#1.1.1 基类核心定义)
      • [1.1.2 IOHandler派生类框架](#1.1.2 IOHandler派生类框架)
    • [1.2 Poller核心接口实现](#1.2 Poller核心接口实现)
      • [1.2.1 AddEvents事件注册](#1.2.1 AddEvents事件注册)
      • [1.2.2 WaitEvents事件等待](#1.2.2 WaitEvents事件等待)
    • [1.3 事件派发器核心框架](#1.3 事件派发器核心框架)
      • [1.3.1 基础派发逻辑](#1.3.1 基础派发逻辑)
      • [1.3.2 timeout参数的两种模式](#1.3.2 timeout参数的两种模式)
    • [1.4 AddEvents知识图谱](#1.4 AddEvents知识图谱)
  • [2 ~> 以Recver(Listener.hpp)为主线------完善所涉及的细节](#2 ~> 以Recver(Listener.hpp)为主线——完善所涉及的细节)
    • [2.1 Listener类基础定义](#2.1 Listener类基础定义)
      • [2.1.1 类定义](#2.1.1 类定义)
    • [2.2 ET模式下的Accept处理逻辑](#2.2 ET模式下的Accept处理逻辑)
      • [2.2.1 Accepter接口改造](#2.2.1 Accepter接口改造)
      • [2.2.2 循环Accept与错误码判断](#2.2.2 循环Accept与错误码判断)
      • [2.2.3 核心原则](#2.2.3 核心原则)
    • [2.3 非阻塞套接字实现](#2.3 非阻塞套接字实现)
    • [2.4 新连接的托管与回指机制](#2.4 新连接的托管与回指机制)
      • [2.4.1 AddConnection完整实现](#2.4.1 AddConnection完整实现)
      • [2.4.2 设计原理](#2.4.2 设计原理)
    • [2.5 工程细节:头文件包含顺序](#2.5 工程细节:头文件包含顺序)
    • [2.6 Recver(Listener.hpp)知识图谱](#2.6 Recver(Listener.hpp)知识图谱)
  • [3 ~> 以Recver(IOHandler.hpp)为主线------完善所涉及的细节](#3 ~> 以Recver(IOHandler.hpp)为主线——完善所涉及的细节)
    • [3.1 IOHandler类完整定义](#3.1 IOHandler类完整定义)
    • [3.2 Recver非阻塞循环读取](#3.2 Recver非阻塞循环读取)
      • [3.2.1 完整读取逻辑](#3.2.1 完整读取逻辑)
    • [3.3 协议层与业务层的解耦接入](#3.3 协议层与业务层的解耦接入)
      • [3.3.1 分层架构](#3.3.1 分层架构)
      • [3.3.2 回调透传链路](#3.3.2 回调透传链路)
      • [3.3.3 协议层核心接口](#3.3.3 协议层核心接口)
    • [3.4 Sender写事件的正确处理逻辑](#3.4 Sender写事件的正确处理逻辑)
      • [3.4.1 核心原则](#3.4.1 核心原则)
      • [3.4.2 完整发送逻辑](#3.4.2 完整发送逻辑)
      • [3.4.3 EnableReadWrite事件修改接口](#3.4.3 EnableReadWrite事件修改接口)
      • [3.4.4 最后一个坑:多路转接select、poll、epoll当中如何正确理解发送问题!!!](#3.4.4 最后一个坑:多路转接select、poll、epoll当中如何正确理解发送问题!!!)
    • [3.5 Excepter统一异常处理](#3.5 Excepter统一异常处理)
    • [3.6 Recver(IOHandler.hpp)知识图谱](#3.6 Recver(IOHandler.hpp)知识图谱)
  • [4 ~> Version1版本代码](#4 ~> Version1版本代码)
  • [5 ~> 本文总结](#5 ~> 本文总结)
  • 结尾


前言

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

导入语

本文围绕基于epoll实现的单Reactor事件驱动网络模型展开,从底层Poller系统调用封装、监听连接处理、业务IO读写,到分层解耦架构落地,完整覆盖Reactor模型代码实现的全流程。核心攻克ET模式下的非阻塞IO处理规范、事件按需注册机制、连接生命周期管理、双缓冲区设计、系统调用错误边界处理等高频易错痛点,所有代码均经过边界条件审计与逻辑校验,可直接作为高并发网络编程考核的核心复习依据。

思维导图

bash 复制代码
Reactor 代码细节核心知识体系
|-- 1. Poller层:epoll内核接口封装
|   |-- Connection基类抽象
|   |   |-- 纯虚接口:Sockfd/Recver/Sender/Excepter
|   |   |-- 公共成员:事件集合_events、对端地址_clientaddr
|   |   `-- Reactor回指指针_R
|   |-- Poller核心接口
|   |   |-- AddEvents:epoll_ctl_ADD注册事件
|   |   `-- WaitEvents:epoll_wait等待就绪事件
|   `-- 事件派发器
|       |-- LoopOnce单次事件循环
|       |-- 事件分发:读事件/写事件/异常事件
|       `-- timeout参数:阻塞/非阻塞模式
|-- 2. Listener层:监听连接处理
|   |-- Listener类定义
|   |   |-- 继承Connection基类
|   |   |-- 监听套接字_listensock
|   |   `-- 业务回调_on_message透传
|   |-- ET模式Accept逻辑
|   |   |-- 循环accept获取全部就绪连接
|   |   |-- 错误码分支:EAGAIN/EWOULDBLOCK/EINTR
|   |   `-- 强制非阻塞要求
|   |-- 新连接生命周期
|   |   |-- 设置非阻塞属性
|   |   |-- 封装为IOHandler对象
|   |   |-- 注册EPOLLIN | EPOLLET事件
|   |   `-- 托管至Reactor并建立回指
|   `-- 工程细节:头文件依赖与编译顺序
|-- 3. IOHandler层:业务IO处理
|   |-- IOHandler类定义
|   |   |-- 套接字描述符_sockfd
|   |   |-- 双缓冲区:_inbuffer/_outbuffer
|   |   `-- 消息处理回调_on_message
|   |-- Recver读处理
|   |   |-- 非阻塞循环读取
|   |   |-- 返回值三分支:>0/==0/<0
|   |   |-- 错误码边界判断
|   |   `-- 协议回调与报文处理
|   |-- Sender写处理
|   |   |-- 非阻塞循环发送
|   |   |-- 发送缓冲区移除逻辑
|   |   |-- EPOLLOUT按需注册机制
|   |   `-- 发送错误处理
|   `-- Excepter统一异常与资源清理
|-- 4. 分层解耦架构
|   |-- 网络层:Reactor + IOHandler
|   |-- 协议层:Protocol报文封解包
|   |-- 业务层:Calculator业务计算
|   `-- 回调机制:跨层调用解耦
`-- 核心易错考点
    |-- ET模式必须搭配非阻塞IO
    |-- EPOLLIN常设、EPOLLOUT按需注册
    |-- 系统调用错误码三分支判断
    |-- 智能指针循环引用问题
    |-- 连接生命周期空指针防护
    `-- 异常事件的正确处理逻辑

1 ~> AddEvents(Poller.hpp)

本章是Reactor模型的底层基础,完成epoll系统调用的封装、连接基类的抽象以及事件派发的核心框架,是整个事件驱动模型与内核交互的核心层级。

1.1 Connection基类抽象

Connection是所有连接对象的抽象基类,定义了事件处理的统一接口,通过多态实现事件的统一派发。

1.1.1 基类核心定义

cpp 复制代码
class Reactor; // 前置声明,解决循环依赖

class Connection
{
public:
    Connection() : _events(0), _R(nullptr) {}
    
    uint32_t Events() { return _events; }
    void SetEvents(uint32_t events) { _events = events; }
    void SetAddress(const InetAddr &addr) { _clientaddr = addr; }

    virtual int Sockfd() = 0;
    virtual void Recver() = 0;
    virtual void Sender() = 0;
    virtual void Excepter() = 0;

    virtual ~Connection() = default;

protected:
    InetAddr _clientaddr;   // 对端套接字地址信息
    uint32_t _events;       // 当前连接关心的epoll事件集合
    Reactor* _R;            // 所属Reactor实例的弱引用回指指针
};

审计批注

  1. 原笔记中将_R指针定义在public访问区,违反封装原则。修正为protected访问权限,仅允许派生类访问,外部无法直接修改回指指针,保证对象生命周期安全。
  2. 原笔记基类析构函数未定义为虚函数,若通过基类指针释放派生类对象会发生内存泄漏。生产环境必须声明为虚析构函数,保证派生类析构函数被正确调用。
  3. 回指指针使用裸指针而非智能指针,核心目的是避免shared_ptr循环引用:Reactor通过shared_ptr管理Connection,若Connection再用shared_ptr指向Reactor,会导致引用计数永远无法归零。裸指针仅作弱引用、不参与所有权管理,是该场景下的标准解决方案。

1.1.2 IOHandler派生类框架

IOHandler是普通业务连接的实现类,继承Connection基类,负责具体的读写IO操作,每个连接持有独立的读写缓冲区。

cpp 复制代码
class IOHandler : public Connection
{
public:
    IOHandler(int sockfd) : _sockfd(sockfd) {}

    int Sockfd() override { return _sockfd; }
    void Recver() override;
    void Sender() override;
    void Excepter() override;

private:
    int _sockfd;
    std::string _inbuffer;  // 接收缓冲区
    std::string _outbuffer; // 发送缓冲区
};

1.2 Poller核心接口实现

Poller类是对epoll系统调用的直接封装,屏蔽内核接口细节,向上提供统一的事件注册与等待接口。

1.2.1 AddEvents事件注册

将指定套接字的指定事件注册到epoll内核事件表中,完成用户态事件到内核态的透传。

cpp 复制代码
void AddEvents(int sockfd, uint32_t events)
{
    struct epoll_event ev;
    ev.events = events;
    ev.data.fd = sockfd;
    
    int n = epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
    if (n < 0)
    {
        LOG(LogLevel::FATAL) << "epoll_ctl error";
    }
}

审计批注

此处使用ev.data.fd携带文件描述符,依赖上层Reactor的_connections哈希表完成fd到Connection对象的映射。若追求更高派发效率,可使用ev.data.ptr直接携带Connection对象指针,省去哈希表查找开销,但需严格保证指针生命周期安全。

1.2.2 WaitEvents事件等待

阻塞等待epoll事件就绪,返回就绪事件的数量。

cpp 复制代码
int WaitEvents(struct epoll_event revs[], int num, int timeout)
{
    int n = epoll_wait(_epfd, revs, num, timeout);
    if (n < 0)
    {
        LOG(LogLevel::FATAL) << "epoll_wait error!";
    }
    else if (n == 0)
    {
        LOG(LogLevel::INFO) << "epoll_wait timeout!";
    }
    return n;
}

审计批注

原笔记中将超时返回的日志级别错误写为FATAL,属于严重级别错误。epoll_wait返回0是超时正常现象,并非系统错误,必须修正为INFO级别。若使用FATAL级别,在非阻塞轮询模式下会产生大量致命级错误日志,严重干扰问题排查。

1.3 事件派发器核心框架

Reactor的Dispatcher是事件循环的核心,负责从epoll获取就绪事件,分发给对应连接的处理函数。

1.3.1 基础派发逻辑

cpp 复制代码
void LoopOnce(int timeout)
{
    int n = _epoller->WaitEvents(revs, gnum, timeout);
    for (int i = 0; i < n; i++)
    {
        int sockfd = revs[i].data.fd;
        uint32_t revents = revs[i].events;

        // 异常事件转换为读写事件
        if ((revents & EPOLLERR) || (revents & EPOLLHUP))
        {
            revents |= (EPOLLIN | EPOLLOUT);
        }

        // 分发给读事件处理
        if ((revents & EPOLLIN) && IsConnectionExists(sockfd))
        {
            _connections[sockfd]->Recver();
        }
        // 分发给写事件处理
        if ((revents & EPOLLOUT) && IsConnectionExists(sockfd))
        {
            _connections[sockfd]->Sender();
        }
    }
}

void Dispatcher()
{
    int timeout = -1;
    while (true)
    {
        LoopOnce(timeout);
    }
}

审计批注

  1. 原逻辑中将EPOLLERR和EPOLLHUP直接转换为读写事件的处理方式存在缺陷。EPOLLERR表示套接字发生错误(如对端重置连接),EPOLLHUP表示对端挂断,正确做法是优先触发Excepter()异常处理,而非直接走读写逻辑,否则可能引发无效读写导致程序崩溃。
  2. IsConnectionExists判断是必须的空指针防护:在处理事件的过程中,连接可能已被前序逻辑释放(如读处理中检测到对端关闭并移除连接),若不判断直接调用会触发空指针解引用。

1.3.2 timeout参数的两种模式

  • timeout = 0:非阻塞轮询模式,epoll_wait立即返回,无事件时返回0。该模式会占用100%CPU,仅用于特殊调试场景,生产环境禁止使用。
  • timeout = -1:永久阻塞模式,无事件时线程挂起,有事件就绪才被唤醒。该模式CPU利用率最高,是Reactor模型的标准配置。

1.4 AddEvents知识图谱


2 ~> 以Recver(Listener.hpp)为主线------完善所涉及的细节

本章聚焦监听连接的事件处理,核心解决ET模式下如何正确接收所有就绪连接、如何管理新连接生命周期、如何完成新连接与Reactor的托管绑定等核心问题。

2.1 Listener类基础定义

Listener是监听套接字的封装,继承Connection基类,负责处理新连接接入事件,本身只关心读事件(新连接就绪等价于读事件就绪)。

2.1.1 类定义

cpp 复制代码
class Listener : public Connection
{
public:
    Listener(uint16_t port, OnMessage_t on_message)
        : _port(port), _on_message(on_message), _listensock(std::make_unique<TcpSocket>())
    {
        _listensock->BuildSocketMethod_port(port);
        LOG(LogLevel::INFO) << "Listen sockfd create success: " << _listensock->Socketfd();
    }

    int Sockfd() override { return _listensock->Socketfd(); }
    void Recver() override;
    void Sender() override {}  // 监听套接字不关心写事件
    void Excepter() override {} // 监听套接字异常暂不处理

private:
    uint16_t _port;
    OnMessage_t _on_message;
    std::unique_ptr<Socket> _listensock;
};

审计批注

原笔记中未实现Listener的Sockfd()纯虚函数,会导致类无法实例化。监听套接字的文件描述符由_listensock管理,必须重写该接口返回对应fd,满足基类的多态约定。

2.2 ET模式下的Accept处理逻辑

ET(边缘触发)模式下,连接就绪事件只会通知一次,因此必须一次性循环读取所有已就绪的连接,否则剩余连接不会再触发事件,造成连接饥饿。

2.2.1 Accepter接口改造

Accept接口必须支持非阻塞错误码返回,用于区分"真错误"和"无更多连接"。

cpp 复制代码
int Accepter(InetAddr *clientaddr, int *errcode) override
{
    struct sockaddr_in peer;
    socklen_t len = sizeof(peer);
    int sockfd = accept(_sockfd, CONV(&peer), &len);
    
    if (sockfd < 0)
    {
        *errcode = errno;
        return -1;
    }
    
    *clientaddr = peer;
    *errcode = 0;
    return sockfd;
}

审计批注

原笔记中注释掉了*errcode = errno;赋值语句,会导致外层错误判断完全失效,属于严重逻辑漏洞。非阻塞accept返回-1时,必须将errno保存到输出参数中,外层才能区分错误类型。

2.2.2 循环Accept与错误码判断

cpp 复制代码
void Recver() override
{
    while (true)
    {
        int errcode = 0;
        InetAddr clientaddr;
        int sockfd = _listensock->Accepter(&clientaddr, &errcode);
        
        if (sockfd >= 0)
        {
            // 成功获取新连接,完成封装与托管
            LOG(LogLevel::INFO) << "get a new sockfd success: " << sockfd;
            
            SetNonBlock(sockfd);
            std::shared_ptr<Connection> conn = std::make_shared<IOHandler>(sockfd, _on_message);
            conn->SetEvents(EPOLLIN | EPOLLET);
            conn->SetAddress(clientaddr);
            _R->AddConnection(conn);
        }
        else
        {
            if (errcode == EAGAIN || errcode == EWOULDBLOCK)
            {
                // 所有连接已处理完毕,退出循环
                LOG(LogLevel::INFO) << "accepter Finish!";
                break;
            }
            if (errcode == EINTR)
            {
                // 被信号中断,重试accept
                LOG(LogLevel::INFO) << "accepter interrupt!";
                continue;
            }
            // 真正的系统错误
            LOG(LogLevel::ERROR) << "accepter error, errno: " << errcode;
            break;
        }
    }
}

2.2.3 核心原则

ET模式下,包括监听套接字在内的所有文件描述符,必须设置为非阻塞模式。原因在于:循环读取的最后一次调用必然会因为无数据而返回,若为阻塞模式,最后一次调用会导致线程永久挂起,整个Reactor停滞。

2.3 非阻塞套接字实现

通过fcntl系统调用修改文件描述符的状态标志,设置非阻塞模式。

cpp 复制代码
void SetNonBlock(int fd)
{
    int flags = fcntl(fd, F_GETFL);
    if (flags < 0)
    {
        LOG(LogLevel::ERROR) << "fcntl error, set " << fd << " non block failed";
        return;
    }
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

审计批注

必须采用"先获取原有标志,再或上新标志"的写法,严禁直接fcntl(fd, F_SETFL, O_NONBLOCK),否则会覆盖文件描述符原有的其他状态位,引发不可预知的错误。

套接字创建时的统一设置

在TcpSocket创建套接字时就统一设置非阻塞,同时配置地址端口复用选项:

cpp 复制代码
void CreateSocketOrDie() override
{
    _sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (_sockfd < 0)
    {
        LOG(LogLevel::FATAL) << "create socket error";
        exit(SOCKET_ERR);
    }
    
    SetNonBlock(_sockfd); // 创建即设置非阻塞
    
    int opt = 1;
    setsockopt(_sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
    
    LOG(LogLevel::INFO) << "create socket success";
}

选项说明

  • SO_REUSEADDR:允许绑定处于TIME_WAIT状态的端口,服务器重启时可立即绑定端口。
  • SO_REUSEPORT:允许多个套接字绑定同一个端口,内核自动负载均衡,用于多Reactor场景。

2.4 新连接的托管与回指机制

新连接创建后,必须完成三个核心动作:设置事件、加入Reactor连接管理、回指所属Reactor。

2.4.1 AddConnection完整实现

cpp 复制代码
void AddConnection(std::shared_ptr<Connection> &conn)
{
    int sockfd = conn->Sockfd();
    uint32_t events = conn->Events();
    
    // 1. 事件写透到内核epoll
    _epoller->AddEvents(sockfd, events);
    // 2. 连接对象托管到连接表
    _connections[sockfd] = conn;
    // 3. 连接回指当前Reactor实例
    conn->_R = this;
    
    LOG(LogLevel::INFO) << "insert " << conn->Sockfd() << " into reactor!";
}

2.4.2 设计原理

连接与Reactor的双向引用设计,与内核中struct filestruct socket的互指设计思想一致:

  • Reactor通过_connections哈希表管理所有连接,可以根据fd快速找到对应连接对象。
  • 连接通过_R指针找到所属Reactor,可以调用Reactor的能力(如修改事件、移除连接等)。

2.5 工程细节:头文件包含顺序

由于Connection中仅做了Reactor的前置声明,在Listener的实现中调用_R->AddConnection时,编译器必须已经看到Reactor的完整定义,否则会报"非法使用不完整类型"错误。

正确的包含顺序为:

cpp 复制代码
#include "Connection.hpp"
#include "Reactor.hpp"   // 必须在Listener之前包含Reactor完整定义
#include "Listener.hpp"

2.6 Recver(Listener.hpp)知识图谱


3 ~> 以Recver(IOHandler.hpp)为主线------完善所涉及的细节

本章聚焦普通业务连接的读写处理,是Reactor模型的核心业务承载层,涵盖非阻塞读取、协议解析、按需发送、异常处理等完整IO生命周期。

3.1 IOHandler类完整定义

IOHandler是业务连接的核心实现,负责数据的读取、发送、缓冲区管理,并通过回调机制对接上层协议与业务。

cpp 复制代码
static const int gbuffersize = 1024;
using OnMessage_t = std::function<std::string(std::string &, int *)>;

class IOHandler : public Connection
{
public:
    IOHandler(int sockfd, OnMessage_t on_message)
        : _sockfd(sockfd), _on_message(on_message) {}

    int Sockfd() override { return _sockfd; }
    void Recver() override;
    void Sender() override;
    void Excepter() override;

private:
    int _sockfd;
    OnMessage_t _on_message; // 消息处理回调
    std::string _inbuffer;   // 接收缓冲区(字节队列)
    std::string _outbuffer;  // 发送缓冲区(字节队列)
};

3.2 Recver非阻塞循环读取

ET模式下读事件就绪后,必须循环读取直到底层无数据,保证本次事件触发的所有数据都被读取完毕。

3.2.1 完整读取逻辑

cpp 复制代码
void Recver() override
{
    LOG(LogLevel::INFO) << "IOHandler event ready, sockfd is: " << _sockfd;
    
    char buffer[gbuffersize];
    while (true)
    {
        ssize_t n = recv(_sockfd, buffer, gbuffersize - 1, 0);
        
        if (n > 0)
        {
            buffer[n] = '\0';
            _inbuffer += buffer;
        }
        else if (n == 0)
        {
            // 对端正常关闭连接
            LOG(LogLevel::INFO) << "client quit, address is: " 
                                << _clientaddr.StringAddress() 
                                << " sockfd:" << _sockfd;
            Excepter();
            return;
        }
        else
        {
            if (errno == EAGAIN || errno == EWOULDBLOCK)
            {
                // 本轮数据全部读取完毕
                break;
            }
            if (errno == EINTR)
            {
                // 被信号中断,重试读取
                continue;
            }
            // 读取发生真正错误
            LOG(LogLevel::ERROR) << "recv error, address is: " 
                                 << _clientaddr.StringAddress()
                                 << " sockfd: " << _sockfd;
            Excepter();
            return;
        }
    }

    // 读取完成后,尝试处理完整报文
    int code = 0;
    std::string result = _on_message(_inbuffer, &code);
    if (code == 0)
    {
        _outbuffer += result;
    }
    else
    {
        Excepter();
        return;
    }

    // 尝试直接发送响应
    if (!_outbuffer.empty())
    {
        Sender();
    }
}

审计批注

  1. 对端关闭连接(n==0)属于正常生命周期结束,并非异常事件。原逻辑统一调用Excepter()处理并不严谨,生产环境应单独实现连接关闭逻辑,完成资源清理、连接移除等工作,避免与真正的错误混淆。
  2. buffer[n] = '\0'配合_inbuffer += buffer的写法仅适用于文本协议,若传输二进制数据,string+=运算符会因遇到'\0'截断数据。二进制场景应使用_inbuffer.append(buffer, n)明确指定追加长度。
  3. 读取完成后立即调用Sender()尝试发送是最优实践:此时内核发送缓冲区大概率为空,写事件处于就绪状态,直接发送可避免额外的epoll事件触发,降低延迟。

3.3 协议层与业务层的解耦接入

通过回调函数机制,将网络IO层与协议层、业务层完全解耦,各层可独立开发与替换。

3.3.1 分层架构

  1. 网络层:Reactor + IOHandler,只负责数据的收发与事件管理,不关心数据内容。
  2. 协议层:Protocol,负责报文的封包、解包、序列化、反序列化,判断报文完整性。
  3. 业务层:Calculator,负责具体的业务逻辑处理,只关心结构化的请求与响应。

3.3.2 回调透传链路

  • 主函数中定义协议与业务对象,将业务处理回调注入协议层
  • 协议层的处理回调通过Listener构造函数透传
  • Listener创建IOHandler时,将回调注入每个业务连接
  • IOHandler读取到数据后,通过回调调用协议层处理

3.3.3 协议层核心接口

cpp 复制代码
// 返回值:响应报文序列化结果
// 参数:inbuffer-输入字节流,code-输出型错误码(0成功,-1失败)
std::string HandlerRequest(std::string &streamstr, int *code)
{
    std::string resp_package;
    while (true)
    {
        std::string jsonstring;
        int n = UnPackage(streamstr, &jsonstring);
        if (n == 0)
        {
            // 无完整报文,退出
            *code = 0;
            return resp_package;
        }
        else if (n == -1)
        {
            // 报文解析错误
            *code = -1;
            return resp_package;
        }
        
        // 反序列化 -> 业务处理 -> 序列化响应
        Request req;
        req.Deserialize(jsonstring);
        Response resp = _callback(req);
        std::string resp_str = resp.Serialize();
        resp_package += Package(resp_str);
    }
}

审计批注

原笔记中解析失败直接调用exit(1)终止整个进程,属于严重设计缺陷。单个客户端的非法报文不应导致整个服务崩溃,正确做法是返回错误码,由上层关闭对应连接,保证服务可用性。

3.4 Sender写事件的正确处理逻辑

写事件的处理与读事件有本质区别:读事件是常设的,而写事件必须按需注册,否则会导致epoll频繁触发空转,CPU占用100%。

3.4.1 核心原则

  • 读事件EPOLLIN:常设到epoll中,因为数据何时到来是不可预知的。
  • 写事件EPOLLOUT:按需注册。套接字创建之初,内核发送缓冲区为空,写事件默认就绪,若常设EPOLLOUT会导致epoll不断返回就绪,造成忙等。仅当发送缓冲区满、无法继续发送时,才注册EPOLLOUT事件,待缓冲区可写时触发一次,写完后立即移除。

3.4.2 完整发送逻辑

cpp 复制代码
void Sender() override
{
    while (true)
    {
        ssize_t n = send(_sockfd, _outbuffer.c_str(), _outbuffer.size(), 0);
        
        if (n >= 0)
        {
            // 移除已发送的数据
            _outbuffer.erase(0, n);
            if (_outbuffer.empty())
            {
                // 全部发送完毕
                break;
            }
        }
        else
        {
            if (errno == EAGAIN || errno == EWOULDBLOCK)
            {
                // 发送缓冲区已满,本轮无法继续发送
                break;
            }
            if (errno == EINTR)
            {
                // 被信号中断,重试发送
                continue;
            }
            // 发送发生真正错误
            LOG(LogLevel::ERROR) << "send error, address is: " 
                                 << _clientaddr.StringAddress()
                                 << " sockfd:" << _sockfd;
            Excepter();
            return;
        }
    }

    // 根据发送结果调整事件注册
    if (_outbuffer.empty())
    {
        // 数据发完,关闭写事件监听
        _R->EnableReadWrite(_sockfd, true, false);
    }
    else
    {
        // 数据未发完,开启写事件监听,等待缓冲区可写
        _R->EnableReadWrite(_sockfd, true, true);
    }
}

3.4.3 EnableReadWrite事件修改接口

该接口由Reactor提供,用于动态修改连接关心的事件集合:

cpp 复制代码
void EnableReadWrite(int sockfd, bool isread, bool iswrite)
{
    uint32_t events = 0;
    if (isread) events |= EPOLLIN;
    if (iswrite) events |= EPOLLOUT;
    events |= EPOLLET; // 保持ET模式
    
    struct epoll_event ev;
    ev.events = events;
    ev.data.fd = sockfd;
    epoll_ctl(_epfd, EPOLL_CTL_MOD, sockfd, &ev);
    
    // 同步更新Connection对象的事件记录
    _connections[sockfd]->SetEvents(events);
}

3.4.4 最后一个坑:多路转接select、poll、epoll当中如何正确理解发送问题!!!

3.5 Excepter统一异常处理

所有读写错误、对端关闭等异常场景,最终都会进入异常处理函数,完成连接资源清理工作。

cpp 复制代码
void Excepter() override
{
    LOG(LogLevel::ERROR) << "Excepter, address is: " 
                         << _clientaddr.StringAddress()
                         << " sockfd:" << _sockfd;
    
    // 1. 从epoll中移除事件
    _R->RemoveConnection(_sockfd);
    // 2. 关闭套接字
    close(_sockfd);
    // 3. 连接对象会因引用计数归零自动释放
}

审计批注

移除连接时必须先从epoll事件表中删除,再关闭文件描述符。虽然关闭fd会自动从epoll中移除,但显式调用EPOLL_CTL_DEL是更严谨的做法,避免时序问题引发的事件误触发。

3.6 Recver(IOHandler.hpp)知识图谱


4 ~> Version1版本代码

Reactor-V1版本代码知识图谱


5 ~> 本文总结

核心考点深度总结

  1. ET模式的铁则 ET模式是Reactor高性能的核心,但有两条不可突破的约束:所有文件描述符必须设置为非阻塞;所有IO操作必须循环执行直到返回EAGAIN。违反任意一条都会导致程序挂起或事件丢失。LT模式无此约束,但事件通知效率更低,适合快速开发的场景。
  2. 事件注册的黄金法则 读事件常设,写事件按需注册。这是epoll编程中最容易出错的点,很多初学者会将EPOLLOUT常设,导致CPU跑满。核心逻辑:发送数据时先尝试直接发送,发不完再注册写事件,写完立刻取消写事件监听。
  3. 系统调用错误码三分支判断 所有非阻塞IO系统调用(accept、recv、send)返回-1时,绝不能直接判定为错误,必须按优先级判断:
    1. EAGAIN/EWOULDBLOCK:正常结束,本轮IO完成
    2. EINTR:被信号中断,重试即可
    3. 其他错误码:真正的IO错误,走异常处理 该判断逻辑是所有非阻塞IO的标准范式,必须烂熟于心。
  4. 分层解耦的回调机制 Reactor网络层通过回调函数对接协议层与业务层,实现了"网络IO"与"业务逻辑"的完全解耦。替换业务逻辑无需修改网络代码,只需更换回调函数,这是工业级网络库的标准设计思想。
  5. 智能指针的循环引用陷阱 双向引用场景下严禁双向使用shared_ptr,必然导致循环引用内存泄漏。解决方案有两种:一方使用裸指针作弱引用(本文方案);一方使用weak_ptr。前者实现简单,后者更安全但有额外开销。
  6. 连接生命周期的空指针防护 事件派发时必须先判断连接是否存在。因为在处理读事件的过程中,可能已经触发异常并移除了连接,若继续处理写事件会访问空指针。这是多逻辑分支下极易忽略的边界问题。
  7. 异常事件的正确处理 EPOLLERR、EPOLLHUP属于套接字异常事件,应优先触发异常清理逻辑,而非简单转换为读写事件。对端正常关闭(recv返回0)属于正常生命周期,应与错误异常区分处理。
  8. 工程化细节坑点
    1. 头文件包含顺序与前置声明的配合使用,解决循环依赖问题
    2. 日志级别的正确使用,禁止将正常现象标记为致命错误
    3. 字符串操作的二进制兼容性问题,根据协议类型选择正确的追加方式
    4. 套接字创建时统一设置非阻塞与端口复用选项,避免遗漏

结尾

uu们,本文的内容到这里就全部结束了,艾莉丝在这里再次感谢您的阅读!

|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ### 艾莉丝努力练剑 C/C++ & Linux 底层探索者 | 一个正在努力练剑的技术博主 *** ** * ** *** 👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。 ❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。 ⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。 💬 【评论】 分享你的经验或疑问,评论区一起交流避坑! 不要忘记给博主"一键四连"哦! "今日练剑达成!" "技术之路难免有困惑,但同行的人会让前进更有方向。" |

结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!

往期回顾

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

🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡 ૮₍ ˶ ˊ ᴥ ˋ˶₎ა