技术博客

RPC 的实现:从 @rpc 注解到字节流,一次远端调用被编码成什么样

第 114 / 149 篇 · 2026-09-13 · godot · 网络同步源码分析GDScript

player.rpc("take_damage", 25) 这行代码在本地看起来只是个函数调用标注,实际发生时引擎做了五件事:查 RPC 配置、校验权威、编码包头、压缩参数、塞进传输通道。这篇把这条链跟到底,重点看包到底长什么样——理解了字节布局,带返回值的 RPC、call_local 的行为、以及"为什么 RPC 集合不一致会直接报错"就都顺理成章了。

编码链:从脚本语句到 peer 缓冲

调用入口在 Node::rpc(),委托给 SceneMultiplayer::rpcp(node, method, args, mode, channel)(scene/main/scene_multiplayer.cpp)。链条是这样:

  1. 配置查询:从脚本元数据里查这个方法的 RPC 配置(能不能远端调、谁有权调、可不可靠、走哪个通道)。没有 @rpc 注解的方法在这里直接失败——注解不是装饰,是白名单登记;
  2. 权威校验:p_mode == RPC_MODE_AUTHORITY 时检查调用方声明的 authority 是否和节点当前的 multiplayer_authority 匹配(七-1 说过,这是声明校验不是身份证明);
  3. 方法名 → 整数索引:用脚本里的 RPC 方法表把方法名解析成索引号。这就是包里没有方法名字符串的原因;
  4. 参数压缩:每个参数过 encode_and_compress_variant(),首字节元信息 + 按值域分档的整数编码(七-2 那篇逐行读过,这里不重复);
  5. 入队发送:目标 peer 列表逐个 put_packet()。

call_local 模式的语义在这一步体现:本地执行不是"发给自己再收回来",而是当场同步调用函数体,和网络发送并行发生。这意味着本地执行顺序在包到达远端之前——依赖"本地先跑还是远端先跑"的顺序保证的逻辑都是错的。

包格式

一个 RPC 包的布局(字段宽度以源码为准,这里画的是结构关系):

RPC 包布局:take_damage(25) 的字节构成(示意) 配置字节 模式|通道 目标节点 id NodePath 缓存号 方法索引 查本端 RPC 表 参数 × N 各带首字节元信息 例:int 25 1+1 = 2 字节 例:take_damage(25) ≈ 配置 1B + 节点 1B + 方法 1B + 参数 2B ≈ 5 字节 对比:方法名字符串 "take_damage" 自己就要 11+ 字节 —— 索引化是带宽优化的第一步 收端解码 按索引查本端脚本 RPC 表 → 反解出方法 → 按参数个数逐字节解 Variant → 调 Object::call 两端 RPC 表不一致时:checksum 校验在连接建立阶段就拒绝,不会走到这一步

注意这里有两个压缩层次叠加:节点寻址用 NodePath 缓存整数(七-1),方法名用脚本 RPC 表的整数索引,参数用 Variant 值域分档(七-2)。三层压缩各管一段,共同效果是一个语义完整的远端函数调用只花几个字节。量级感:每帧一次的位置同步 + 偶发的事件 RPC,万行代码的游戏流量大头永远是前者——事件 RPC 的这点编码开销从来不是瓶颈,纠结"RPC 会不会太慢"之前先 profile 同步列表。

方法索引为什么必须配 checksum

方法索引在包里的代价是:它只在两端 RPC 表完全一致时才有意义。索引是表内序号,同一个索引在两份不同的脚本里可以指向完全不同的方法——参数按错误的签名解码,轻则崩溃重则静默错数据。

引擎的对策是整表 checksum:连接建立时两端交换各自全部 RPC 方法集合的哈希(函数名、参数、@rpc 注解配置全部参与),不一致直接断开并报错。这就是为什么报错信息常常指向一个你根本没在调的函数——校验是整表粒度的,任何一个函数的注解改了,整个哈希就变(七-2 里记过一笔,这里把原因补齐:逐函数校验要把函数级哈希塞进每个包,协议变胖;整表校验把一致性变成连接的前置条件,运行时每个包零校验成本)。

工程推论:只改 @rpc 注解的某次热更,会让新旧客户端互不兼容,比改函数体危险得多——函数体不影响 checksum,注解影响。灰度发布和 mod 生态都要注意这一点。

带返回值的 RPC

4.x 支持远端函数返回值:var hp = await player.rpc_id(1, "query_hp")。实现上是两条独立消息:调用包照旧发出去,收端执行完把返回值单独打成一个响应包带调用 id 送回来,调用端挂起协程等这个 id。几个由实现推出的使用约束:

  • 不可靠通道上的返回值 RPC 可能永远等不到——响应包和调用包一样受 transfer mode 约束,超时没有内建重试;
  • 开销是双倍的(去程 + 响应),高频查询别用返回值 RPC 模式,把"查询-应答"改成服务器主动推的同步属性更省;
  • 协程挂起期间如果节点被释放,await 永远等不到结果也不会报错,记得在退出树时取消。

校验与调试速查

  • 两端 RPC 表 checksum 不一致:连接直接失败,报错列出不一致的函数——改注解导致的,对齐两端脚本;
  • "Function not found in RPC config":注解没写或写错位置(注解必须在远端也存在的脚本方法上);
  • 方法被调但 authority 校验失败:remote_sender 和节点 authority 不匹配,检查 set_multiplayer_authority 的时机是否在 spawn 之前;
  • 想看真实字节流:ENetConnection 层抓包(Wireshark 对 ENet 不友好,直接在 SceneMultiplayer 的收发包回调里打印更实际)。

本文基于 Godot 4.2–4.4 源码,关键文件:scene/main/scene_multiplayer.cpp(rpcp / send_rpc)、scene/main/node.cpp(rpc() 入口)、core/io/multiplayer_api.cpp。包内字段宽度以版本为准,结构关系不变。