第 4 课:回调类型定义(callbacks.h)

对应源文件:

  • trantor/net/callbacks.h — 所有网络回调 typedef
  • trantor/net/TcpConnection.h — 回调的注册接口与存储位置(辅助理解)

一、为什么要先读这个文件?

callbacks.h 只有 46 行,却是后续所有网络模块的词汇表

从第 5 课开始,你会在 EventLoop、TcpServer、TcpClient 里频繁看到这些类型名。如果不先建立印象,每次碰到 RecvMessageCallback 都要回头查,打断阅读节奏。

先把这张表背熟,后面读代码会顺畅得多。


二、完整回调类型表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// callbacks.h 全文(去掉注释)
using TimerCallback         = std::function<void()>;

using TcpConnectionPtr      = std::shared_ptr<TcpConnection>;

using RecvMessageCallback   = std::function<void(const TcpConnectionPtr &,
                                                  MsgBuffer *)>;
using ConnectionErrorCallback = std::function<void()>;
using ConnectionCallback    = std::function<void(const TcpConnectionPtr &)>;
using CloseCallback         = std::function<void(const TcpConnectionPtr &)>;
using WriteCompleteCallback = std::function<void(const TcpConnectionPtr &)>;
using HighWaterMarkCallback = std::function<void(const TcpConnectionPtr &,
                                                  const size_t)>;
using SSLErrorCallback      = std::function<void(SSLError)>;
using SockOptCallback       = std::function<void(int)>;

三、逐一解析

3.1 TcpConnectionPtr — 连接的生命线

1
using TcpConnectionPtr = std::shared_ptr<TcpConnection>;

这不是回调,是基础类型,但几乎所有回调都把它作为第一个参数。

shared_ptr 的意义:

  • TcpConnection 代表一个网络连接,它的生命周期不归用户管,也不归 TcpServer 管
  • 任何持有 TcpConnectionPtr 的地方都延长了连接的寿命
  • 回调执行时通过传入 const TcpConnectionPtr & 保证回调期间连接一定存活
1
2
TcpServer ──shared_ptr──► TcpConnection
用户回调   ──shared_ptr──► TcpConnection  ← 回调执行时引用计数 >= 2,不会析构

⚠️ 如果用户把 TcpConnectionPtr 存起来(例如放进全局 map),连接关闭后该指针依然有效,但 connected() 返回 false。要注意及时清理,否则内存泄漏。


3.2 RecvMessageCallback — 收到数据

1
2
using RecvMessageCallback = std::function<void(const TcpConnectionPtr &, MsgBuffer *)>;
//                                              连接对象                  接收缓冲区

触发时机:socket 上有数据可读,数据已从内核读入 MsgBuffer

参数说明

  • conn:当前连接,可调用 conn->send(...) 回复
  • buf:指向连接的接收缓冲区(MsgBuffer *,非 const,可修改)

关键约定

  • 回调里必须调用 buf->retrieve(n) 消费已处理的数据
  • 若不消费,下次数据到来时旧数据仍在 buf 里(粘包处理就靠这个)
  • MsgBuffer * 而非 MsgBuffer & 是为了允许回调传 nullptr(虽然实际不会)

典型写法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
conn->setRecvMsgCallback([](const TcpConnectionPtr &conn, MsgBuffer *buf) {
    // 解析包头,检查是否收到完整包
    while (buf->readableBytes() >= sizeof(PacketHeader)) {
        uint32_t len = buf->peekInt32();
        if (buf->readableBytes() < len) break;  // 包不完整,等下次

        buf->retrieve(4);                        // 消费包长度字段
        auto body = buf->read(len - 4);          // 消费包体
        handlePacket(conn, body);
    }
});

3.3 ConnectionCallback — 连接建立/断开

1
using ConnectionCallback = std::function<void(const TcpConnectionPtr &)>;

触发时机连接建立连接断开都触发这个回调!靠 conn->connected() 区分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
conn->setConnectionCallback([](const TcpConnectionPtr &conn) {
    if (conn->connected()) {
        // 新连接建立
        LOG_INFO << "新连接: " << conn->peerAddr().toIpPort();
        // 初始化该连接的上下文数据
        conn->setContext(std::make_shared<PlayerSession>());
    } else {
        // 连接断开
        LOG_INFO << "连接断开: " << conn->peerAddr().toIpPort();
        // 清理玩家会话
        auto session = conn->getContext<PlayerSession>();
        cleanupPlayer(session);
    }
});

为什么建立和断开用同一个回调?

这是 muduo 风格的设计——连接是一个对象,它有两个状态转变:进入"已连接"和离开"已连接"。同一个回调处理同一个对象的两个生命周期事件,逻辑集中,不用分散管理。


