一起实现一个Reactor网络库:从Channel到Poller

Reactor网络库:从Channel到Poller

在学习网络编程、epoll 和 Reactor 模型的过程中,很多同学都会陷入一个共同的困境:epoll 的三个核心 API(epoll_create、epoll_ctl、epoll_wait)都会用,简单的 demo 也能跑通,但一旦想自己搭建一套可扩展的网络框架,就瞬间陷入迷茫------模块该怎么拆?职责该怎么分?哪些代码该放在一起,哪些该彻底分离?

这一篇,我们一起学习、一起拆解,抛开复杂的冗余功能,只聚焦 Reactor 模型中最基础、也最核心的两个模块,把它们的边界、职责和实现逻辑拆透彻、讲明白:

· Channel:事件语义的封装者,负责把"fd 和事件"翻译成业务能看懂的语言;

· Poller:epoll 的统一管理者,负责和内核打交道,屏蔽 epoll 的底层细节。

我们不追求功能完备,只追求结构清晰、职责边界明确------这才是后续扩展功能(定时器、多线程、主从Reactor)不推翻重来的关键。

1. 动手前先统一目标:Reactor 的核心不是 epoll

很多人学习 Reactor 时都会陷入一个误区:把 Reactor 和 epoll 画上等号,觉得"用 epoll 就是 Reactor"。其实不然,Reactor 的核心不是某个 API,而是"事件在代码中的流动逻辑"------谁监听事件、谁分发事件、谁处理事件,每一步都要有明确的分工。

所以,我们本次的目标不是"写出一个能用 epoll 的程序",而是实现三个核心诉求:

  1. fd 上发生了什么事件(读、写、错误、关闭),能被清晰、直观地表达出来,不用再对着 epoll 的事件标志猜含义;

  2. 模块分工明确:谁负责监听事件、谁负责分发事件、谁负责处理事件,不混在一起;

  3. 可扩展性强:后续想加定时器、多线程、主从Reactor等功能时,不用修改现有核心代码,直接基于现有模块扩展即可。

明确了这个目标,我们再开始拆解模块,就不会偏离方向。

2. Channel:先把"事件"这件事说明白

我们先思考一个问题:epoll 给我们返回的是什么?是"某个 fd 上发生了某些事件"这个事实,比如"fd=5 触发了 EPOLLIN 事件"。但对于业务层来说,我们不关心"EPOLLIN 是什么",我们关心的是"这个 fd 现在能不能读?能不能写?是不是出错了?要不要关闭连接?"

Channel 的作用,就是填补"epoll 底层事件"和"业务层需求"之间的鸿沟,把"fd + 事件 + 回调"打包在一起,让事件变得"可理解、可处理"。

2.1 Channel 想解决什么问题?

举个简单的例子:epoll_wait 返回一个 EPOLLRDHUP 事件,内核只告诉我们"对端关闭了写端",但业务层需要知道"我还能不能读这个 fd 里剩下的数据?读完之后要不要关闭自己的写端?"

如果没有 Channel,我们就得在业务代码里反复判断事件标志,写一堆 if/else,代码会变得杂乱无章,而且每次新增事件类型,都要修改所有相关逻辑。

而 Channel 就是把这些"底层事件标志"封装成"业务可理解的语义",同时绑定对应的处理逻辑(回调函数),让事件发生时,自动执行对应的处理代码------不用业务层再关心底层细节。

2.2 Channel 的核心成员:只描述"状态",不操作 epoll

Channel 的核心成员很简单,只有三个,但每个成员的职责都非常明确,没有多余的逻辑:

cpp 复制代码
int _fd;                  // 关联的文件描述符,唯一标识一个I/O对象
uint32_t _events;         // 我关心的事件(比如EPOLLIN、EPOLLOUT),交给Poller监听
uint32_t _revents;        // 内核实际告诉我发生的事件,由Poller设置

我们用最通俗的话理解这三个成员:

· _events:我"想监听什么"------比如我关心 fd 的读事件,就把 _events 设为 EPOLLIN,然后交给 Poller,让 Poller 帮我注册到 epoll 中;

