第 4 课:回调类型定义(callbacks.h)
对应源文件:
trantor/net/callbacks.h— 所有网络回调 typedeftrantor/net/TcpConnection.h— 回调的注册接口与存储位置(辅助理解)
一、为什么要先读这个文件?
callbacks.h 只有 46 行,却是后续所有网络模块的词汇表。
从第 5 课开始,你会在 EventLoop、TcpServer、TcpClient 里频繁看到这些类型名。如果不先建立印象,每次碰到 RecvMessageCallback 都要回头查,打断阅读节奏。
先把这张表背熟,后面读代码会顺畅得多。
二、完整回调类型表
| |
三、逐一解析
3.1 TcpConnectionPtr — 连接的生命线
| |
这不是回调,是基础类型,但几乎所有回调都把它作为第一个参数。
shared_ptr 的意义:
TcpConnection代表一个网络连接,它的生命周期不归用户管,也不归 TcpServer 管- 任何持有
TcpConnectionPtr的地方都延长了连接的寿命 - 回调执行时通过传入
const TcpConnectionPtr &保证回调期间连接一定存活
| |
⚠️ 如果用户把
TcpConnectionPtr存起来(例如放进全局 map),连接关闭后该指针依然有效,但connected()返回 false。要注意及时清理,否则内存泄漏。
3.2 RecvMessageCallback — 收到数据
| |
触发时机:socket 上有数据可读,数据已从内核读入 MsgBuffer。
参数说明:
conn:当前连接,可调用conn->send(...)回复buf:指向连接的接收缓冲区(MsgBuffer *,非const,可修改)
关键约定:
- 回调里必须调用
buf->retrieve(n)消费已处理的数据 - 若不消费,下次数据到来时旧数据仍在 buf 里(粘包处理就靠这个)
MsgBuffer *而非MsgBuffer &是为了允许回调传nullptr(虽然实际不会)
典型写法:
| |
3.3 ConnectionCallback — 连接建立/断开
| |
触发时机:连接建立和连接断开都触发这个回调!靠 conn->connected() 区分:
| |
为什么建立和断开用同一个回调?
这是 muduo 风格的设计——连接是一个对象,它有两个状态转变:进入"已连接"和离开"已连接"。同一个回调处理同一个对象的两个生命周期事件,逻辑集中,不用分散管理。
3.4 CloseCallback — 内部关闭通知
| |
用途:这是 trantor 内部使用的回调,用于 TcpServer 在连接关闭时从连接池里移除该连接。
用户代码通常不需要设置 CloseCallback,应使用 ConnectionCallback 的 !conn->connected() 分支处理断连事件。
| |
3.5 WriteCompleteCallback — 数据发送完毕
| |
触发时机:发送缓冲区全部写入内核(不是对端收到)。
典型用途:发送大文件时的流量控制。
| |
与 HighWaterMarkCallback 配合使用(见下)。
3.6 HighWaterMarkCallback — 发送缓冲区高水位警告
| |
触发时机:发送缓冲区积压数据超过设定的水位线(单位:字节)。
典型用途:反压(Backpressure)控制——当对端消费太慢,发送方应暂停生产。
| |
高水位 + WriteComplete 配对使用是 trantor 处理背压的标准模式:
| |
3.7 TimerCallback — 定时器回调
| |
最简单的回调,无参数无返回值。用于 EventLoop::runAfter()、runEvery() 等定时接口:
| |
3.8 ConnectionErrorCallback — 连接失败
| |
用于 TcpClient:主动发起连接失败时触发(区别于已建立的连接被断开)。
| |
3.9 SSLErrorCallback — TLS 握手错误
| |
TLS 握手失败时触发,可以根据错误类型做不同处理(如记录审计日志)。
3.10 SockOptCallback — socket 选项设置
| |
在连接建立后、加入 EventLoop 前,回调里可以直接对 fd 调用 setsockopt,设置系统默认接口不提供的底层选项(如 TCP_KEEPIDLE、SO_BINDTODEVICE 等)。
四、回调在 TcpConnection 中的存储与注册
所有回调都存在 TcpConnection 的 protected 成员里(TcpConnection.h 第 370-377 行):
| |
注册方式(TcpConnection.h 第 322-361 行)——每个回调都有拷贝和移动两个版本:
| |
移动版本避免 lambda 捕获列表的不必要拷贝,在高频创建连接的场景下有意义。
五、setContext — 连接上的用户数据
TcpConnection 还提供了一个"后备箱"机制(TcpConnection.h 第 186-226 行):
| |
std::shared_ptr<void> 是类型擦除技巧,可以存任意类型的 shared_ptr:
| |
本质:shared_ptr<void> 虽然类型是 void,但析构时会调用原始类型的析构函数(因为 shared_ptr 的 deleter 在构造时绑定了正确的析构函数)。这是 C++ 的类型擦除 + 自动析构技巧。
六、完整回调触发时序图
| |
七、与游戏服务器实践对接
| |
八、回调速查表
| 回调类型 | 签名 | 触发时机 | 谁设置 |
|---|---|---|---|
ConnectionCallback | void(conn) | 连接建立 & 断开 | 用户 |
RecvMessageCallback | void(conn, buf*) | 收到数据 | 用户 |
WriteCompleteCallback | void(conn) | 发送缓冲区清空 | 用户 |
HighWaterMarkCallback | void(conn, size) | 发送缓冲区溢出 | 用户 |
CloseCallback | void(conn) | 连接关闭 | TcpServer 内部 |
TimerCallback | void() | 定时器到期 | 用户 |
ConnectionErrorCallback | void() | 主动连接失败 | 用户(TcpClient) |
SSLErrorCallback | void(SSLError) | TLS 握手失败 | 用户 |
SockOptCallback | void(fd) | 连接建立后立刻 | 用户(高级) |
核心收获
- 先把所有回调类型背熟,后续读 TcpConnection/TcpServer 源码时不会被类型签名拦住
ConnectionCallback连接建立和断开共用一个回调,通过conn->connected()区分状态RecvMessageCallback直接暴露MsgBuffer*,调用方负责消费数据(retrieve/retrieveAll)WriteCompleteCallback触发时发送缓冲区已清空,是流控的关键回调(可在此继续投递数据)CloseCallback是框架内部回调(TcpServer 用来从 connSet_ 移除连接),用户一般不直接使用
九、思考题
RecvMessageCallback的第二个参数是MsgBuffer *(裸指针),为什么不用MsgBuffer &或std::shared_ptr<MsgBuffer>?ConnectionCallback合并了连接建立和断开两个事件,有什么缺点?能想到什么改进方案?setContext使用std::shared_ptr<void>而不是void *,多了什么保障?- 如果用户在
WriteCompleteCallback里又调用了conn->send()发数据,会不会立刻触发新的WriteCompleteCallback(递归)?
十、思考题参考答案
1. RecvMessageCallback 的第二个参数是 MsgBuffer *(裸指针),为什么不用 MsgBuffer & 或 std::shared_ptr<MsgBuffer>?
不用 MsgBuffer &(引用)的原因:
表面上看,引用和指针在这里效果一样——都不涉及所有权转移,都能让回调直接操作原始缓冲区。但这是 muduo/trantor 的设计惯例,有实际考量:
- 与 C 风格回调兼容:
std::function<void(const TcpConnectionPtr &, MsgBuffer *)>可以绑定到 C 风格函数指针(参数是指针的函数更容易和 C API 互操作),而引用参数的函数无法直接转成 C 函数指针。 - 语义明确性:指针参数在 C++ 社区有一个非正式约定——“指针参数暗示可为
nullptr,引用参数暗示不可为空”。虽然 trantor 实际不会传nullptr,但使用指针留出了未来扩展的余地(比如某些特殊场景下传空指针表示连接重置)。 - 历史延续:muduo 库最初就是这样设计的,trantor 作为精神继承者保持了一致的 API 风格,降低 muduo 用户的迁移成本。
不用 std::shared_ptr<MsgBuffer> 的原因:
- 所有权语义不对:
MsgBuffer是TcpConnectionImpl的成员变量readBuffer_,生命周期跟随连接对象。回调执行期间连接一定存活(因为第一个参数const TcpConnectionPtr &持有shared_ptr),所以readBuffer_也一定存活。不存在MsgBuffer被提前析构的风险,没有理由用shared_ptr管理它。 - 性能开销:
shared_ptr有引用计数的原子操作开销。RecvMessageCallback是极高频调用的回调(每收到一次数据就触发),额外的原子操作完全没有必要。 - 避免用户误用:如果传
shared_ptr<MsgBuffer>,用户可能把它存起来在其他线程使用——但MsgBuffer不是线程安全的,只能在 IO 线程中操作。裸指针暗示"临时借用,不要持有",减少误用风险。
2. ConnectionCallback 合并了连接建立和断开两个事件,有什么缺点?
缺点:
回调内部必须分支判断:每次
ConnectionCallback被调用,用户都需要写if (conn->connected()) { ... } else { ... }来区分是建立还是断开。这增加了样板代码,也容易忘记处理其中一个分支。单一回调无法绑定不同的处理逻辑:如果连接建立的初始化逻辑很复杂(如鉴权、协议协商),而断开的清理逻辑也很复杂(如保存玩家数据、通知好友),把两者塞在一个 lambda 里会导致函数体过长,违反单一职责原则。
无法独立注册/替换:如果框架的某个中间层需要在连接断开时做额外处理(如统计在线人数),它必须包装整个
ConnectionCallback,不能只 hook 断开事件。语义不够精确:从类型签名
std::function<void(const TcpConnectionPtr &)>看不出这个回调会在何时被调用,必须查阅文档或源码才知道它身兼两职。
可能的改进方案:
| |
但 muduo/trantor 选择合并的理由也很充分:
- 连接是一个"有生命周期的对象",建立和断开是同一对象的两个状态转变事件,用同一个回调处理在概念上是自洽的
- 大多数场景下,建立和断开的处理逻辑确实成对出现(建立时分配资源,断开时释放资源),放在一起反而有利于不遗漏配对操作
- 减少 API 表面积——少一个回调 setter 就少一些学习成本
- 这是一个经典的工程权衡:API 简洁性 vs 灵活性,muduo/trantor 选择了简洁
3. setContext 使用 std::shared_ptr<void> 而不是 void *,多了什么保障?
核心保障:自动析构和类型安全的内存管理。
先看 void * 方案的问题:
| |
void * 方案的致命缺陷:
- 不知道何时释放:连接断开时,框架应该释放 context 指向的内存——但
void *不知道原始类型是什么,无法调用正确的析构函数,也不知道该用delete还是free还是什么都不做(可能指向栈上对象) - 需要用户手动清理:用户必须在
ConnectionCallback的断开分支里手动deletecontext,忘记了就内存泄漏 - 无法区分有效性:无法判断指针指向的对象是否还有效(悬空指针风险)
shared_ptr<void> 方案的保障:
| |
- 自动析构:
shared_ptr<void>虽然类型是void,但析构时会调用原始类型的析构函数。这是shared_ptr的一个精妙设计——deleter 在构造时就绑定了正确的析构函数:
| |
引用计数保护生命周期:如果用户在其他地方也持有同一个
shared_ptr<GameSession>,连接断开时 context 不会被析构——只有最后一个持有者释放时才析构。避免了悬空指针。无需用户手动清理:连接对象析构时,
contextPtr_(shared_ptr<void>)自动析构,用户无需在断开回调里手动 delete。即使用户忘记调用clearContext(),也不会内存泄漏。类型擦除:框架层面不需要知道用户存了什么类型,
shared_ptr<void>可以存任意类型的shared_ptr(通过隐式转换),取出时用std::static_pointer_cast<T>还原——这是 C++ 中经典的类型擦除技巧。
总结:shared_ptr<void> 相比 void *,用很小的性能代价(引用计数的原子操作)换来了自动内存管理、异常安全、无悬空指针三重保障,在游戏服务器这种长时间运行的场景下尤为重要。
4. 如果用户在 WriteCompleteCallback 里又调用了 conn->send() 发数据,会不会立刻触发新的 WriteCompleteCallback(递归)?
不会立刻递归,但要分情况讨论。
先理解 WriteCompleteCallback 的触发机制。查看 TcpConnectionImpl::writeCallback()(TcpConnectionImpl.cc 第 204-257 行)的核心逻辑:
| |
而 WriteCompleteCallback 是通过 TcpServer 设置在连接上的回调,当发送缓冲区清空时由框架调用。
关键点在于事件驱动模型的非递归性质:
当用户在 WriteCompleteCallback 里调用 conn->send(data):
如果在 IO 线程中(通常是这种情况):
send()内部调用sendInLoop(),先尝试直接写入 socket:- 如果数据全部写入内核成功(
sendLen == length),数据直接发走,writeBufferList_仍然为空——但这里不会立即触发WriteCompleteCallback,因为当前就在writeCallback的调用链上(或在回调返回后的事件处理流程中),不存在重新触发写事件的条件 - 如果数据部分写入或未写入(socket 缓冲区满),剩余数据被追加到
writeBufferList_,注册写事件。下一轮 epoll 循环中 socket 变为可写时,才会再次调用writeCallback(),等缓冲区清空后再触发WriteCompleteCallback
- 如果数据全部写入内核成功(
整体流程是事件驱动的单线程模型:EventLoop 的
loop()是一个while循环,每轮处理一批就绪的 IO 事件。WriteCompleteCallback是在某轮事件处理中被调用的,在这轮处理完成之前不会开始下一轮epoll_wait。所以即使send()重新注册了写事件,也要等下一轮事件循环才能处理——不会递归。
总结:trantor 的事件驱动模型天然避免了递归问题。在 WriteCompleteCallback 里调用 send() 是安全且常见的做法(大文件分块发送就是这个模式),不会产生递归调用栈。数据要么直接写入 socket(不触发新回调),要么进入发送缓冲队列等待下一轮事件循环处理。
学习日期:2025-03-09 | 上一课:第03课_日期时间与工具函数 | 下一课:第05课_EventLoop事件循环