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 的程序",而是实现三个核心诉求:
-
fd 上发生了什么事件(读、写、错误、关闭),能被清晰、直观地表达出来,不用再对着 epoll 的事件标志猜含义;
-
模块分工明确:谁负责监听事件、谁负责分发事件、谁负责处理事件,不混在一起;
-
可扩展性强:后续想加定时器、多线程、主从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; // 任意事件发生时的通用回调
为什么要拆这么细?我们一起想几个实际开发中会遇到的场景,你就懂了:
-
出错了(EPOLLERR),但不一定是正常关闭------比如连接超时、端口被占用,这时需要执行错误处理逻辑(比如日志打印、资源清理),但不能执行正常的关闭回调;
-
对端关闭写端(EPOLLRDHUP),但还有数据可读------这时需要先执行读回调,把剩余数据读完,再执行关闭回调,而不是直接关闭;
-
想在"任何事件发生时"刷新连接活跃时间------比如用于心跳检测,不管是读、写还是错误事件,只要发生了,就需要执行一段通用逻辑,这就是 _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 的映射关系(相当于"员工档案")
我们逐一拆解它们的作用:
-
_epfd:epoll_create(或推荐使用的 epoll_create1)返回的文件描述符,是 epoll 实例的唯一标识,所有 epoll 操作(epoll_ctl、epoll_wait)都需要通过它来执行。它代表了一个内核资源,必须妥善管理、及时释放;
-
_evs:epoll_wait 函数的输出参数,用于存储内核返回的"就绪事件列表"。我们可以把它理解为一个临时缓冲,每次调用 epoll_wait 后,就从这里获取发生的事件;
-
_channels:fd 到 Channel 的映射表,这是 Poller 最关键的成员之一。因为 epoll 返回的是"就绪的 fd",但上层模块(比如 EventLoop)只认识 Channel,所以 Poller 需要通过这个映射表,根据 fd 找到对应的 Channel,再把事件交给 Channel 处理。
3.2 为什么一定要保存 fd → Channel 的映射?
这是很多初学者会忽略的问题,我们用一句话说清楚核心逻辑:epoll 只认识 fd,上层只认识 Channel。
举个流程例子:
-
我们把 Channel 交给 Poller,Poller 会获取 Channel 的 _fd 和 _events,通过 epoll_ctl 注册到 epoll 中;
-
当 fd 上发生事件时,epoll_wait 返回的是"这个 fd 以及对应的事件标志";
-
Poller 拿到 fd 后,需要通过 _channels 映射表,找到对应的 Channel,然后把事件标志设置到 Channel 的 _revents 中;
-
最后,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 事件循环------让事件真正流动起来,敬请期待~