· _revents:内核"实际发生了什么"------比如 epoll 检测到这个 fd 有数据可读,就会把 _revents 设为 EPOLLIN,再通知 Channel 去处理;

· 关键原则:Channel 不直接和 epoll 打交道,它只负责"描述事件状态",所有和 epoll 的交互,都交给 Poller 去做。这样就能保证 Channel 的"轻量",不掺杂任何底层 epoll 操作逻辑。

2.3 为什么要拆这么多回调?别偷懒,拆开会更省心

在实现 Channel 时,很多人会偷懒:只写一个统一的回调函数(比如 OnEvent()),所有事件(读、写、错误、关闭)都在这个函数里用 if/else 判断处理。这种写法看似简单,后期会带来巨大的麻烦。

我们推荐拆分出 5 个独立的回调函数,每个回调对应一种特定的事件场景:

cpp 复制代码
std::function<void()> _read_cb;    // 读事件回调
std::function<void()> _write_cb;   // 写事件回调
std::function<void()> _error_cb;   // 错误事件回调
std::function<void()> _close_cb;   // 关闭事件回调
std::function<void()> _event_cb;   // 任意事件发生时的通用回调

为什么要拆这么细?我们一起想几个实际开发中会遇到的场景,你就懂了:

  1. 出错了(EPOLLERR),但不一定是正常关闭------比如连接超时、端口被占用,这时需要执行错误处理逻辑(比如日志打印、资源清理),但不能执行正常的关闭回调;

  2. 对端关闭写端(EPOLLRDHUP),但还有数据可读------这时需要先执行读回调,把剩余数据读完,再执行关闭回调,而不是直接关闭;

  3. 想在"任何事件发生时"刷新连接活跃时间------比如用于心跳检测,不管是读、写还是错误事件,只要发生了,就需要执行一段通用逻辑,这就是 _event_cb 的作用。

如果一开始只写一个 OnEvent(),后期就会不断在里面加 if/else,逻辑会越来越乱,耦合度越来越高,修改一个场景的逻辑,很可能影响到其他场景。而拆分回调后,每个回调只负责自己的场景,逻辑清晰,修改起来也更安全,这也是职责分离思想的实际应用。

2.4 HandleEvent:事件分发顺序,比你想象的更关键

Channel 最核心的函数,就是 HandleEvent()------它负责接收 Poller 设置的 _revents,然后按照一定的顺序,分发到对应的回调函数中。这个分发顺序不是随意的,而是经过实际开发验证的"最佳实践",直接影响程序的稳定性。

我们按步骤拆解 HandleEvent() 的逻辑,每一步都说明"为什么要这么做"。

2.4.1 错误/挂断事件优先处理

cpp 复制代码
void HandleEvent() {
    uint32_t rev = _revents;
    // 错误事件优先
    if (rev & EPOLLERR) {
        if (_error_cb) _error_cb();
        return; // 出错后,后续读写无意义,直接返回
    }
    // 挂断事件(无数据可读时)
    if ((rev & EPOLLHUP) && !(rev & EPOLLIN)) {
        if (_close_cb) _close_cb();
        return;
    }
    // 后续读、写事件处理...
}

我们一起记住这个重要经验:一旦 fd 出错(EPOLLERR)或挂断(EPOLLHUP),继续对它进行读写操作往往是没有意义的,甚至会导致程序崩溃。因此,错误和挂断事件必须优先处理,处理完直接返回,避免执行后续的读写逻辑。

2.4.2 读事件(包含 RDHUP)紧随其后

cpp 复制代码
// 读事件:包含正常读(EPOLLIN)、紧急数据读(EPOLLPRI)、对端关闭写端(EPOLLRDHUP)
if (rev & (EPOLLIN | EPOLLPRI | EPOLLRDHUP)) {
    if (_read_cb) _read_cb();
}

这里有一个细节:EPOLLRDHUP 表示"对端关闭了写端",这是 TCP 半关闭的常见场景(比如客户端发送完数据后,调用 shutdown 关闭写端)。这种情况下,我们可能还能从 fd 中读取剩余的数据,因此把 EPOLLRDHUP 和读事件放在一起处理,逻辑最自然、最合理。

2.4.3 写事件放在最后

