第 6 课:Channel — 事件通道
对应源文件:
trantor/net/Channel.h— 公共接口trantor/net/Channel.cc— 实现(仅 112 行)
一、Channel 是什么?
Channel 是 trantor Reactor 模式中的中间层,它把一个文件描述符(fd)的事件管理和回调分发封装在一起。
| |
三个关键概念:
events_:当前感兴趣的事件(告诉 Poller 我想监听什么)revents_:Poller 返回的实际发生的事件(poll/epoll 填写)- 回调函数:事件发生后调用哪个函数
Channel 不拥有 fd——fd 的生命周期由 Socket 对象管理,Channel 只是"贴在" fd 上的事件管理标签。
二、事件标志位
| |
为什么用 POLLIN/POLLOUT 而不是 EPOLLIN/EPOLLOUT?
poll.h 定义的 POLLIN/POLLOUT 是 POSIX 标准,数值上与 Linux 的 EPOLLIN/EPOLLOUT 完全相同。Windows 上通过宏重定义做了映射(Channel.cc 第 19-27 行):
| |
这样 Channel 的代码对上层保持统一,平台差异由宏处理。
POLLPRI:带外数据(Out-of-Band),TCP 紧急指针,极少用到,但 trantor 把它和 POLLIN 一起归入"可读"事件。
Linux 独有:POLLRDHUP(Channel.cc 第 91 行):
| |
POLLRDHUP 表示对端关闭了写方向(半关闭),Linux 2.6.17+ 支持。收到这个事件说明对端不会再发数据了,可以提前知道连接即将关闭。
三、events_ 的修改与 Poller 同步
每次修改 events_ 后都要调用 update() 通知 Poller:
| |
update() 的实现极简(Channel.cc 第 48-51 行):
| |
为什么每次都通知 Poller?
epoll 内核维护一张表,记录每个 fd 感兴趣的事件。events_ 是用户空间的"意图",只改 events_ 不调用 epoll_ctl 内核不会知道。update() 就是把用户的意图同步到内核。
四、handleEvent — 事件分发核心
当 Poller 返回活跃 Channel 后,EventLoop 调用 channel->handleEvent():
| |
4.1 handleEventSafely — 按 revents_ 分发回调
| |
事件分发优先级图:
| |
为什么 POLLHUP 要排除 POLLIN?
POLLHUP 表示连接挂断,但 TCP 半关闭时可能同时有 POLLHUP | POLLIN(对端关闭写,但本端还有数据没读完)。这种情况应该先把数据读完(readCallback_),不能直接关闭。所以关闭回调只在"纯 HUP 无数据"时才触发。
五、tie() — 防止回调时对象已析构
这是 Channel 中最精妙的设计之一。
问题:考虑以下场景:
TcpConnection拥有一个Channel- EventLoop 拿到活跃 Channel,准备调用
handleEvent() - 此时另一个线程(或本轮其他回调)销毁了
TcpConnection handleEvent()执行时,回调里引用的数据已经无效!→ 野指针崩溃
解决方案:tie() 机制
| |
使用方式(在 TcpConnectionImpl 中):
| |
handleEvent 中:
| |
时序保障:
| |
六、eventCallback_ — 统一事件回调
大多数情况下 Channel 用分类回调(read/write/close/error)。
但有时需要一个"接管所有事件"的回调,比如 Windows 的 IOCP poller,事件类型语义不同,直接用统一回调更合适:
| |
设置了 eventCallback_ 后,其他四个回调全部失效——handleEventSafely 的第一行直接 return。
七、Channel 在 Poller 中的三态
Channel 在 Poller(EpollPoller)内部有三种状态,用 index_ 字段标记:
| |
状态转移:
| |
kDeleted 和彻底 remove() 的区别:
kDeleted:fd 还在 Poller 的channelMap_中(可以快速重新激活)remove():fd 从channelMap_删除,下次enableReading()时需重新ADD
这个优化减少了"暂时禁用再重新启用"时的 epoll_ctl 调用次数。
八、Channel 完整生命周期
以一个 TCP 连接为例:
| |
为什么要"用完就关闭写事件"(步骤 ⑥)?
EPOLLOUT 在 socket 发送缓冲区有空间时就会持续触发(水平触发 LT 模式)。如果一直开着写事件,没有数据要发的时候 epoll_wait 会立刻返回,进入空转(busy loop)。所以发送缓冲区清空后要立刻 disableWriting()。
九、Channel 与三个类的关系
| |
| |
十、五种回调对比
| 回调 | 触发条件 | 典型用途 |
|---|---|---|
readCallback_ | POLLIN/POLLPRI/POLLRDHUP | 读数据(MsgBuffer::readFd) |
writeCallback_ | POLLOUT | 发送缓冲区数据写入内核 |
closeCallback_ | POLLHUP && !POLLIN | 通知 TcpServer 关闭连接 |
errorCallback_ | POLLNVAL/POLLERR | 记录错误日志,关闭连接 |
eventCallback_ | 任何事件(优先) | Windows IOCP 或特殊用途 |
核心收获
- Channel 不拥有 fd,只是 fd 的事件注册与回调分发代理,生命周期由持有者(如 TcpConnection)管理
tie(shared_ptr)防止 Channel 在handleEvent()中途被析构:持有所有者的 weak_ptr,执行回调前 lock()- 三种状态 kNew/kAdded/kDeleted 驱动
epoll_ctl的 ADD/MOD/DEL 操作 enableReading/Writing()修改 events_ 后调用update()→loop_->updateChannel()→ Poller 同步disableAll()+remove()是安全移除 Channel 的标准两步流程(先停止监听,再从 Poller 注销)
十一、思考题
- Channel 的
fd_是const int,为什么不允许修改?(提示:Poller 用 fd 做 key) enableWriting()之后为什么一定要在发送完成后disableWriting()?不关掉会发生什么?(提示:epoll LT 模式)tie()使用weak_ptr<void>而不是weak_ptr<TcpConnection>,有什么好处?- 如果
handleEventSafely()里的readCallback_在执行过程中调用了channel->disableAll()和channel->remove(),此时 Channel 对象会立刻析构吗?为什么?
十二、思考题参考答案
1. Channel 的 fd_ 是 const int,为什么不允许修改?
根本原因:fd 是 Channel 在 Poller 中的唯一标识,修改会导致映射关系断裂。
看 Channel.h 第 302 行的声明:
| |
以及 EpollPoller 中 fd 的使用方式:
- Debug 模式的
channels_map 以 fd 为 key(EpollPoller.cc 第 147-151 行):
| |
epoll_ctl以 fd 为参数(EpollPoller.cc 第 210 行):
| |
- KQueue 的
channels_也以 fd 为 key(KQueue.cc 第 183 行):
| |
- PollPoller 的
pollfds_数组中存的是 fd(PollPoller.cc 第 101-102 行):
| |
如果允许修改 fd_,会导致以下灾难性问题:
- 映射不一致:Channel 已经用
fd=5注册到了 Poller 的channels_[5] = this。如果修改fd_为 7,此后channel->fd()返回 7,但 Poller 还认为这个 Channel 对应 fd=5。执行update()时传给epoll_ctl的是 fd=7,但内核中注册的是 fd=5——两边不匹配。 remove()找不到自己:remove通过channels_.erase(fd)删除映射,如果 fd 已变,就删不掉原来的映射,导致内存泄漏(map 中残留无效指针)。- fd 复用风险:如果旧 fd 被 close 后被系统重新分配给新连接,Poller 还保存着旧 Channel 的映射
channels_[旧fd],新连接的 Channel 注册时会冲突。
正确做法:如果需要关联到新的 fd,应该销毁旧 Channel 并创建新的。fd 和 Channel 是一对一绑定关系,整个生命周期不可变。这也是为什么 Channel 的构造函数直接将 fd 设为 const(Channel.cc 第 36-38 行):
| |
2. enableWriting() 之后为什么必须在发送完成后 disableWriting()?
不关掉写事件会导致 epoll busy loop(空转),CPU 100%。
trantor 使用的是 LT(水平触发) 模式——epoll_event.events 中没有设置 EPOLLET 标志(参见 EpollPoller.cc 第 207 行 event.events = channel->events(),只有 POLLIN | POLLOUT,无 EPOLLET)。
LT 模式的行为:只要 fd 的条件满足,每次 epoll_wait 都会返回该事件。
对于 POLLOUT(可写事件),“条件满足"意味着内核发送缓冲区有空闲空间。而 TCP 的发送缓冲区在大部分时间都是有空间的(默认 128KB~数 MB),只有在对端接收很慢、缓冲区堆满时才会"不可写”。
如果发送完数据后不 disableWriting():
| |
epoll_wait 的超时形同虚设——因为每次调用都会立即返回(有可写事件),主循环变成了不停地调用 epoll_wait → handleEvent → doRunInLoopFuncs → epoll_wait …
正确的写事件使用模式:
| |
这也是为什么 TcpConnection 的 handleWrite() 在缓冲区写空后一定会调用 channel_->disableWriting()。
3. tie() 使用 weak_ptr<void> 而不是 weak_ptr<TcpConnection> 的好处
核心好处:类型擦除,Channel 不依赖 TcpConnection 的定义。
看 Channel.h 第 269-273 行和第 312 行:
| |
如果改成 weak_ptr<TcpConnection>,会导致以下问题:
循环依赖:Channel.h 需要
#include "TcpConnection.h"(或至少前向声明 + 完整定义在 .cc 中)。而 TcpConnection 已经包含了 Channel(unique_ptr<Channel>),形成Channel.h ↔ TcpConnection.h的循环头文件依赖。虽然可以用前向声明缓解,但weak_ptr<T>的lock()需要完整类型定义。Channel 的通用性丧失:Channel 不仅被 TcpConnection 使用,还被以下对象使用:
EventLoop自己的wakeupChannelPtr_(绑定到 EventLoop,不是 TcpConnection)TimerQueue内部的 timerfd Channel(Linux)Acceptor的监听 Channel
这些持有者都不是 TcpConnection。如果
tie只接受TcpConnection,这些场景就无法使用tie机制。weak_ptr<void>的转换是安全的:C++ 标准保证shared_ptr<Derived>可以隐式转换为shared_ptr<void>,且转换后weak_ptr<void>::lock()返回的shared_ptr<void>仍然持有正确的引用计数和正确的删除器。也就是说,类型信息只在析构时需要——而析构器(deleter)在shared_ptr创建时就被捕获了,与void转换无关。符合最小知识原则:Channel 只关心"持有者是否还活着",不关心持有者的具体类型。
handleEvent里只做tie_.lock()检查是否为空:
| |
这是一种经典的类型擦除设计模式,让底层组件(Channel)不依赖上层组件(TcpConnection)的具体类型,实现了依赖反转。
4. readCallback_ 中调用 disableAll() + remove(),Channel 会立刻析构吗?
不会立刻析构。原因有两层保护。
第一层保护:tie() 的 guard
回顾 handleEvent() 的调用链(Channel.cc 第 53-69 行):
| |
guard 是一个局部 shared_ptr<void>,在 handleEvent() 整个函数作用域内持有 TcpConnection 的引用。即使 readCallback_ 内部导致 TcpConnection 的其他所有 shared_ptr 被释放,guard 仍然保持引用计数 >= 1,TcpConnection 不会析构。
TcpConnection 不析构 → 它持有的 unique_ptr<Channel> 不析构 → Channel 不析构。
第二层保护:disableAll() + remove() 只修改状态,不析构
看这两个操作做了什么:
| |
这两步操作都只是修改了 Channel 的状态和 Poller 的内部映射,不涉及 Channel 对象本身的 delete/析构。Channel 的生命周期完全由持有它的 unique_ptr(在 TcpConnection 中)管理。
完整时序分析:
| |
如果没有 tie() 机制呢? 如果 Channel 没有调用 tie()(tied_ == false),handleEventSafely() 直接执行,没有 guard 保护。此时如果 readCallback_ 内部触发了 TcpConnection 的析构(比如这是最后一个 shared_ptr),Channel 确实会被析构——在自己的成员函数还没返回时!这就是经典的"回调中自杀"问题,会导致未定义行为。所以 tie() 机制不是可选的——对于 TcpConnection 拥有的 Channel,它是必须的安全保障。
学习日期:2025-03-12 | 上一课:第05课_EventLoop事件循环 | 下一课:第07课_Poller多路复用