一条跨服消息跑过三个进程:ET框架Actor模型消息通信链路拆解

📅 发布时间:2026/9/17 18:33:54
一条跨服消息跑过三个进程:ET框架Actor模型消息通信链路拆解
一条跨服消息跑过三个进程ET框架Actor模型消息通信链路拆解【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET玩家点击地面角色从 1 线迁到 2 线一条移动指令要穿过 Gate、Location、Map 三个进程才能落到具体对象身上。ET 框架把 Actor 模型下沉到 Entity 级Entity级通信就是靠这条消息通信链路撑住跨进程、跨线的调度的。下面从这条消息的完整旅程入手看 Send 与 Call 的分叉点、Location Server 如何给搬家的 Actor 定位以及嵌套 RPC 死锁这类容易翻车的环节。开场场景一条移动消息的三站旅程把镜头对准一个玩家对象Entity挂着MailboxComponent的 Unit。客户端每帧把点击坐标打包成Frame_ClickMap发给 GateGate 认出这是一条 actor 消息直接按消息体里的ActorId转发Map 进程上的 Unit 收到后Handler 里执行MoveTo。全程没有任何人写目标是哪个进程、哪台机器——发送方只报对象路由是框架的事。[客户端] ── Frame_ClickMap ────────────── [Gate 进程] │ 认出 actor 消息按 ActorId 转发 │ [Map 进程] Unit 的 MailboxComponent 队列 ── Handler.Run ── MoveTo ▲ └── 转发前拿 Entity.Id 去 [Location 进程] 查当前 InstanceId见后文这里有个关键分叉发送方手里拿的是InstanceId 还是 Entity.Id拿 InstanceId走原生 Actor 链路一次直达只拿 Entity.Id迁移后旧 Id 会失效就得走分布式消息路由的 Location 链路。两条链路共用同一套 Handler 处理模型差别在寻址环节。ET框架Actor模型消息通信玩家跨线迁移的战场背景示意为什么 Actor 要下沉到 EntityMailboxComponent 的挂载规则Erlang 的 Actor 是进程级的消息先落到战斗进程进程内部再按玩家 id 二次分发等于多绕了一圈。ET 的做法是把接收端直接做成 Entity 对象——谁该收消息消息就进谁的MailboxComponent队列没有中间转发层。Erlang 那套在 ET 里退化为特例把整个Game.Scene当 Actor就成了进程级通信。维度ETErlangSkynet架构单线程多进程单进程多线程单进程多线程Actor 载体Entity 对象erlang 进程lua 虚拟机Actor 标识Entity.InstanceId进程号 Pid服务地址调试工具链通用 C#/.NET profiler、top 直接看进程VM 自带工具lua 侧需配套脚本热更新HybridCLR 支持 C# 热更进程级代码热替换lua 脚本可重载这个设计的代价是跨进程消息要序列化、网络有几毫秒延迟换来的好处是进程边界即隔离边界单机和多机部署在代码层面没有区别。Send 和 Call 到底差在哪Send 是发完即走没有回执Call 是 RPC发送方会挂起等待直到对方reply回来才继续。选哪个取决于对方处理完的结果我要不要、等不等得起。// 知道目标 InstanceId 时从 Game.Scene 拿发送器发 Send 或 Call var senderComponent Game.Scene.GetComponentActorSenderComponent(); var sender senderComponent.Get(sessionActorId); sender.Send(message); // 单向不等回执 var resp sender.Call(request); // 等回执拿返回值消息落到MailboxComponent后按邮箱类型分流目前有两类GateSession收到消息直接转发给客户端不做分发默认的MessageDispatcher则按消息类型路由到具体 Handler。为什么要有前者因为 GateSession 的职责根本不是分发硬套 MessageDispatcher 反而多一层。Handler 的写法就是继承抽象类 特性标注目标 AppType泛型参数依次是 Actor 类型、消息类型RPC 再加返回类型[ActorMessageHandler(AppType.Map)] public class Actor_TestHandler : AMActorHandlerUnit, Actor_Test { protected override ETTask Run(Unit unit, Actor_Test message) { Log.Debug(message.Info); } }挂载时机有个隐含规则Entity 挂上MailboxComponent的那一刻才成为ActorInstanceId 此刻才产生。登录链路里 Gate 给 session 挂邮箱AddComponentMailBoxComponent, string(MailboxType.GateSession)之后这个 InstanceId 随G2M_CreateUnit传给 Map 进程——先挂邮箱、再传 Id顺序反了首条消息必然投递失败。Actor 搬家后消息怎么找得到Location Server 的定位与重试先不讲 happy path讲最容易翻车的一刻消息在途Actor 恰好开始迁移。迁移窗口期为什么必须加锁不加锁会发生什么发送方按旧 InstanceId 投出消息目标进程里这个 Actor 已经被销毁回执不存在发送方重查 Location Server可能拿到旧地址也可能拿到新地址——消息要么双投要么丢失。ET 的处理是一个锁定的迁移窗口传送前目标进程先删掉本进程内的 actor 记录在 Location Server 上对该 key 加锁此后所有查询该 key 的请求排队等待迁移完成更新新地址并解锁排队请求带着新地址继续投递。在途消息在这期间收到的不存在错误会触发重试重试时撞锁、等待直到解锁拿到新地址——所以迁移窗口内的消息不会丢只会延迟。再看常态链路完整规则是首次查询 缓存ActorLocationSender只有第一次才查 Location Server之后缓存 InstanceId 直发逐条回执Location 链路的 Send 要求接收方对每条消息回执收到上一条回执才发下一条保证有序失败重试收到Actor 不存在错误后等待 1 秒重新查一次地址再发默认最多 5 次仍失败则抛异常。发送 Location 消息(target.Entity.Id, msg): sender 缓存[target.Entity.Id] if sender 未缓存: sender LocationServer.Query(target.Entity.Id) # 首次查询 缓存[target.Entity.Id] sender for 尝试 in 1..5: 回执 sender.Send(msg) # 必须等回执 if 回执 Actor不存在: 等待 1 秒 sender LocationServer.Query(target.Entity.Id) # 重取地址 else: break// 只拿得到 Entity.Id 时从 Game.Scene 拿 ActorLocationSenderComponent var locationSender Game.Scene.GetComponentActorLocationSenderComponent().Get(unitId); locationSender.Send(locationMessage); // 有序 Send逐条回执 var resp await locationSender.Call(locationRequest); // Location RPCHandler 侧只需把抽象类换成带 Location 后缀的版本AMActorLocationHandler/AMActorLocationRpcHandler其余写法一致。还有一个反直觉的点为什么客户端的Frame_ClickMap消息体里要带ActorId字段因为 Gate 把消息转发到 Map 后Map 必须知道投给哪个 Entity。替代方案——底层转发协议携带 unit id或外层再包一条带 Id 的包装消息——前者协议复杂后者增加序列化开销和 GC。把消息本身做成 actor 消息Gate 收到直接按 ActorId 投送Map 侧 Unit 照样用邮箱队列消化没有引入第二套机制。message Frame_ClickMap // IActorLocationMessage { int64 ActorId 93; // 目标 Entity.IdLocation 路由的依据 int64 Id 94; float X 1; float Y 2; float Z 3; }⚠️ 嵌套 RPC 死锁怎么破Run 里补一行协程MailboxComponent本质是消息队列同一时刻只处理一条Run返回的 ETTask 没完成队列就停在那条消息上。于是 A Call B、B Call C、C Call A 时三方队列互相卡住谁都无法推进——这不是 ET 特有的坑是顺序处理邮箱的 Actor 模型的固有属性。最小改动是把实际处理从当前消息上下文里摘出去[ActorMessageHandler(AppType.Map)] public class Actor_TestHandler : AMActorHandlerUnit, Actor_Test { protected override ETTask Run(Unit unit, Actor_Test message) { // 处理丢进新协程当前消息立即让出队列 RunAsync(unit, message).Coroutine(); } public ETVoid RunAsync(Unit unit, Actor_Test message) { Log.Debug(message.Info); } }代价要说清楚这条消息不再占用队列后续消息可能与它交叉执行顺序性让位于活性。哪些 Handler 必须严格串行、哪些可以放手得按业务判断而不是无脑全加协程。另一个过来人的提醒断线重连、玩家换 Gate 登录时Map 进程里缓存着旧 session InstanceId 的 actor sender 已经指向一个会投递失败的目标要主动 Remove 掉重建别等 5 次重试超时才发现问题。✅ 动手前必须知道的 3 件事附排查清单先挂邮箱再传 IdInstanceId 在MailboxComponent挂载瞬间才存在。登录链路里任何先引用后挂载的时序错误表现都是首条消息丢失。Run 内不做大对象分配、不长耗时一条消息卡住整个 Actor 的队列停摆。高频小消息优先合并批量处理单条 Run 里出现毫秒级逻辑就值得拆分。地址变了就清缓存ActorLocationSender缓存 InstanceId 是为了省查询但对象换 Gate、换线后旧缓存立即作废。重连流程里显式清理恢复速度远快于等重试链路自己收敛。排查清单消息发出无响应先确认目标挂了MailboxComponent且邮箱类型与职责匹配转发型用 GateSession分发型用 MessageDispatcher跨进程投递持续失败确认目标在 Location Server 注册过并检查是否恰好处于迁移锁窗口偶发整体卡死沿 Call 链找 A→B→C→A 的环给环上某个节点改成协程内处理Location Server 查询量异常高说明目标 Actor 迁移频繁回头审视是不是把不该迁移的对象也注册进了 Location 机制延伸阅读Book/5.4Actor Model.md、Book/5.5Actor Location-ZH.md【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考