Multiplayer RPC APIs over ENet 【Godot 源代码阅读】

🟠🔵 Enet Multiplayer APIs
Godot 4.0 RC1 终于被我等出来了[大狗头]

为了祝贺 Godot 4.0 艰辛地从 Alpha、Beta,再到 RC 的历程,终于直到正式发布前的阶段性成就,特意献上一份 Godot 源代码阅读材料,鱼乐诸君。内容有点长,像裹脚布,主要是代码和草图有半。
首先,由于涉及到源代码阅读,这就需要讨论一下阅读代码的方法论。阅读源代码的能力算是程序员的一种底层基础能力之一,这个能力的重要性与研究数据结构与算法同等重要。软件开发的过程中必不避免接触到其他人的工作成果,而阅读源代码可能是出于学习行为,也可以是出于项目需求问题的解决。
这里就阅读源代码给出几点参考意见:
源代码是人类的思想表达形式之一,阅读源代码相当于在做一个思维逆向工程,与软件破解逆向程没有本质区别。
每个人的代码组织会有所差异,与各人的思维方式直接相关,应该尽量尝试代入作者的思维看待问题。
不要想着通透所有代码,应该轻重分明,把握主线。阅读源代码前先建立目标,避免过多关注不相关代码。
阅读过程陷入停滞,就应该暂停一下,可能是某些相关的基础缺失导致这种结果,应该将时间用在打基础上。
不要完美主义,但可以更进一步,关注一每个小问题的解决。不要纠结缺失,它是提供一个未来进步的空间。
保持使用笔记的习惯,特别是方便快速检索和加查的工具。我推荐具有强大跳转功能的 Sublime Text。
将阅读结果整理成流程图,像以下这种简洁风格 ASCII 流程图最爱,它也是 RFC 文档的图表工具。
最后,以史为镜,可以知兴衰;以人为镜,可以知得失。遇事不决问历史。
记得第一次尝试阅读的源代码是 Wordpress,这是一个 Web 内容管理工具,我的习惯是先过一遍官方文档。学习 Python、Lua、Vim 或者 Rust 等等,在也保持同样的习惯,当然,这可能要消耗大量时间。
特别是一点,当过程进行不下去的时候,几乎可以直接下定论:你可能遇到一个全新领域,对此,开启一个学习专题,进行深入的探索才是出路。没有一个工程可以单靠单一的技术实现的,涉及面广泛是一个成功项目的标志,探索过程带来的认识提升,也就是阅读源代码所带来的终极效果。
ENet 与 RPC 协议基础
在 Godot RPC API 系统中,`MultiplayerAPI` 和 `MultiplayerPeer` 是两种最重要的类型,前者用来管理 RPC 协议的规则与配置,后者通过具体网络传输协议提供网络通信支持,其中又以 ENet 协议的实现类型 `NetworkedMultiplayerENet` 或者新版本中的 `ENetMultiplayerPeer` 为基础。
通过后续的分析,最终可以得到以下这样的一个 RPC API 执行逻辑图,这里先展示出来。此图简化了网络传递,着重 RPC 执行过程。发起 RPC API 调用过程是左侧 ENet Peer Host,以 `rpc()` 方法执行为线索,其实 `rset()` 最后也会执行到 `_send_rpc()`,统一由 ENetPeer 进行网络传输:
Birrell 和 Nelson 在 1984 发表于 ACM Transactions on Computer Systems 的论文 Implementing remote procedure calls 对 Remote Procedure Call Protocol (RPC) 远程过程调用协议做了经典的诠释。客户端程序透过网络调用远程计算机上的对象,就像调用本地应用程序一样。
第一代 RPC 是 ONC RPC,以前称为 Sun RPC,它提供一个编译器,需要用户定义一个远程过程接口来生成客户机和服务器的存根函数,client stub,这个编译器叫做 rpcgen,谷歌的 protobuf 构架也是这种。
第二代 RPC 支持对象,以微软 DCOM(COM+) 为代表。面向对象的语言开始在 1980 年代末兴起,很明显,当时的 Sun ONC 和 DCE RPC 系统都没有提供任何支持诸如从远程类实例化远程对象、跟踪对象的实例或提供支持多态性。
第三代 RPC 以 Web Services,Simple Object Access Protoco (SOAP) 简单对象访问协议为代表,是 Web 平台编程流行的产物。
Enet 省略了某些更高级别的网络功能,例如身份验证,服务发现,加密或其他特定于应用程序的类似任务,因此该库保持灵活,可移植且易于嵌入,并具以下优点:
01. 克服 TCP 不支持多信道问题;
02. 克服 TCP 需要用户自己处理粘包问题,UPD 数据包则先天有边界保护;
03. 克服 UDP 不支持排序,连接管理,带宽资源管理,数据包的大小有限制;
04. Enet 基于单一 UDP 协议实现了具有 UDP 和 TCP 等价功能,但比同时集成两者更干净、统一。
TCP 协议特点:
TCP 基于连接实现可靠性传输,使开发更为简单,但同时也带来效率慢的特点。
TCP 为流量设计,可使用滑动窗口控制每秒内可以传输多少KB的数据,讲究的是充分利用带宽。
TCP 为了可靠性使用复杂的拥塞控制算法,3 次握手 4 次挥手建立和断开连接、重传策略。
TCP 内置在系统协议栈中,极难对其进行改进。
滑动窗口是一种基于双指针的一种算法思想,两个指针指向的元素之间形成一个窗口,通过调整窗口大小来控制数据包的数量,以实现流量控制。窗口有两类,一种是固定大小类的窗口,一类是大小动态变化的窗口。
UDP 协议特点:
UDP 协议基于无连接进行高效的不可靠数据传输,但同时应用层收到的数据有缺失、乱序等问题。
UDP 协议头开销小,使用 8 个字节,相比 TCP 要占用 20 个字节。
UDP 面向报文,有数据包边界保护。
UDP 支持一对一、一对多、多对一和多对多的交互通信等。
UDP 协议以其简单、传输快的优势,在越来越多场景下取代 TCP,如网页浏览、流媒体、实时游戏、物联网。
从数据类型定义的角度来看,Godot 集成 ENet 源代码中:
- 每个主机对应 `ENetHost`:An ENet host for communicating with peers.
- 每个连接端对应的是 `ENetPeer`: An ENet peer which data packets may be sent or received from.
- 每个数据包对应 `ENetPacket`:ENet packet structure.
- 每个数据信道对应 `ENetChannel`:ENet packet structure.

