在上一章 数据包封装 (ENetPacket) 中,我们学会了如何创建包裹(数据包)并给它们贴上"必须送达"或"无所谓"的标签。
但是,如果所有的包裹都挤在一条窄窄的小路上排队,会发生什么?这就引出了网络编程中一个经典的问题,以及 ENet 的杀手级特性------多通道 (Channels)。
1. 为什么需要通道?
想象你在玩一款网络游戏,发生了以下两件事:
- 你打字发了一句聊天信息:"救命!"(这是一个可靠包,必须送达,不能乱序)。
- 你的角色正在疯狂跑路,每秒发送 20 次位置更新(这是不可靠包,丢了就丢了)。
如果没有通道(TCP 的做法)
所有的包裹都在一条管道里排队。假设你发的"救命!"这个包在网络上丢了:
- 接收端发现"救命!"没收到,于是它暂停接收所有后续数据。
- 它等待你重传"救命!"。
- 在这期间,你发送的那些最新的跑路位置数据(本来应该很流畅)全部被堵在了后面。
- 你的游戏画面瞬间卡住(Lag),直到"救命!"重传成功,所有积压的位置数据才突然涌入。
这就是著名的队头阻塞 (Head-of-Line Blocking)。
ENet 的做法(多通道)
ENet 允许你在同一个连接里开辟多条"车道"。
- 通道 0:专门用来发聊天、装备购买等重要信息。
- 通道 1:专门用来发玩家位置、朝向等实时信息。
即使通道 0 上的"救命!"丢包了正在等待重传,通道 1 上的位置更新依然畅通无阻。这就是多通道机制的核心价值。
2. 核心概念:通道 ID
在 ENet 中,通道非常简单,它们只是用数字编号(0, 1, 2...)。
- 数量限制:当你创建 Host 或发起连接时,你已经指定了最大通道数(通常 2 个就够用了,一个是关键逻辑,一个是实时数据)。
- 独立性:通道 0 上的序列号和通道 1 上的序列号是互不干扰的。
3. 实战:使用多通道
使用通道非常简单,它体现在发送和接收的两个环节。
3.1 确保通道存在
回顾一下我们在 主机管理 (ENetHost) 或 对等节点 (ENetPeer) 中提到的初始化代码:
c
// 这里的 '2' 表示我们要在这个连接上分配 2 个通道 (Channel 0 和 Channel 1)
ENetHost * client = enet_host_create(NULL, 1, 2, 0, 0);
3.2 在特定通道发送数据
在调用发送函数时,第二个参数就是 channelID。
c
// 场景 1:发送聊天信息 (走通道 0,要求可靠)
ENetPacket * chatPacket = enet_packet_create("Help!", 6, ENET_PACKET_FLAG_RELIABLE);
// 发送到通道 0
enet_peer_send(peer, 0, chatPacket);
c
// 场景 2:发送位置信息 (走通道 1,不要求可靠)
// 假设 Position 是个结构体
ENetPacket * posPacket = enet_packet_create(&myPos, sizeof(myPos), 0);
// 发送到通道 1
enet_peer_send(peer, 1, posPacket);
解释: 这就好比你在邮局寄信时,告诉工作人员:"这封急件走 VIP 窗口(通道 0),这封平信走普通窗口(通道 1)"。
3.3 在接收端区分通道
当你在事件循环中收到数据包时,你需要知道它是从哪个通道来的,以便决定如何处理它。
c
ENetEvent event;
while (enet_host_service(host, &event, 1000) > 0) {
if (event.type == ENET_EVENT_TYPE_RECEIVE) {
// 检查 channelID 字段
if (event.channelID == 0) {
printf("收到聊天信息: %s\n", (char*)event.packet->data);
}
else if (event.channelID == 1) {
printf("收到位置更新数据 (处理逻辑略)\n");
}
enet_packet_destroy(event.packet);
}
}
4. 内部原理解析
通道在内存中长什么样?发送函数到底做了什么?
4.1 逻辑流程图
不需要等 ChatPacket (#5)
4.2 深入代码 (enet.h & peer.c)
1. ENetChannel 结构体 在 enet.h 中,我们可以看到 ENetChannel 的定义。每个通道主要维护自己的序列号。
c
// file: enet/enet.h (简化)
typedef struct _ENetChannel
{
// 发送出去的下一个可靠包序号
enet_uint16 outgoingReliableSequenceNumber;
// 发送出去的下一个不可靠包序号
enet_uint16 outgoingUnreliableSequenceNumber;
// 接收到的序号记录
enet_uint16 incomingReliableSequenceNumber;
// ... 还有接收队列 ...
} ENetChannel;
2. 对等节点中的数组 ENetPeer 结构体中有一个指针,指向一个动态分配的通道数组。
c
// file: enet/enet.h (简化)
typedef struct _ENetPeer
{
// ...
ENetChannel * channels; // 通道数组
size_t channelCount; // 通道数量 (例如 2)
// ...
} ENetPeer;
3. 发送时的逻辑 当你调用 enet_peer_send 时,ENet 会根据你传入的 channelID 找到对应的结构体,并增加该通道的计数器。
c
// file: peer.c (简化逻辑示意)
int enet_peer_send (ENetPeer * peer, enet_uint8 channelID, ENetPacket * packet)
{
// 1. 获取对应的通道对象
ENetChannel * channel = & peer -> channels [channelID];
// 2. 创建发送命令
ENetOutgoingCommand * command = ...;
// 3. 核心:分配序列号
// 注意:这里使用的是 channel->... 而不是 peer->...
// 这保证了不同通道的序列号互不影响
if (packet->flags & ENET_PACKET_FLAG_RELIABLE) {
channel -> outgoingReliableSequenceNumber++;
command -> reliableSequenceNumber = channel -> outgoingReliableSequenceNumber;
}
// ... 将命令放入发送队列 ...
}
4.3 为什么这能防止阻塞?
在接收端,ENet 在收到包时会检查序列号:
- 对于通道 0 :如果收到了序号 #6,但还没收到 #5,ENet 会把 #6 先存起来,直到 #5 到达。这叫有序 (Sequenced)。
- 对于通道 1 :如果收到了序号 #100,它完全不关心通道 0 缺了什么。只要通道 1 自己的序号是对的(或者对于不可靠包,直接丢给用户),它就会立即触发
ENET_EVENT_TYPE_RECEIVE。
这就是多通道机制实现"并行传输"的秘密。
5. 最佳实践
- 不要开太多通道:虽然你可以开 255 个通道,但通常 2 到 4 个就足够了。通道越多,内存开销(虽然很小)和管理复杂度也会增加。
- 分类管理 :
- Channel 0: 关键系统消息(登录、断线、聊天、交易)。必须可靠。
- Channel 1: 状态同步(移动、旋转)。不可靠,追求实时。
- Channel 2: 大文件传输(如地图下载)。可靠,但优先级低(这是进阶用法,结合带宽限制)。
- 两端一致 :确保客户端和服务器在初始化时,分配的
channelCount是一致的,或者至少发送方的 ID 不要超过接收方的最大 ID,否则发送会失败。
6. 总结
在这一章,我们学习了:
- 多通道 (Channels) 是为了解决"队头阻塞"问题,让不同类型的数据互不干扰。
- 通道本质上是独立计数的序列号管理器。
- 使用
enet_peer_send的第二个参数来指定数据走哪条"车道"。 - 在接收端的
event.channelID中区分数据来源。
现在,我们的数据包已经可以高效、有序(或无序)地在网络中穿梭了。但是,我们怎么去不断地从网络中"捞"出这些数据呢?我们的程序怎么知道什么时候该读数据,什么时候该发数据?