3.4 CloseCallback — 内部关闭通知

1
using CloseCallback = std::function<void(const TcpConnectionPtr &)>;

用途:这是 trantor 内部使用的回调,用于 TcpServer 在连接关闭时从连接池里移除该连接。

用户代码通常不需要设置 CloseCallback,应使用 ConnectionCallback!conn->connected() 分支处理断连事件。

1
2
3
4
连接断开
    ├─ CloseCallback ──────────► TcpServer(从连接 map 里删除条目)[内部]
    └─ ConnectionCallback ─────► 用户代码(清理玩家数据等)[用户]

3.5 WriteCompleteCallback — 数据发送完毕

1
using WriteCompleteCallback = std::function<void(const TcpConnectionPtr &)>;

触发时机:发送缓冲区全部写入内核(不是对端收到)。

典型用途:发送大文件时的流量控制。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 场景:分批发送大数据,避免一次性塞满发送缓冲区
void sendNextChunk(const TcpConnectionPtr &conn) {
    auto chunk = getNextChunk();
    if (chunk.empty()) return;  // 发完了
    conn->send(chunk);
    // 等这次发完,再发下一块(背压控制)
}

conn->setWriteCompleteCallback([](const TcpConnectionPtr &conn) {
    sendNextChunk(conn);  // 发完一块,立刻发下一块
});

HighWaterMarkCallback 配合使用(见下)。


3.6 HighWaterMarkCallback — 发送缓冲区高水位警告

1
2
3
using HighWaterMarkCallback = std::function<void(const TcpConnectionPtr &,
                                                  const size_t)>;
//                                                              当前缓冲区大小

触发时机:发送缓冲区积压数据超过设定的水位线(单位:字节)。

典型用途:反压(Backpressure)控制——当对端消费太慢,发送方应暂停生产。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 注册高水位回调,水位 = 64KB
conn->setHighWaterMarkCallback(
    [](const TcpConnectionPtr &conn, size_t markLen) {
        LOG_WARN << "发送缓冲区积压 " << markLen << " 字节,暂停生产";
        conn->stopReading();  // 停止从客户端读数据(不让它继续发)
    },
    64 * 1024
);

// 等缓冲区清空后恢复
conn->setWriteCompleteCallback([](const TcpConnectionPtr &conn) {
    conn->startReading();    // 恢复读
});

高水位 + WriteComplete 配对使用是 trantor 处理背压的标准模式:

1
2
发送缓冲 > 64KB → HighWaterMark → 停止读新数据
发送缓冲 = 0   → WriteComplete → 恢复读新数据

3.7 TimerCallback — 定时器回调

1
using TimerCallback = std::function<void()>;

最简单的回调,无参数无返回值。用于 EventLoop::runAfter()runEvery() 等定时接口:

1
2
3
4
5
6
7
8
9
// 每 30 秒检查一次空闲连接
loop->runEvery(30.0, []() {
    checkIdleConnections();
});

// 5 秒后执行一次
loop->runAfter(5.0, []() {
    broadcastServerTime();
});

3.8 ConnectionErrorCallback — 连接失败

1
using ConnectionErrorCallback = std::function<void()>;

用于 TcpClient主动发起连接失败时触发(区别于已建立的连接被断开)。

1
2
3
tcpClient->setConnectionErrorCallback([]() {
    LOG_ERROR << "连接服务器失败,准备重连";
});

3.9 SSLErrorCallback — TLS 握手错误

1
2
3
4
5
6
enum class SSLError {
    kSSLHandshakeError,       // 握手失败
    kSSLInvalidCertificate,   // 证书无效
    kSSLProtocolError         // 协议错误
};
using SSLErrorCallback = std::function<void(SSLError)>;

TLS 握手失败时触发,可以根据错误类型做不同处理(如记录审计日志)。


3.10 SockOptCallback — socket 选项设置

1
2
using SockOptCallback = std::function<void(int)>;
//                                         fd(文件描述符)

在连接建立后、加入 EventLoop 前,回调里可以直接对 fd 调用 setsockopt,设置系统默认接口不提供的底层选项(如 TCP_KEEPIDLESO_BINDTODEVICE 等)。


四、回调在 TcpConnection 中的存储与注册

所有回调都存在 TcpConnectionprotected 成员里(TcpConnection.h 第 370-377 行):

1
2
3
4
5
6
7
protected:
    RecvMessageCallback    recvMsgCallback_;
    ConnectionCallback     connectionCallback_;
    CloseCallback          closeCallback_;
    WriteCompleteCallback  writeCompleteCallback_;
    HighWaterMarkCallback  highWaterMarkCallback_;
    SSLErrorCallback       sslErrorCallback_;

