技术博客

ENetConnection:可靠 UDP 的通道模型与传输模式怎么选

第 115 / 149 篇 · 2025-04-13 · godot · 网络同步ENet源码分析

Godot 多人游戏的默认传输是 ENet——一个 2002 年诞生、被无数游戏验证过的可靠 UDP 库。用 ENetMultiplayerPeer 时你基本感觉不到它的存在,但三个传输模式的选择、聊天和位置同步要不要分通道、为什么大包会被拆,答案都在 ENet 层。这篇讲 ENet 的通道模型和发送标志,4.3 起 Godot 把它的原始 API 也暴露出来了(ENetConnection / ENetPacketPeer),可以绕过多人抽象直接用。

为什么需要"可靠 UDP"这个怪物

TCP 的问题游戏圈讲了二十年:它是字节流且全序的。一个丢包,后续所有数据卡在重传后面等——哪怕后面那份数据是不重要的位置更新。统计上看,丢包率 $p$、RTT 为 $R$ 的链路上,TCP 级别的队头阻塞让无关紧要的包也搭上重传的便车。游戏要的是"重要的包重传,不重要的包丢了就丢了",一个连接里两种语义并存——TCP 给不了,裸 UDP 又给不了可靠,于是有了 ENet 这类用户态可靠层:在 UDP 上自己实现确认、重传、排序,但把"要不要这些保证"的开关交给发包的人。

通道:连接内的虚拟车道

ENet 的一个连接(peer)下挂最多 255 个通道(channel),每个通道是独立的可靠序列:通道 1 的重传不阻塞通道 2 的投递。这正好映射游戏需求——聊天走通道 A(可靠),位置同步走通道 B(不可靠),互不相干。

Godot 的多人层把通道 0、1、2 预留给三种 transfer mode(可靠 / 不可靠 / 不可靠但有序),用户自定义通道从 3 号开始。这个设计有一个常被忽略的细节:通道 0 上可靠和不可靠包是混跑的?不是——每种模式一条通道,所以"可靠包的重传堵住不可靠包"在多通道隔离下不会发生。之前七-1 提到聊天和位置要分开通道,准确说法是给它们分配不同的自定义通道号,物理上彻底隔离两条流。

每个通道自己维护可靠序列号和重传队列。收端按通道内的序号重组、去重、补洞,对上层呈现"这个通道的包按序到达"。

四种发送标志

ENet 的发送语义由标志位组合决定,Godot 的三种 transfer mode 映射到其中几种(ENetPacketPeer.PACKET_FLAG_*):

  • RELIABLE:确认 + 重传 + 按序。丢包时收端等待重传,延迟有上界(重传间隔倍增,见下文);
  • UNRELIABLE:不等确认不重传,但带序号——晚到的旧包会被收端丢弃,保证你拿到的位置不会"跳回去";
  • UNRELIABLE_FRAGMENT:不可靠 + 超过 MTU 时允许分片重组。普通 UNRELIABLE 包超了 MTU 直接被拒;
  • UNSEQUENCED:连序号都不要,先到先得,适合完全不敏感的点缀数据(Godot transfer mode 没有用这种,自定义 peer 时可用)。

选择逻辑就一条:数据越新越好的走不可靠(位置、朝向、血量快照——丢一帧没关系,下一帧马上覆盖),数据必须到达的走可靠(射击事件、拾取、聊天)。选错的代价不对称:该可靠走了不可靠,事件静默丢失;该不可靠走了可靠,一次丢包把所有后续更新卡在重传后面——后者在移动端弱网是帧率杀手。

MTU、分片与 1350 之谜

ENet 的默认 MTU 是 1392 字节(扣掉 UDP/IP 头后的载荷上限),超过的单包行为分两种:可靠包自动分片(fragment),在收端重组;不可靠包默认直接失败。Godot 的 SceneMultiplayer.max_sync_packet_size 默认压在 1350,就是在 MTU 内侧留余量,避免单包被分片。

为什么要这么怕分片?IP 层分片有个著名的坑:任一碎片丢失,整个 IP 包作废,而 IP 本身不重传——上层可靠层要重传整个原始包,放大丢包代价。丢包率 $p$ 下,把一个包拆成 $k$ 个 IP 片,整包到达率从 $1-p$ 跌到 $(1-p)^k$,可靠层的重传量相应放大。所以工程上的铁律是单包控制在 MTU 内,宁可多包不可大片。

不可靠 + 大包的组合因此是被刻意堵死的:UNRELIABLE 超 MTU 报错,UNRELIABLE_FRAGMENT 是唯一放行口,代价是重组 buffer 的内存和"分片之一丢失则整包丢"的概率放大。位置同步包写大了(比如一次同步太多属性)撞上这个限制时,报错信息往往不直观——把同步列表拆开、拆节点、降频率,三选一。

ENet 通道模型:一个连接,三条互不相干的可靠序列 Peer(连接) 对端 IP:port 通道 0:可靠流 确认/重传/按序 —— 事件、聊天 通道 1:不可靠流 丢就丢了 —— 位置、血量快照 通道 2..N:自定义 语音、区块流等 尺寸纪律 ENet MTU ≈ 1392B;Godot 同步包上限 1350B 就是防 IP 分片 分 k 片后整包到达率 (1-p)^k —— 任一片丢则全包作废重传 不可靠包超 MTU:默认报错,仅 UNRELIABLE_FRAGMENT 放行(代价:丢片概率放大)

带宽节流与压缩

ENet 自带节流机制:链路持续饱和或 RTT 抖动超阈值时,peer 自动降发送速率,这个行为由 host.throttle_configure() 的参数控制(默认阈值比较宽松)。多人游戏里它通常不是主要限速因素——你的发包频率才是。但做大规模同屏(上百个 MultiplayerSynchronizer)时留意 ENetConnection.pop_statistic(...) 里的丢包和节流指标。

压缩方面 ENet 有 compress_with_range_coder()(基于 range coding 的字节级压缩,对 JSON 类文本包有 30%+ 收益),但 Godot 多人层已经做了 Variant 编码和 NodePath 缓存(见前两篇),压缩收益被吃掉大半,一般不必开。真要传大块重复数据(地图块、replay 日志),自定义通道 + 自己压更可控。

什么时候绕开多人抽象

ENetConnection/ENetPacketPeer(4.3+)把 ENet 原生的 host/peer/event 循环暴露出来:自己 accept 连接、自己收 EVENT_RECEIVE、自己决定标志位。值得绕开的场景:非游戏协议的对接(房间服务、文件传输)、需要精确控制通道和标志的实时语音、以及做网络层录制回放(七-8 的注入点)。代价是你自己处理封包格式和心跳——ENet 只保证送达,不保证语义。

本文基于 Godot 4.2–4.4,ENet 行为以库 1.3.x 为准;通道数上限 255、MTU 1392 为 ENet 常量,Godot 侧预留通道分配见 scene/main/scene_multiplayer.h 与 modules/enet/。