单连接多频道
ENet 起源自 Lee Salzman 开发的一个免费联网 FSP 游戏,最新版本是 Cube 2: Sauerbraten,为其编写的网络库就是 ENet。
基于 ENet 实现的各种 RPC API 有两种调用方式:
Reliable 可靠:当函数调用到达时,将返回确认;一定时间后没有收到确认,则重新发送函数调用。
Unreliable 不可靠:函数调用只发送一次,不检查是否到达,没有额外开销,典型的 UPD 协议风格。

服务器/客户端网络模型 Peer-to-Peer 是两种常见的网络模型,而 P2P 则是去中心化的网络模型。
ENet 对等网中所有主机在创建连接时都会相互确认连接,虽然建立连接时也需要服务器,但和传统的 C/S 网络构架不同,ENet 中是对等网络模型。Peer to Peer (P2P) 打破了传统的 Client/Server (C/S) 模式,在网络中的每个节点的地位都是对等的。每个节点既充当服务器,为其他节点提供服务,同时也享用其他节点提供的服务。“Peer”在英语里有“对等者、伙伴、对端”的意义。
根据 ENet 文档描述,其功能包括:
- **连接管理** Connection Management:提供与外部主机通信的接口,动态监管外部主机及网络状况。
- **排序** Sequencing:提供多个合理排序的网络包流而非单一比特流,从而简化不同类型数据的传输。
- **通道** Channels:连接可用多个具有网络包独立排序的数据通道,解决因推迟可靠网络包的乱序排序问题。
- **可靠性** Reliability:可靠性是可选项,外部主机在特定时间内没有确认收到网络包就触发重传。
- **拆分和重组** Fragmentation & Reassembly:大数据包拆解发送,在接收端重组,此过程自动完成。
- **聚合** Aggregation:集合多个协议指令,ack,packet transfer 等,确保可用性,减少丢包及延时等。
- **适应性** Adaptability:使用动态适应的数据窗口,和静态的带宽分配机制,解决网络拥塞问题。
ENet 作为一种有传输信道的协议,ENetHost 以 channelLimit 指示信道数量,取值范围 [1, 255],默认定义了三种专用信道:
- **SYSCH_CONFIG** 系统配置变更通知
- **SYSCH_RELIABLE** 可靠模式专用通道
- **SYSCH_UNRELIABLE** 非可靠模式通道
ENet 协议定义了 12 条指令,每条指令都相应定义了其数据结构体,其中的:
- Acknowledge 确认指令,在收到可靠包后用来答复发送方,表明已经收到了相应的数据包;
- Ping 指令用于监测外部主机的连接状态等等,与心跳包发送时机相关;
- connect 指令用于主动发起连接的一端进行主动连接操作
- Verify Connect 建立连接时,在第二次握手用于答复,同时用于主动连接方同步被动连接方的相关信息。
- Bandwidth Limit 流量限制指令,调节对端对应本地的 peer 的带宽的相应的数值。
- Throttle Configure 流量控制调整指令,用于调节由 RTT 控制的 packetThrottle 相关设置。
- Send Reliable 可靠包发送指令,用于发送不用分片的可靠包,相应的数据跟在指令的后面。
- Send Unreliable 不可靠包发送指令,发送不用分片的不可靠包,与 unrealiable 包的实现机制相关。
- Send Unsequenced 指令发送不需要分片的 Unsequenced,用相应的 unsequencedGroup 标记序号。
- Send Fragment 指令用于发送所有需要分片的数据包,通过 flag 标记 reliable 和 unreliable 等类型。
在创建时,ENet 会建立一个 `ENetHost` 作为通信的客户端,包含与 peer 进行通信的 socket。使用一个ENetList dispatchQueue 队列存放有事件产生的 peers,还使用一个 ENetPeer* peers 数组用于存放与外部客户端通信的 peer 数据结构。
ENet 使用比 TCP 更轻量的三次握手连接的二次握手断连,使用 verify Connect 指令确认,同时它用于主动连接方同步被动连接方的相关信息。