注册方式(TcpConnection.h 第 322-361 行)——每个回调都有拷贝和移动两个版本

1
2
void setRecvMsgCallback(const RecvMessageCallback &cb) { recvMsgCallback_ = cb; }
void setRecvMsgCallback(RecvMessageCallback &&cb)      { recvMsgCallback_ = std::move(cb); }

移动版本避免 lambda 捕获列表的不必要拷贝,在高频创建连接的场景下有意义。


五、setContext — 连接上的用户数据

TcpConnection 还提供了一个"后备箱"机制(TcpConnection.h 第 186-226 行):

1
2
3
4
5
void setContext(const std::shared_ptr<void> &context);  // 存任意类型
template <typename T>
std::shared_ptr<T> getContext() const;                   // 取出时指定类型
bool hasContext() const;
void clearContext();

std::shared_ptr<void> 是类型擦除技巧,可以存任意类型的 shared_ptr

1
2
3
4
5
6
// 连接建立时,绑定游戏会话对象
conn->setContext(std::make_shared<GameSession>(playerId));

// 收到消息时,取出会话
auto session = conn->getContext<GameSession>();
session->handleMessage(buf);

本质shared_ptr<void> 虽然类型是 void,但析构时会调用原始类型的析构函数(因为 shared_ptr 的 deleter 在构造时绑定了正确的析构函数)。这是 C++ 的类型擦除 + 自动析构技巧。


六、完整回调触发时序图

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
客户端建立连接
[Acceptor::handleRead] → accept() 返回新 fd
[TcpConnectionImpl 创建]
[connectEstablished()] → ConnectionCallback(conn)  ← ① 连接建立
      ▼ (数据到达)
[handleRead()] → readFd() 填充 MsgBuffer
RecvMessageCallback(conn, buf)                     ← ② 收到数据(可多次)
      ▼ (用户调用 conn->send())
[handleWrite()] → 数据写入内核
      ▼ (写缓冲区全部发出)
WriteCompleteCallback(conn)                        ← ③ 发送完成(可多次)
      ▼ (连接关闭)
[handleClose()] → ConnectionCallback(conn)         ← ④ 连接断开(connected()==false)
                → CloseCallback(conn)              ← ⑤ TcpServer 清理连接 [内部]

七、与游戏服务器实践对接

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 游戏服务器的标准 TcpServer 配置模板
server.setConnectionCallback([&](const TcpConnectionPtr &conn) {
    if (conn->connected()) {
        // 新玩家连接:创建会话,加入在线列表
        auto session = std::make_shared<PlayerSession>();
        conn->setContext(session);
        playerManager.onConnect(conn);
        LOG_INFO << "新连接: " << conn->peerAddr().toIpPort();
    } else {
        // 玩家断线:清理会话,通知其他人
        auto session = conn->getContext<PlayerSession>();
        if (session) playerManager.onDisconnect(session);
        LOG_INFO << "断开连接: " << conn->peerAddr().toIpPort();
    }
});

server.setRecvMessageCallback([](const TcpConnectionPtr &conn, MsgBuffer *buf) {
    auto session = conn->getContext<PlayerSession>();
    if (!session) { conn->forceClose(); return; }
    // 包头检查 → 解包 → 分发给消息处理器
    protocolHandler.dispatch(conn, session, buf);
});

八、回调速查表

回调类型签名触发时机谁设置
ConnectionCallbackvoid(conn)连接建立 & 断开用户
RecvMessageCallbackvoid(conn, buf*)收到数据用户
WriteCompleteCallbackvoid(conn)发送缓冲区清空用户
HighWaterMarkCallbackvoid(conn, size)发送缓冲区溢出用户
CloseCallbackvoid(conn)连接关闭TcpServer 内部
TimerCallbackvoid()定时器到期用户
ConnectionErrorCallbackvoid()主动连接失败用户(TcpClient)
SSLErrorCallbackvoid(SSLError)TLS 握手失败用户
SockOptCallbackvoid(fd)连接建立后立刻用户(高级)