cpp 复制代码
// 写事件:放在读事件之后
if (rev & EPOLLOUT) {
    if (_write_cb) _write_cb();
}

为什么写事件要放在读事件之后?因为如果 fd 处于异常状态(比如对端已经关闭),先执行读事件可以及时检测到异常并处理,避免在异常状态下继续写数据------比如对端已经关闭连接,我们还在写数据,就会触发错误,导致程序异常。

补充一个最佳实践:不要一开始就注册 EPOLLOUT 事件,否则会导致 epoll_wait 立即返回(socket 通常默认可写),频繁触发写回调浪费资源;仅在需要写数据且 write 返回 EAGAIN 时,才注册 EPOLLOUT 事件,写完后立即注销。

2.4.4 任意事件回调:通用钩子的妙用

cpp 复制代码
// 任意事件发生时,都会执行(错误/挂断事件已提前返回,不会走到这里)
if (rev != 0 && _event_cb) {
    _event_cb();
}

_event_cb 是一个通用的钩子函数,它不对应某个特定的事件,而是在"任何事件(读、写)发生且处理完成后"执行。它的用途非常灵活,比如:

· 刷新连接活跃时间:用于心跳检测,只要有事件发生,就说明连接是正常的;

· 统计连接状态:比如统计某个 fd 今天的读写次数、数据量;

· 调试日志:打印事件发生的时间、事件类型,方便后期排查问题。

3. Poller:epoll 的统一管理者,不掺任何业务逻辑

如果说 Channel 负责"表达事件语义",把底层事件翻译成业务能看懂的语言,那么 Poller 的职责就非常单一且明确------只和 epoll 内核打交道,负责事件的监听、收集,不处理任何业务逻辑,不调用任何回调函数。

Poller 的核心价值,就是屏蔽 epoll 的底层细节,让 Channel 不用关心 epoll 的 API 怎么用,让上层模块(比如 EventLoop)不用关心事件是怎么从内核获取的。

3.1 Poller 内部需要哪些东西?三个核心成员就够了

Poller 的内部成员很简洁,只有三个,刚好对应 epoll 的核心操作需求,我们可以类比"管理办公室"的逻辑来理解它们的作用:

cpp 复制代码
int _epfd;                                  // epoll 实例的文件描述符,对应一个内核资源(相当于"管理办公室")
epoll_event _evs[MAX_EPOLLEVENTS];          // epoll_wait 的临时结果缓冲(相当于"事件通知板")
std::unordered_map<int, Channel*> _channels;// fd → Channel 的映射关系(相当于"员工档案")

我们逐一拆解它们的作用:

  1. _epfd:epoll_create(或推荐使用的 epoll_create1)返回的文件描述符,是 epoll 实例的唯一标识,所有 epoll 操作(epoll_ctl、epoll_wait)都需要通过它来执行。它代表了一个内核资源,必须妥善管理、及时释放;

  2. _evs:epoll_wait 函数的输出参数,用于存储内核返回的"就绪事件列表"。我们可以把它理解为一个临时缓冲,每次调用 epoll_wait 后,就从这里获取发生的事件;

  3. _channels:fd 到 Channel 的映射表,这是 Poller 最关键的成员之一。因为 epoll 返回的是"就绪的 fd",但上层模块(比如 EventLoop)只认识 Channel,所以 Poller 需要通过这个映射表,根据 fd 找到对应的 Channel,再把事件交给 Channel 处理。

3.2 为什么一定要保存 fd → Channel 的映射?

这是很多初学者会忽略的问题,我们用一句话说清楚核心逻辑:epoll 只认识 fd,上层只认识 Channel

举个流程例子:

  1. 我们把 Channel 交给 Poller,Poller 会获取 Channel 的 _fd 和 _events,通过 epoll_ctl 注册到 epoll 中;

  2. 当 fd 上发生事件时,epoll_wait 返回的是"这个 fd 以及对应的事件标志";

  3. Poller 拿到 fd 后,需要通过 _channels 映射表,找到对应的 Channel,然后把事件标志设置到 Channel 的 _revents 中;

  4. 最后,Poller 把"就绪的 Channel"交给上层模块,由上层模块调用 Channel 的 HandleEvent() 分发事件、执行回调。