ENet 在连接建立过程中可以改变 peer 的状态,过程如下:
- 首先,两个 host 建立连接前需要保证 peers 数组内有空闲的 peer,其状态为 `disconnected`。
- 主动连接方使用空闲 peer 向对端发送 `connect` 指令,状态变为 `connecting`。
- 对端接收到 `connect` 指令后,返回 `verify connect` 指令作为答复,状态变为 ack connect。
- 主动连接方收到 `verify connect` 指令后,并答复一个 ack 指令,其状态变为 `connected`,连接建立。
- 并且,向用户 dispatch 一个 **connect event** 事件。
- 对端接收到 ack 命令后,状态变为 `connnected`,双方连接建立完成。
- 并且,同样向用户 dispatch 一个 **connect event** 事件。
至此,双方连接建立完成。



ENet 提供了三种断开连接的方式:disconnect, disconneted now 和 disconnect later。
在断开连接再发起时,出现新旧两个连接的端口号相同的情况,会被判定为相同的连接,ENet 使用 SessionID 防止两个具有相同 IP 地址和端口号的前后两次连接发送的数据发生混淆。而 TCP 协议会使主动断开连接的一方处于 TIME_WAIT 的状态来防止这种情况的发生。
但是,这只是简单的 ID 匹配,所以并不能像 TCP 100% 防止两次连接中数据包混淆这种情况的发生,但是大部分情况下仍是有效的。
ENet 采用的是选择重传的方式,为保证新旧窗口的序号没有重叠,窗口的最大尺寸不应该超过序号空间的一半。ENet 在发送新的数据包时会通过**usedReliableWindows**判断当前窗口占用是否与空闲窗口重叠,如果重叠则暂停数据包的发送。reliableWindows 会记录各个窗口中目前在传输中的包的个数。
更进一步,ENet 提供了一个动态的阀门,packet throttle,来响应网络连接时带来的偏差,通过限制包的发送数量来应对各种类型的网络拥塞问题。
网络状况评估指标:
RTTV[n] = RTTV[n-1]* 3/4 + (RTT[n] - RTTS[n-1])1/4
RTTS[n] = RTTS[n-1] * 7/8 +RTT[n]* 1/8
RTO Limit: Min(30, Max(5, RTO * 32))
Round-Trip Time (RTT) 往返时延,是指数据从网络一端传到另一端所需的时间。
RTT smoothed (RTTs) 单位时间内 RTT 加权平均值。
RTT variance (RTTv) 单位时间内 RTT 偏差加权平均值。
Retransmission TimeOut (RTO) 重传超时时间。初始 RTO = RTTs + 4RTTv
初始超时重传时间 RTO = RTTs+4RTTv,下次超时重传时间是上一次重传时间的两倍,但最大不超过 500ms。超时断开判定使用心跳信号,即在没有发送数据包的状态,超过 500ms 就会发送一个心跳包。断开连接判定条件: currentTime-earliestTimeout > RTO Limit。
Godot 源代码阅读
为了对 ENet 的源代码作一管中窥豹之举,这里以 Unique ID 以及它在接收端的身份 Sender ID 作为 Godot 集成的 ENet 源代码阅读线索,对涉及的关键内容进行梳理。
Godot 使用 C++ 封装了基于 C 语言的 ENet,整个构架封装在 NetworkedMultiplayerENet 类型中。而 ENet 源代码存放在第三方目录下。
ENet 定义了四种事件,使用 I/O 事件轮询模型,封装在 NetworkedMultiplayerENet::poll()。当新连接请求到来,根据读取到的 peer id,分别触发连接状态信号。在服务器端不触发连接成功信号,但会向当前所有已连接的 peers 发送通告。将新加入 peer 通告给已有的 peers,反过来又将已有的 peers 通告给新加入的 peer,这种双向的操作体现了对等网的基本特征:
在接收数据阶段,也会触发 peer 的连接与断开信号,对应 SYSCH_CONFIG 信道发送的 SYSMSG_ADD_PEER 和 SYSMSG_REMOVE_PEER 两种配置消息。接收的数据保存到 **incoming_packets**,稍后就可以使用它。
NetworkedMultiplayerPeer 中定义的 get_unique_id() 方法是一个纯虚函数,它需要由子类根据具体协议来实现,例如 NetworkedMultiplayerENet 的实现中,就使用 1 代表服务器。而创建服务器时,就根据时钟加用户目录、数据指针地址等生成一个 ID,使用的算法是 Hash Djb 2:
触发 `peer_connected` 信号时,或者自己给自己发送 RPC 调用时,get_rpc_sender_id() 返回值为 0。Godot 3.5 中如果配置错误会导致 ScreenTree 获取到的发送方 ID 总是为 0。它又直接包装调用 MultiplayerAPI 的 get_rpc_sender_id() 方法获取 peer ID,而后者直接返回数据成员 `rpc_sender_id`。这个值会在MultiplayerAPI 构造函数中初始化为 0。
MultiplayerAPI 有多个位置涉及 rpc_sender_id 的读写:
- **MultiplayerAPI::MultiplayerAPI()** 构造器中初始化为 0 值;
- 执行 `poll()` 方法轮询数据时,`get_packet_peer()` 获取 ID;
- **MultiplayerAPI::rpcp()** 执行远程调用;
- **MultiplayerAPI::rsetp()** 执行远程属性设置;
一般同不直接调用 NetworkPeer `poll()` 方法,而是通过 MultiplayerAPI 根据当前联接状态调用它。
ENet 接收到的数据会保存到 **incoming_packets**,其中就包含 sender id,`get_packet_peer()` 就可以查询数据包中的这个 ID,在轮询时,这个 ID 会在 `_process_packet()` 期间生效。根据数据包指定的命令,数据包处理函数会进行相应操作,Godot 将这些命令定义在枚举类型 `NetworkCommands`,对应各种 RPC 行为。也就是说,要读取 sender id,就应该在执行 REMOTE_CALL 和 REMOTE_SET 两个命令期间进行,也就是远端 RPC 远程调用方法执行时。
其中的 RAW 命令是更低层的数据接收处理,会触发 **network_peer_packet** 信号,让用户有机会参与。
将关注点转到 process packet 的部分来,它才是 Godot 3.x RPC 远程调用的终点站。在这里,会触发 Godot 3.x 两类远程调用的响应方法,即 `rpc()` 和 `rset()` 两者调用的远程方法。
RPC 执行到 `_process_rpc()` 或者 `_process_rset()` 就要进行鉴权,如果节点没有相应的权限,则不给予执行,还要检测数据包是否有问题等等,并提示错误信息。`_can_call_mode()` 方法验证 RPC 配置与节点设置是否对应:
通过以上分析,最终可以得到开始展示的 RPC API 执行逻辑图,简化了网络传递,着重 RPC 执行过程。从发起 RPC API 调用 `rpc()` 方法,到远端执行为线索,`rset()` 最后也会执行到 `_send_rpc()`,由 ENetPeer 进行网络传输。
执行到 MultiplayerEnet 的 `put_packet()` 方法后,就即将进入 ENet 源代码的 C API,其中有`enet_peer_send()` 和 `enet_host_broadcast()`,分别用于单端消息和广播消息的发送。
另外一个路线是 rset() -> rsetp(),大体上流程一致。
在数据经网络传输之前或接收到数据,需要相应调用 `encode_variant()` 和 `decode_variant()` 进行类型进制制表达转换,涉及到对象的序列化与反序列化,Serialization vs. Deserialization。Go 语言中又称为 marshalling 和 unmarshalling。这是一个新的领域,可以开启另一个专题进行探索。
源代码文件参考:
- godot-3.5.1-stable\core\io\marshalls.h
- godot-3.5.1-stable\scene\main\node.cpp
- godot-3.5.1-stable\core\io\multiplayer_api.cpp@_send_rpc
- godot-3.5.1-stable\modules\enet\networked_multiplayer_enet.cpp@put_packet
- godot-3.5.1-stable\thirdparty\enet\peer.c@enet_peer_send
- godot-3.5.1-stable\thirdparty\enet\host.c@enet_host_broadcast
- godot-3.5.1-stable\core\io\packet_peer.h
- godot-3.5.1-stable\core\io\packet_peer_udp.cpp
- godot\core\io\net_socket.h
- godot\core\io\marshalls.cpp
- godot\scene\main\multiplayer_api.cpp
- godot\modules\multiplayer\scene_multiplayer.h
- godot\modules\multiplayer\scene_rpc_interface.h
- godot\core\io\packet_peer.h
- godot\modules\multiplayer\scene_cache_interface.h
参考
[Godot history in images!](https://godotengine.org/article/godot-history-images/)
[ENet with DTLS encryption in 4.0](https://godotengine.org/article/enet-dtls-encryption/)
[冗余传输机制的网络库](https://github.com/Uyouii/Redundancy-Transmission-Protocol)
[Enet实现原理](https://blog.csdn.net/gamekit/article/details/107092176)
[ENet Reliable UDP networking library](https://github.com/lsalzman/enet)
[Lee Salzman's Page of Random Stuff](http://sauerbraten.org/lee/)
[ENet v1.3.17 Reliable UDP networking library](http://enet.bespin.org/Features.html)
[What Every Programmer Needs To Know About Game Networking](https://gafferongames.com/post/what_every_programmer_needs_to_know_about_game_networking/)
[UDP vs. TCP by Glenn Fiedler](https://gafferongames.com/post/udp_vs_tcp/)
[User Datagram Protocol (UDP)](https://www.khanacademy.org/a/transmission-control-protocol--tcp)
[Transmission Control Protocol (TCP)](https://www.khanacademy.org/a/user-datagram-protocol-udp)
[TCP 图解千百问](https://mp.weixin.qq.com/s/tH8RFmjrveOmgLvk9hmrkw)
[gRPC Docs](https://grpc.io/docs/)
[Protocol Buffers](https://github.com/protocolbuffers/protobuf)
[Protocol Buffers Documentation](https://protobuf.dev)
[Protobuf in GDScript](https://github.com/oniksan/godobuf)