核心收获

  • 先把所有回调类型背熟,后续读 TcpConnection/TcpServer 源码时不会被类型签名拦住
  • ConnectionCallback 连接建立和断开共用一个回调,通过 conn->connected() 区分状态
  • RecvMessageCallback 直接暴露 MsgBuffer*,调用方负责消费数据(retrieve / retrieveAll
  • WriteCompleteCallback 触发时发送缓冲区已清空,是流控的关键回调(可在此继续投递数据)
  • CloseCallback 是框架内部回调(TcpServer 用来从 connSet_ 移除连接),用户一般不直接使用

九、思考题

  1. RecvMessageCallback 的第二个参数是 MsgBuffer *(裸指针),为什么不用 MsgBuffer &std::shared_ptr<MsgBuffer>
  2. ConnectionCallback 合并了连接建立和断开两个事件,有什么缺点?能想到什么改进方案?
  3. setContext 使用 std::shared_ptr<void> 而不是 void *,多了什么保障?
  4. 如果用户在 WriteCompleteCallback 里又调用了 conn->send() 发数据,会不会立刻触发新的 WriteCompleteCallback(递归)?

十、思考题参考答案

1. RecvMessageCallback 的第二个参数是 MsgBuffer *(裸指针),为什么不用 MsgBuffer &std::shared_ptr<MsgBuffer>

不用 MsgBuffer &(引用)的原因:

表面上看,引用和指针在这里效果一样——都不涉及所有权转移,都能让回调直接操作原始缓冲区。但这是 muduo/trantor 的设计惯例,有实际考量:

  1. 与 C 风格回调兼容std::function<void(const TcpConnectionPtr &, MsgBuffer *)> 可以绑定到 C 风格函数指针(参数是指针的函数更容易和 C API 互操作),而引用参数的函数无法直接转成 C 函数指针。
  2. 语义明确性:指针参数在 C++ 社区有一个非正式约定——“指针参数暗示可为 nullptr,引用参数暗示不可为空”。虽然 trantor 实际不会传 nullptr,但使用指针留出了未来扩展的余地(比如某些特殊场景下传空指针表示连接重置)。
  3. 历史延续:muduo 库最初就是这样设计的,trantor 作为精神继承者保持了一致的 API 风格,降低 muduo 用户的迁移成本。

不用 std::shared_ptr<MsgBuffer> 的原因:

  1. 所有权语义不对MsgBufferTcpConnectionImpl成员变量 readBuffer_,生命周期跟随连接对象。回调执行期间连接一定存活(因为第一个参数 const TcpConnectionPtr & 持有 shared_ptr),所以 readBuffer_ 也一定存活。不存在 MsgBuffer 被提前析构的风险,没有理由用 shared_ptr 管理它。
  2. 性能开销shared_ptr 有引用计数的原子操作开销。RecvMessageCallback极高频调用的回调(每收到一次数据就触发),额外的原子操作完全没有必要。
  3. 避免用户误用:如果传 shared_ptr<MsgBuffer>,用户可能把它存起来在其他线程使用——但 MsgBuffer 不是线程安全的,只能在 IO 线程中操作。裸指针暗示"临时借用,不要持有",减少误用风险。

2. ConnectionCallback 合并了连接建立和断开两个事件,有什么缺点?

缺点:

  1. 回调内部必须分支判断:每次 ConnectionCallback 被调用,用户都需要写 if (conn->connected()) { ... } else { ... } 来区分是建立还是断开。这增加了样板代码,也容易忘记处理其中一个分支。

  2. 单一回调无法绑定不同的处理逻辑:如果连接建立的初始化逻辑很复杂(如鉴权、协议协商),而断开的清理逻辑也很复杂(如保存玩家数据、通知好友),把两者塞在一个 lambda 里会导致函数体过长,违反单一职责原则。

  3. 无法独立注册/替换:如果框架的某个中间层需要在连接断开时做额外处理(如统计在线人数),它必须包装整个 ConnectionCallback,不能只 hook 断开事件。

  4. 语义不够精确:从类型签名 std::function<void(const TcpConnectionPtr &)> 看不出这个回调会在何时被调用,必须查阅文档或源码才知道它身兼两职。

可能的改进方案:

1
2
3
4
5
6
7
// 方案1:分成两个独立回调
using ConnectCallback    = std::function<void(const TcpConnectionPtr &)>;  // 仅连接建立
using DisconnectCallback = std::function<void(const TcpConnectionPtr &)>;  // 仅连接断开

// 方案2:增加枚举参数
enum class ConnEvent { Connected, Disconnected };
using ConnectionCallback = std::function<void(const TcpConnectionPtr &, ConnEvent)>;

但 muduo/trantor 选择合并的理由也很充分:

  • 连接是一个"有生命周期的对象",建立和断开是同一对象的两个状态转变事件,用同一个回调处理在概念上是自洽的
  • 大多数场景下,建立和断开的处理逻辑确实成对出现(建立时分配资源,断开时释放资源),放在一起反而有利于不遗漏配对操作
  • 减少 API 表面积——少一个回调 setter 就少一些学习成本
  • 这是一个经典的工程权衡:API 简洁性 vs 灵活性,muduo/trantor 选择了简洁

3. setContext 使用 std::shared_ptr<void> 而不是 void *,多了什么保障?

核心保障:自动析构和类型安全的内存管理。

先看 void * 方案的问题:

1
2
3
4
// 假设用 void* 实现
void* context_;
void setContext(void* ctx) { context_ = ctx; }
void* getContext() { return context_; }

void * 方案的致命缺陷:

  1. 不知道何时释放:连接断开时,框架应该释放 context 指向的内存——但 void * 不知道原始类型是什么,无法调用正确的析构函数,也不知道该用 delete 还是 free 还是什么都不做(可能指向栈上对象)
  2. 需要用户手动清理:用户必须在 ConnectionCallback 的断开分支里手动 delete context,忘记了就内存泄漏
  3. 无法区分有效性:无法判断指针指向的对象是否还有效(悬空指针风险)

shared_ptr<void> 方案的保障:

1
std::shared_ptr<void> contextPtr_;
  1. 自动析构shared_ptr<void> 虽然类型是 void,但析构时会调用原始类型的析构函数。这是 shared_ptr 的一个精妙设计——deleter 在构造时就绑定了正确的析构函数:
1
2
3
4
5
6
7
8
// 构造时:
auto session = std::make_shared<GameSession>(playerId);
conn->setContext(session);
// shared_ptr<GameSession> 隐式转换为 shared_ptr<void>
// deleter 仍然记录着 "析构时调用 GameSession::~GameSession()"

// 连接断开,contextPtr_ 析构时:
// 引用计数归零 → 调用 GameSession 的析构函数 → 内存正确释放
  1. 引用计数保护生命周期:如果用户在其他地方也持有同一个 shared_ptr<GameSession>,连接断开时 context 不会被析构——只有最后一个持有者释放时才析构。避免了悬空指针。

  2. 无需用户手动清理:连接对象析构时,contextPtr_shared_ptr<void>)自动析构,用户无需在断开回调里手动 delete。即使用户忘记调用 clearContext(),也不会内存泄漏。

  3. 类型擦除:框架层面不需要知道用户存了什么类型,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 行)的核心逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