可以说,_channels 映射表是 Poller 和 Channel 之间的"桥梁",没有它,Poller 就无法将 epoll 返回的 fd 事件,对应到具体的 Channel 上------这也是 Poller 存在的核心价值之一。

3.3 UpdateEvent:统一处理 ADD / MOD,让 Channel 更轻量

当 Channel 想修改自己"关心的事件"(比如从只关心读事件,变成同时关心读和写事件)时,它不会直接调用 epoll_ctl,而是告诉 Poller:"我现在关心的事件变了,你帮我处理一下"。

Poller 提供 UpdateEvent 方法,统一处理 epoll 的 EPOLL_CTL_ADD(新增监听)和 EPOLL_CTL_MOD(修改监听)操作:

cpp 复制代码
void UpdateEvent(Channel* channel) {
    int fd = channel->fd();
    uint32_t events = channel->events();
    epoll_event ev;
    ev.data.fd = fd;
    ev.events = events;
    
    if (!HasChannel(channel)) {
        // 不存在该Channel,执行ADD操作
        epoll_ctl(_epfd, EPOLL_CTL_ADD, fd, &ev);
        _channels[fd] = channel;
    } else {
        // 已存在该Channel,执行MOD操作
        epoll_ctl(_epfd, EPOLL_CTL_MOD, fd, &ev);
    }
}

这里我们约定一个重要规则:Channel 只负责告诉 Poller"我关心什么事件",Poller 决定是执行 ADD 还是 MOD 操作

这样做的好处是,Channel 不需要知道自己是否已经被注册到 epoll 中,也不需要关心 epoll_ctl 的具体用法,只需要专注于"描述事件",从而保持自身的轻量和简洁,不掺杂任何 epoll 状态管理逻辑。同时也能避免频繁的 ADD/DEL 操作,提升性能。

3.4 RemoveEvent:删除一定要干净,避免资源泄漏

当一个连接关闭(比如客户端断开连接),我们需要将对应的 fd 从 epoll 中移除,同时清理相关资源。Poller 提供 RemoveEvent 方法,负责干净地删除监听:

cpp 复制代码
void RemoveEvent(Channel* channel) {
    int fd = channel->fd();
    // 1. 从映射表中删除Channel
    _channels.erase(fd);
    // 2. 从epoll中删除fd的监听
    epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
}

这里有一个非常容易忽略的细节:epoll_ctl 执行 EPOLL_CTL_DEL 操作时,第四个参数(epoll_event)必须传 nullptr*。这是 epoll 的规范用法,虽然有些内核版本允许传非 nullptr 的值,但为了兼容性和规范性,务必传 nullptr。

另外,删除顺序也很重要:先从 _channels 映射表中删除 Channel,再从 epoll 中删除 fd 监听------避免删除 epoll 监听后,又有事件触发,导致 Poller 找不到对应的 Channel 而崩溃。

3.5 Poll:只收集事件,不处理事件

Poller 最核心的方法之一是 Poll,它的作用是调用 epoll_wait,收集内核返回的就绪事件,然后找到对应的 Channel,设置 _revents,最后将就绪的 Channel 列表返回给上层模块(比如 EventLoop)。

注意:Poll 方法只做"收集事件"这件事,不处理任何事件,不调用任何回调函数------这是 Poller 的职责边界,绝对不能突破。

cpp 复制代码
std::vector<Channel*> Poll(int timeout = -1) {
    std::vector<Channel*> active_channels;
    // 调用epoll_wait,等待就绪事件
    int nfds = epoll_wait(_epfd, _evs, MAX_EPOLLEVENTS, timeout);
    if (nfds < 0) {
        perror("epoll_wait error");
        return active_channels;
    }
    // 遍历就绪事件,找到对应的Channel
    for (int i = 0; i < nfds; ++i) {
        int fd = _evs[i].data.fd;
        auto it = _channels.find(fd);
        if (it != _channels.end()) {
            Channel* channel = it->second;
            // 将内核返回的事件,设置到Channel的_revents中
            channel->set_revents(_evs[i].events);
            // 将就绪的Channel加入列表
            active_channels.push_back(channel);
        }
    }
    return active_channels;
}