void TcpConnectionImpl::writeCallback() {
    // 遍历 writeBufferList_,逐个发送节点
    while (!writeBufferList_.empty()) {
        auto &nodePtr = writeBufferList_.front();
        if (nodePtr->remainingBytes() == 0) {
            writeBufferList_.pop_front();  // 发完一个节点,移除
        } else {
            auto n = sendNodeInLoop(nodePtr);  // 继续发送
            if (nodePtr->remainingBytes() > 0 || n < 0) return;
        }
    }
    // 全部发完,禁用写事件
    ioChannelPtr_->disableWriting();
}

WriteCompleteCallback 是通过 TcpServer 设置在连接上的回调,当发送缓冲区清空时由框架调用。

关键点在于事件驱动模型的非递归性质:

当用户在 WriteCompleteCallback 里调用 conn->send(data)

  1. 如果在 IO 线程中(通常是这种情况)send() 内部调用 sendInLoop(),先尝试直接写入 socket:

    • 如果数据全部写入内核成功sendLen == length),数据直接发走,writeBufferList_ 仍然为空——但这里不会立即触发 WriteCompleteCallback,因为当前就在 writeCallback 的调用链上(或在回调返回后的事件处理流程中),不存在重新触发写事件的条件
    • 如果数据部分写入或未写入(socket 缓冲区满),剩余数据被追加到 writeBufferList_,注册写事件。下一轮 epoll 循环中 socket 变为可写时,才会再次调用 writeCallback(),等缓冲区清空后再触发 WriteCompleteCallback
  2. 整体流程是事件驱动的单线程模型:EventLoop 的 loop() 是一个 while 循环,每轮处理一批就绪的 IO 事件。WriteCompleteCallback 是在某轮事件处理中被调用的,在这轮处理完成之前不会开始下一轮 epoll_wait。所以即使 send() 重新注册了写事件,也要等下一轮事件循环才能处理——不会递归。

总结:trantor 的事件驱动模型天然避免了递归问题。在 WriteCompleteCallback 里调用 send() 是安全且常见的做法(大文件分块发送就是这个模式),不会产生递归调用栈。数据要么直接写入 socket(不触发新回调),要么进入发送缓冲队列等待下一轮事件循环处理。


学习日期:2025-03-09 | 上一课:第03课_日期时间与工具函数 | 下一课:第05课_EventLoop事件循环