我们可以看到,Poll 方法的逻辑非常克制:调用 epoll_wait → 遍历就绪事件 → 查找 Channel → 设置 _revents → 返回就绪 Channel 列表。全程不涉及任何业务逻辑,不调用任何回调------处理事件的逻辑,交给上层模块和 Channel 自己完成。

4. 一个容易被忽略,但至关重要的点:epoll fd 的释放

很多初学者写完代码后,会发现程序运行一段时间后,出现"too many open files"错误------这就是资源泄漏导致的,其中很常见的一种情况,就是忘记关闭 epoll fd(_epfd)。

我们必须记住一个原则:凡是通过 epoll_create(或 epoll_create1)、socket、open 等函数返回的 fd,都是内核资源,使用完成后必须通过 close 函数释放

对于 Poller 来说,_epfd 是它的核心成员,对应一个 epoll 内核实例。如果不释放 _epfd,这个内核资源会一直被占用,直到程序退出------如果 Poller 被频繁创建和销毁,就会导致内核资源耗尽。

解决方案很简单:让 Poller 成为一个 RAII 对象(资源获取即初始化),在 Poller 的析构函数中,关闭 _epfd:

cpp 复制代码
~Poller() {
    close(_epfd); // 析构时自动释放epoll fd
}

这样一来,只要 Poller 对象被销毁,_epfd 就会被自动关闭,内核资源也会被释放,从根本上避免资源泄漏问题。同理,Channel 关联的 fd,也需要在合适的时机(比如关闭连接时)关闭。

5. 小结:我们现在站在什么位置?

到这里,我们一起完成了 Reactor 模型最核心的"骨架"搭建------Channel 和 Poller 两个模块,职责边界清晰,逻辑简洁可扩展:

· Channel:事件语义的封装者,负责"描述 fd 和事件",绑定回调函数,处理事件分发,不关心 epoll 底层操作;

· Poller:epoll 的统一管理者,负责和内核打交道,监听事件、收集事件,充当 fd 和 Channel 之间的桥梁,不处理任何业务逻辑。

这个骨架的最大价值,就是"职责分离、低耦合"------每个模块只做自己擅长的事情,不越界、不掺杂无关逻辑。这意味着,我们后续扩展功能时,完全不用推翻现有代码。

学习 Reactor 模型,最重要的不是记住 API 的用法,而是理解"事件流动"的逻辑和"模块拆分"的思想。Channel 和 Poller 作为 Reactor 的基础,只要把它们的职责边界拆清楚、写扎实,后续的扩展就会水到渠成,代码也会越写越顺,而不是越写越乱。

下一篇,我们将基于这个骨架,加入 EventLoop 模块,实现完整的 Reactor 事件循环------让事件真正流动起来,敬请期待~

相关推荐
2501_944525544 小时前
Flutter for OpenHarmony 个人理财管理App实战 - 预算详情页面
android·开发语言·前端·javascript·flutter·ecmascript
打小就很皮...5 小时前
《在 React/Vue 项目中引入 Supademo 实现交互式新手指引》
前端·supademo·新手指引
C澒5 小时前
系统初始化成功率下降排查实践
前端·安全·运维开发
云边云科技_云网融合5 小时前
AIoT智能物联网平台:架构解析与边缘应用新图景
大数据·网络·人工智能·安全
若风的雨5 小时前
NCCL 怎么解决rdma 网卡本地send的时候需要对端recv要准备好的问题,或者mpi 怎么解决的?
网络
C澒5 小时前
面单打印服务的监控检查事项
前端·后端·安全·运维开发·交通物流
pas1365 小时前
39-mini-vue 实现解析 text 功能
前端·javascript·vue.js
qq_532453535 小时前
使用 GaussianSplats3D 在 Vue 3 中构建交互式 3D 高斯点云查看器
前端·vue.js·3d
浩浩测试一下5 小时前
DDOS 应急响应Linux防火墙 Iptable 使用方式方法
linux·网络·安全·web安全·网络安全·系统安全·ddos
2501_915918415 小时前
HTTPS 代理失效,启用双向认证(mTLS)的 iOS 应用网络怎么抓包调试
android·网络·ios·小程序·https·uni-app·iphone