深入剖析 ET8 框架的 Fiber 机制
引言:为什么从 Fiber 开始读 ET8
也是偶然间重新审视 ET(一款双端通用的游戏开发框架),惊艳于它 8.x 版本重构后的轻与净------客户端与服务端共享同一套代码,进程内又能同时跑着成百上千个彼此隔离的逻辑单元。按捺不住对源码的探索欲,便构思着分模块、按链路地去挖掘它的设计思路。这个系列剖析基于的源码版本是 ET release 8.1。
整个 ET8 的架构是层层递进的:Process → Fiber → Scene → Entity → Component → System。如果 Entity 是"细胞",那么 Fiber 就是"器官"------ETTask 的 await 靠它回家,Entity 树长在它的 Root Scene 上,Actor 消息穿过它的队列,网络回调落在它的信箱里。不先把 Fiber 读透,后面每一层都是空中楼阁,所以系列的第一篇,主抓 Fiber。
本文围绕四条完整链路展开:创建、调度、通信、销毁 。每条链路上的每一行核心源码都会完整呈现并逐行推演,以此揭示 ET8 如何用"队列串行化"替代"锁",如何在单线程语义下支撑高并发,以及如何在编译期硬性保证 Fiber 之间的隔离。文中所有代码均来自 Unity/Assets/Scripts/Core/ 目录,改动仅限排版。
设计概览:Fiber 的整体架构与组件图谱
Fiber 在进程中的位置
ET8 把一个进程拆成三层:
text
Process(进程,全局唯一进程号)
└── Fiber(纤程,进程内调度单元,有独立 Id)
└── Root Scene(根场景,一棵 Entity 树的根)
└── Entity / Component / 子 Scene...
为什么需要 Fiber 这一层?传统多线程的答案是"共享内存 + 锁",而 ET8 的答案是**"把线程变细,细到不需要锁":每个 Fiber 在逻辑上只有一个执行者(一个独占线程,或主线程上的一段独占时间片),它内部的全部代码都是单线程语义------不用锁、不用原子操作、不用考虑竞争;Fiber 之间则禁止共享引用**,一切协作通过消息完成。这本质上是一套运行时的 Actor 模型,Fiber 就是 Actor 的"居所"。
核心组件关系图谱
从代码结构来看,Fiber 机制的核心组件可归纳为五类:
- 管理层 :
FiberManager(单例)持有全进程 Fiber 注册表,负责创建与销毁,并按SchedulerType把 Fiber 分派给对应调度器。 - 调度层 :
IScheduler接口 + 三个实现------MainThreadScheduler(主线程轮转)、ThreadScheduler(一 Fiber 一线程)、ThreadPoolScheduler(M 线程跑 N Fiber)。 - 执行层 :
Fiber本体,携带 EntitySystem、Mailboxes、ThreadSynchronizationContext、Log 四大器官,以及 Root Scene。 - 信箱层 :
ThreadSynchronizationContext------每个 Fiber 一个的ConcurrentQueue<Action>,跨线程回调的唯一落地处,"单线程异步"的物理基础。 - 通信层 :进程级单例
MessageQueue(FiberId → 消息队列的字典)+ Fiber 级的ProcessInnerSender与Mailboxes,构成进程内跨 Fiber 的邮路。
四条链路一览
这些组件的协作可以概括为四条链路:
创建:FiberManager.Create → new Fiber(纯内存,不起线程)→ 注册进字典 → 调度器缓冲 → Post 初始化回调进 Fiber 自己的队列 → 下一帧心跳时执行初始化 → 返回 fiberId
心跳:进程主循环 → FiberManager.Update/LateUpdate → 调度器轮转 → Fiber.Instance = fiber + SetSynchronizationContext 立身份 → fiber.Update/LateUpdate → 排空 Fiber 信箱
传信:Call 分配 RpcId → 消息入 MessageQueue 中目标 Fiber 的队列 → 目标 Fiber 下一帧 Fetch(限量 1000)→ 响应走唤醒捷径 / 普通消息投给 MailBoxComponent → SetResult 唤醒发起方
销毁:Remove 把销毁动作 Post 进目标 Fiber 的队列 → Fiber 在自己线程上死去 → 调度器通过轮询观察到死亡 → 队列自然淘汰
链路一:诞生------FiberManager 是纤程世界的"户籍管理处"
入口在 Core/World/Module/Fiber/FiberManager.cs。Create 有两个重载,先看动态分配 Id 的:
csharp
public async ETTask<int> Create(SchedulerType schedulerType, int zone, SceneType sceneType, string name)
{
int fiberId = Interlocked.Increment(ref this.idGenerator);
return await this.Create(schedulerType, fiberId, zone, sceneType, name);
}
Interlocked.Increment:id 自增是整个进程内多线程并发创建 Fiber 时唯一的共享变量写操作,用一个原子指令保护,除此之外再无锁;idGenerator从 10000000 起步,源码注释写明了原因:10000000以下为保留的用于StartSceneConfig的fiber id, 1个区配置1000个纤程,可以配置10000个区------配置表里的静态 Fiber(各类进程 Scene)用一千万以下的小 Id,运行时动态创建用一千万以上的大 Id,两套编号互不冲突,各取所需。
进入四参主重载,逐行推演:
csharp
Fiber fiber = new(fiberId, zone, sceneType, name);
构造器位于 Core/Fiber/Fiber.cs,它是理解 Fiber "器官构成"的最佳入口:
csharp
internal Fiber(int id, int zone, SceneType sceneType, string name)
{
this.Id = id;
this.Zone = zone;
this.EntitySystem = new EntitySystem(); // 器官①:驱动本 Fiber 所有 System 的 Update
this.Mailboxes = new Mailboxes(); // 器官②:本 Fiber 内 Actor 的注册表
this.ThreadSynchronizationContext = new ThreadSynchronizationContext(); // 器官③:本 Fiber 的信箱
#if UNITY
this.Log = Logger.Instance.Log;
#else
this.Log = new NLogger(sceneType.ToString(), this.Process, this.Id); // 器官④:按"场景类型_进程号_FiberId"命名的日志
#endif
this.Root = new Scene(this, id, 1, sceneType, name); // 根 Scene,InstanceId 固定为 1
}
四个要点:
- 纯内存操作,不启动任何线程。Fiber 的"线程"不属于自己,是调度器借给它的------这是理解后文所有调度逻辑的前提;
- 构造器是
internal:Fiber 只能由 FiberManager 创建 ,业务代码连new都写不出来,隔离性从构造一刻就立下了; - 服务端分支
new NLogger(sceneType.ToString(), this.Process, this.Id)------一个进程几千个 Fiber,日志按进程号和 FiberId 命名,排障时能直接过滤出某个 Fiber 的完整生命周期; Root = new Scene(this, id, 1, ...):一个 Fiber 一棵 Entity 树,树根的实例 Id 固定为 1,Fiber 内部编号自洽,全局唯一性由Fiber.Id补齐。
回到 FiberManager.Create:
csharp
if (!this.fibers.TryAdd(fiberId, fiber))
{
throw new Exception($"same fiber already existed, if you remove, please await Remove then Create fiber! {fiberId}");
}
fibers 是 ConcurrentDictionary<int, Fiber>------创建与移除可能发生在不同线程。TryAdd 失败的报错文案本身就是一条使用规约:必须先 await Remove 完成,再 Create 同 Id 的 Fiber,因为此刻旧 Fiber 可能还活在某个线程里,同 Id 共存会引发消息错投。
csharp
this.schedulers[(int) schedulerType].Add(fiberId);
把 fiberId 交给调度器。此处注册的先后顺序颇有讲究:先注册进 fibers 字典,再交给调度器 ------顺序颠倒的话,调度器线程会 Get(fiberId) 拿到 null。而 MainThreadScheduler.Add 此时只是把 id 放进一个缓冲队列,Fiber 并不会立刻被执行,时序的精妙处后文展开。
接下来是整条创建链路的灵魂:
csharp
TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
fiber.ThreadSynchronizationContext.Post(async () =>
{
try
{
// 根据Fiber的SceneType分发Init,必须在Fiber线程中执行
await EventSystem.Instance.Invoke<FiberInit, ETTask>((long)sceneType, new FiberInit() {Fiber = fiber});
tcs.SetResult(true);
}
catch (Exception e)
{
Log.Error($"init fiber fail: {sceneType} {e}");
}
});
await tcs.Task;
return fiberId;
逐层拆解:
- 初始化不是直接调用,而是被 Post 进了这个 Fiber 自己的信箱 。原代码注释点明了原因------
必须在Fiber线程中执行。为什么?初始化要 new 一堆 Entity、挂一堆组件、await 网络请求,这些操作都依赖"当前线程 == 本 Fiber 的线程"这个前提(Fiber.Instance、Entity.IScene缓存、await 续体的上下文,全都是在 Fiber 线程内才成立的)。在哪个线程调用了Create并不重要------调用方可能是另一个 Fiber,可能是线程池------重要的是初始化必须发生在目标 Fiber 的"领地"之内; EventSystem.Instance.Invoke<FiberInit, ETTask>((long)sceneType, ...):按 SceneType 分发。FiberInit只是个包装结构(Core/World/Module/Fiber/FiberInit.cs全文 8 行:public struct FiberInit { public Fiber Fiber { get; set; } }),真正的初始化逻辑在热更层,每种 SceneType 注册一个[Invoke]处理器。看一个典型的,Hotfix/Share/FiberInit_Main.cs:
csharp
[Invoke((long)SceneType.Main)]
public class FiberInit_Main: AInvokeHandler<FiberInit, ETTask>
{
public override async ETTask Handle(FiberInit fiberInit)
{
Scene root = fiberInit.Fiber.Root;
await EventSystem.Instance.PublishAsync(root, new EntryEvent1());
await EventSystem.Instance.PublishAsync(root, new EntryEvent2());
await EventSystem.Instance.PublishAsync(root, new EntryEvent3());
}
}
Router、NetInner、Location、RoomRoot、Map......十几种 SceneType 各有自己的 FiberInit_* 处理器。Main 的初始化是往根 Scene 上发三轮事件 EntryEvent1/2/3------三轮启动事件是 ET 的约定,业务层各自订阅挂组件。注意处理器内部全是 await:加载配置、连数据库、起网络服务都在这些事件的处理器里异步完成;
tcs.SetResult(true)位于await完成之后------所以Create的返回时刻是"该 Fiber 的初始化异步逻辑全部跑完"的时刻,而不是"回调入队"的时刻。对调用方来说,拿到 fiberId 就意味着这个 Fiber 已经可以接客了;- 最外层
await tcs.Task:调用Create的可能是另一个 Fiber (Fiber 之间通过消息协作,"创建 Fiber"本身就是一条可异步等待的指令),TCS 把"跨线程的完成通知"桥接成 ETTask,整个Create呈现为一个可await的普通异步方法; - 注意异常路径:初始化失败只打日志、不 SetResult ------
await tcs.Task将永远挂起。这是刻意的 fail-stop:初始化失败的 Fiber 不允许"假装创建成功",上层只能靠超时感知,宁挂不骗。
链路的调度侧 :Post 进信箱的回调由谁执行?先把时序结论放在这:Main 调度模式下,新 Fiber 在第 N 帧 LateUpdate 才从缓冲并入正式轮转队列,第 N+1 帧 第一次被 Update,第 N+1 帧的 LateUpdate 才执行到信箱消费、跑起初始化回调。时序的正确性完全由"入队位置"保证------这是 ET8 调度手法里最值得品味的一笔,进入链路二详述。
链路二:心跳------调度器是纤程的"起搏器"
总开关与调度器装配
FiberManager 的单例每帧被进程主循环驱动(Unity 侧由 GameLoop 调用,服务端 DotNet/Loader/Init.cs 的主循环同样调用):
csharp
public void Update()
{
this.mainThreadScheduler.Update();
}
public void LateUpdate()
{
this.mainThreadScheduler.LateUpdate();
}
注意:只有主线程调度器有 Update/LateUpdate 入口 ------Thread 和 ThreadPool 调度器旗下的 Fiber 由各自线程驱动,无需外部推。三种调度器在 Awake() 里完成装配:
csharp
public void Awake()
{
this.mainThreadScheduler = new MainThreadScheduler(this);
this.schedulers[(int)SchedulerType.Main] = this.mainThreadScheduler;
#if ENABLE_VIEW && UNITY_EDITOR
this.schedulers[(int)SchedulerType.Thread] = this.mainThreadScheduler;
this.schedulers[(int)SchedulerType.ThreadPool] = this.mainThreadScheduler;
#else
this.schedulers[(int)SchedulerType.Thread] = new ThreadScheduler(this);
this.schedulers[(int)SchedulerType.ThreadPool] = new ThreadPoolScheduler(this);
#endif
}
ENABLE_VIEW 是 ET 的 Unity 运行时调试视图。开了它,Thread/ThreadPool 调度全部退化为主线程调度 ------多线程下调试视图无法安全遍历 Entity 树。一个 #if 换来调试期的线程安全,代价只是性能。这个取舍很能体现作者对"可调试性"的执念:调度策略被抽象成 IScheduler 接口(全文仅 9 行:void Add(int fiberId) + 继承 IDisposable),替换调度方式是纯配置式的。
MainThreadScheduler.Update:轮转五行,密度最高
Core/World/Module/Fiber/MainThreadScheduler.cs,Unity 客户端上所有 Fiber 都住在这 107 行里。
csharp
public void Update()
{
SynchronizationContext.SetSynchronizationContext(this.threadSynchronizationContext);
this.threadSynchronizationContext.Update();
帧首先办两件事:把调度器自己的 同步上下文设为当前线程上下文,然后排空调度器自己的信箱。谁会 Post 到这里?不隶属任何 Fiber 的主线程回调(Unity 引擎触发的、跨线程的兜底投递等)。先把"无主回调"清掉,再进 Fiber 轮转。
csharp
int count = this.idQueue.Count;
while (count-- > 0)
{
if (!this.idQueue.TryDequeue(out int id))
{
continue;
}
Fiber fiber = this.fiberManager.Get(id);
if (fiber == null)
{
continue;
}
if (fiber.IsDisposed)
{
continue;
}
快照轮转 :count 取循环开始那一刻的队列长度,本轮只执行快照内的 Fiber,执行完逐一塞回队尾------于是本帧新加入的 Fiber 不会插队,一帧之内每个存活的 Fiber 恰好被唤醒一次,数学上严格的公平。
Get(id) 返回 null(Fiber 已被 Remove)或 IsDisposed(Fiber 已自杀)时直接 continue------关键:这两个分支都不执行队尾的 Enqueue(id),死亡的 Fiber 出队后不再回队,从轮转里自然淘汰。消亡的清理不需要任何主动摘除动作,这个伏笔在链路四回收。
csharp
Fiber.Instance = fiber;
SynchronizationContext.SetSynchronizationContext(fiber.ThreadSynchronizationContext);
fiber.Update();
Fiber.Instance = null;
this.idQueue.Enqueue(id);
}
轮转体只有五行,是整个 ET8 线程模型密度最高的五行:
Fiber.Instance = fiber:Fiber类里那个[ThreadStatic] internal static Fiber Instance(Fiber.cs:19-21,挂着该字段只能框架使用,绝对不能改成public,改了后果自负的注释)此刻被赋值。从这一行到Fiber.Instance = null之间,当前线程就是该 Fiber 的专属线程 ,任何代码、任何调用深度下都能凭Fiber.Instance回答"我是谁";SetSynchronizationContext(fiber.ThreadSynchronizationContext):第二重身份 。SynchronizationContext是 .NET BCL 的钩子------当前线程一旦设置了这个上下文,本线程内所有await的续体都会被 BCL 自动Post回它。两重设置缺一不可:前者回答"我在哪个 Fiber 里",后者回答"await 之后回到哪里去";fiber.Update():执行该 Fiber 的本帧业务(下详);Fiber.Instance = null:用完即清。防御性写法------若有代码把 Fiber 上下文偷偷逃逸到帧外(比如把Fiber.Instance存进静态字段),null 能立刻暴露问题而不是静默错绑;Enqueue(id):活着才回队尾,与前面的死亡分支形成闭环。
csharp
// Fiber调度完成,要还原成默认的上下文,否则unity的回调会找不到正确的上下文
SynchronizationContext.SetSynchronizationContext(this.threadSynchronizationContext);
}
帧末还原。注释已自明:Unity 引擎自己的回调(UI 事件、协程、渲染回调)发生在 Fiber 轮转之外,它们看到的必须是调度器那个"无主"默认上下文。顺带一提,调度器的构造函数 里就做过一次同样的 SetSynchronizationContext------主线程一启动就被纳入 ET 的上下文体系,Update 开头的重复设置是每帧的防御性重申。
Fiber.Update / LateUpdate:帧内的三步节拍
Fiber.cs:
csharp
internal void Update()
{
try
{
this.EntitySystem.Update();
}
catch (Exception e)
{
this.Log.Error(e);
}
}
单线程不等于不出异常。一个 Fiber 的 Update 抛异常,只死它自己这一帧,try-catch 兜住、记入本 Fiber 的日志,调度器继续转下一个 Fiber------一个 Fiber 的 bug 不会拖垮整个进程。
csharp
internal void LateUpdate()
{
try
{
this.EntitySystem.LateUpdate();
FrameFinishUpdate();
this.ThreadSynchronizationContext.Update();
}
catch (Exception e)
{
Log.Error(e);
}
}
三步的顺序本身就是设计:先跑业务 LateUpdate,再唤醒等帧末的人,最后消费跨线程回调 。第三步是本链路的核心,主角是 Core/ThreadSynchronizationContext.cs------整个 Fiber 模型能成立的物理基础,全文 44 行,一行不落:
csharp
public class ThreadSynchronizationContext : SynchronizationContext
{
// 线程同步队列,发送接收socket回调都放到该队列,由poll线程统一执行
private readonly ConcurrentQueue<Action> queue = new();
private Action a;
public void Update()
{
while (true)
{
if (!this.queue.TryDequeue(out a))
{
return;
}
try
{
a();
}
catch (Exception e)
{
Log.Error(e);
}
}
}
public override void Post(SendOrPostCallback callback, object state)
{
this.Post(() => callback(state));
}
public void Post(Action action)
{
this.queue.Enqueue(action);
}
}
逐行推演:
- 队列是
ConcurrentQueue<Action>:入队方可以是任意线程 (网络线程、定时器线程、别的 Fiber),出队方永远只有 Fiber 自己的那一个线程。读写天然分离,这就是全框架无锁的物理基础------业务代码里找不到一把锁,因为队列本身就是锁的替代品; Post(SendOrPostCallback, object)这个 override 是 BCL 约定的入口------await的续体就是被 .NET 以这个签名回调进来的,它包一层 lambda 转成Action入队。"在 Fiber 线程里 await 网络请求,回调自动回到 Fiber 线程"并非 ET 的实现,而是 .NET SynchronizationContext 机制自带的,ET 只负责在每个 Fiber 心跳前把正确的上下文立起来;Update()的while(true)一次性排空:本帧的所有跨线程回调集中执行,互不穿插别的线程;- 每个回调单独 try-catch:一个坏回调记日志,后面的照常执行;
private Action a复用字段接出队结果,避免热循环反复分配。
夹在中间的小链路顺手讲完------FrameFinishUpdate:
csharp
public async ETTask WaitFrameFinish()
{
ETTask task = ETTask.Create(true);
this.frameFinishTasks.Enqueue(task);
await task;
}
private void FrameFinishUpdate()
{
while (this.frameFinishTasks.Count > 0)
{
ETTask task = this.frameFinishTasks.Dequeue();
task.SetResult();
}
}
WaitFrameFinish() 创建一个未完成的 ETTask 入队即挂起(ETTask.Create(true) 的 true 表示"不从池中复用",要跨帧存活);FrameFinishUpdate 在帧末统一 SetResult 唤醒。帧同步、渲染等待都建立在它上面------"等当前帧结束"五个字,四行实现。
Fiber.Instance 的消费端:Entity 如何一秒找到组织
调度器设置了 Fiber.Instance,业务侧如何消费?Core/Entity/EntityHelper.cs:25:
csharp
public static Fiber Fiber(this Entity entity)
{
return entity.IScene.Fiber;
}
注意:不是 Fiber.Instance------每个 Entity 在 Parent setter 里(Entity.cs:210-218)就已把所属 Scene 的引用缓存 了下来(自己是 Scene 则继承父的 Fiber 并把自己登记为 IScene,否则直接抄父的 IScene)。于是 entity.Fiber() 是 O(1) 的字段读取,O(1) 找到所属 Fiber,进而拿到 fiber.Log、fiber.Mailboxes、fiber.ThreadSynchronizationContext.Post(...) 等一切 Fiber 级设施。
Fiber.Instance 真正的用武之地是框架基础设施:调度器、FiberHelper.GetActorId(Fiber.cs:9-13,用 root.Process + root.Id + self.InstanceId 三元组拼出 ActorId 门牌号)、以及链路三将看到的跨 Fiber 发送入口。此处也可见 Fiber 上两个属性配件的完整含义:
csharp
public Address Address => new Address(this.Process, this.Id);
public int Process => Options.Instance.Process;
Address = 进程内地址(进程号 + FiberId),是 MessageQueue 寻址的键;Process 不存字段、直接读全局配置------同一进程内所有 Fiber 的进程号必然相同,没必要每个 Fiber 记一份冗余。
ThreadScheduler:一 Fiber 一线程的"专宠"
csharp
private void Loop(int fiberId)
{
Fiber fiber = fiberManager.Get(fiberId);
Fiber.Instance = fiber;
SynchronizationContext.SetSynchronizationContext(fiber.ThreadSynchronizationContext);
while (true)
{
if (this.fiberManager.IsDisposed())
{
return;
}
fiber = fiberManager.Get(fiberId);
if (fiber == null)
{
this.dictionary.Remove(fiberId, out _);
return;
}
if (fiber.IsDisposed)
{
this.dictionary.Remove(fiberId, out _);
return;
}
fiber.Update();
fiber.LateUpdate();
Thread.Sleep(1);
}
}
对比主线程调度,三个差异一目了然:
- 身份只设一次 :
Fiber.Instance与 SynchronizationContext 在进循环前设置,终身不换------这个线程从出生到死亡只服务这一个 Fiber,连"用完即清"都省了; - 三种退出条件每轮都查 :进程销毁 → 直接 return;Fiber 从字典消失(被 Remove)→ 清掉线程字典再 return;Fiber 自己 IsDisposed → 同上。消亡在线程侧是被观察到的,而不是被通知到的;
Thread.Sleep(1):空转让出时间片。没有消息的 Fiber 也要占着线程睡------所以这种调度只留给"重" Fiber,用一个线程换确定性的微秒级响应。
Add 与 Dispose 则简单直接:Add 即 new Thread(() => this.Loop(fiberId)) 启动;Dispose 对字典里所有线程 Join(),等它们跑完当前轮退出。
ThreadPoolScheduler:M 线程跑 N Fiber 的"动态配额"
csharp
public ThreadPoolScheduler(FiberManager fiberManager)
{
this.fiberManager = fiberManager;
int threadCount = Environment.ProcessorCount;
this.threads = new List<Thread>(threadCount);
for (int i = 0; i < threadCount; ++i)
{
Thread thread = new(this.Loop);
this.threads.Add(thread);
thread.Start();
}
}
构造时拉起 CPU 核数个线程,全部指向同一个 Loop。服务端默认形态:几百个轻 Fiber 共享核数个线程。
csharp
private void Loop()
{
int count = 0;
while (true)
{
if (count <= 0)
{
Thread.Sleep(1);
// count最小为1
count = this.fiberManager.Count() / this.threads.Count + 1;
}
--count;
动态配额 是这段的精髓:count = Fiber总数 / 线程数 + 1。
- Fiber 多于线程时整除加一:100 个 Fiber、8 个线程,每线程每轮执行 13 个,多出来的 4 个下一轮补位,长期均衡;
+1保证配额最小为 1:1 个 Fiber 时整除得 0,没有这个+1该线程将永远Sleep(1)空转,Fiber 饿死;- 配额每轮重新计算:Fiber 增删后自动再均衡,无中心调度、无负载上报。
csharp
if (this.fiberManager.IsDisposed())
{
return;
}
if (!this.idQueue.TryDequeue(out int id))
{
Thread.Sleep(1);
continue;
}
Fiber fiber = this.fiberManager.Get(id);
if (fiber == null)
{
continue;
}
if (fiber.IsDisposed)
{
continue;
}
Fiber.Instance = fiber;
SynchronizationContext.SetSynchronizationContext(fiber.ThreadSynchronizationContext);
fiber.Update();
fiber.LateUpdate();
SynchronizationContext.SetSynchronizationContext(null);
Fiber.Instance = null;
this.idQueue.Enqueue(id);
}
}
与主线程调度同构:同样的双设置、同样的"死亡 Fiber 出队不回队尾"自然淘汰。两个小区别:上下文被显式置 null 而非还原成调度器上下文(工作线程没有"无主默认"的概念,null 就是干净状态);队列空时 Sleep(1) 防忙等。
三种调度器一张表收束:
| 调度器 | 形态 | 适用场景 | 核心机制 |
|---|---|---|---|
| MainThread | N Fiber 共享主线程 | Unity 客户端 | 每帧快照轮转 |
| Thread | 1 Fiber 独占 1 线程 | 服务端重 Fiber | 死循环 + Sleep(1) |
| ThreadPool | N Fiber 共享 M = CPU 核数线程 | 服务端大量轻 Fiber | 动态配额轮转 |
链路三:传信------MessageQueue 是进程内的"邮政系统"
这是四条链路中最长的一条,涉及 ProcessInnerSenderSystem.cs(187 行,全讲)、MessageQueue.cs(68 行,全讲)、MailBoxComponent.cs(47 行,全讲)。
先立一个层次认知,它是这条链路的"魂":进程级 MessageQueue 管"发到哪个 Fiber",Fiber 级 Mailboxes 管"发给哪个 Actor"。前者是跨线程设施,必须并发容器;后者只在 Fiber 内部使用,普通 Dictionary 即可。两级寻址,各安其位。
场景设定:Realm Fiber 里的玩家请求要同步数据,调了 ProcessInnerSender.Call(locationActorId, request)。ProcessInnerSender 是每个 Fiber 根 Scene 上挂的组件------本 Fiber 对进程内其他 Fiber 的唯一合法嘴。它的生命周期直接决定收件地址的生效与失效:
csharp
[EntitySystem]
private static void Awake(this ProcessInnerSender self)
{
Fiber fiber = self.Fiber();
MessageQueue.Instance.AddQueue(fiber.Id);
}
[EntitySystem]
private static void Destroy(this ProcessInnerSender self)
{
Fiber fiber = self.Fiber();
MessageQueue.Instance.RemoveQueue(fiber.Id);
}
组件一挂上,AddQueue 让本 Fiber 在进程级邮政系统中拥有收件格口;组件一销毁,RemoveQueue 即刻销户------这正是后文 Send 里 TryGetValue 可能失败的机制根源。
发送:Call 的逐行推演
csharp
public static async ETTask<IResponse> Call(
this ProcessInnerSender self,
ActorId actorId,
IRequest request,
bool needException = true
)
{
int rpcId = self.GetRpcId();
request.RpcId = rpcId;
csharp
private static int GetRpcId(this ProcessInnerSender self)
{
return ++self.RpcId;
}
每个 Fiber 一份自增 RpcId(单线程自增,无需原子)。RpcId 是本次 RPC 的会话编号,盖章在请求消息上,响应回来时凭它认领。
csharp
if (actorId == default)
{
throw new Exception($"actor id is 0: {request}");
}
Fiber fiber = self.Fiber();
if (fiber.Process != actorId.Process)
{
throw new Exception($"actor inner process diff: {actorId.Process} {fiber.Process}");
}
两道防线:actorId == default(0 号门牌是非法的);ProcessInnerSender 只管进程内 通信,跨进程走网络组件(NetInner),混用直接抛异常------进程边界由类型系统把守,而不是靠约定俗成。
csharp
Type requestType = request.GetType();
IResponse response;
if (!self.SendInner(actorId, (MessageObject)request)) // 纤程不存在
{
response = MessageHelper.CreateResponse(requestType, rpcId, ErrorCore.ERR_NotFoundActor);
return response;
}
SendInner 返回 false 表示目标 Fiber 不存在 (未创建或已销毁),不等超时,当场构造 ERR_NotFoundActor 错误响应返回------快速失败。
csharp
MessageSenderStruct messageSenderStruct = new(actorId, requestType, needException);
self.requestCallback.Add(rpcId, messageSenderStruct);
登记等待表:key 是 RpcId,value 是包装了 ETTask completion 的 MessageSenderStruct。此刻请求已发出,先把"等人回话"的位子占好,响应到达时凭 RpcId 来这认领。
csharp
async ETTask Timeout()
{
await fiber.Root.GetComponent<TimerComponent>().WaitAsync(ProcessInnerSender.TIMEOUT_TIME);
if (!self.requestCallback.Remove(rpcId, out MessageSenderStruct action))
{
return;
}
if (needException)
{
action.SetException(new Exception($"actor sender timeout: {requestType.FullName}"));
}
else
{
IResponse response = MessageHelper.CreateResponse(requestType, rpcId, ErrorCore.ERR_Timeout);
action.SetResult(response);
}
}
Timeout().Coroutine();
超时守护者,独立的一条协程:定时器到点后去等待表摘 RpcId------摘得掉 说明响应始终没来,按 needException 决定抛异常还是回超时响应;摘不掉 (Remove 返回 false)说明响应已被 3.6 摘除,守护者静默退出。用"摘不摘得掉"这一下原子判断裁决响应与超时的竞速,全程无锁。
csharp
long beginTime = TimeInfo.Instance.ServerFrameTime();
response = await messageSenderStruct.Wait();
long endTime = TimeInfo.Instance.ServerFrameTime();
long costTime = endTime - beginTime;
if (costTime > 200)
{
Log.Warning($"actor rpc time > 200: {costTime} {requestType.FullName}");
}
return response;
}
挂起、被唤醒(怎么唤醒看后文)。超过 200ms 打警告------Fiber 间 RPC 本该是微秒级,200ms 意味着对端 Fiber 忙死或消息拥塞,这是线上排查死锁与风暴的第一线索。
消息落地:SendInner 与 MessageQueue
csharp
private static bool SendInner(this ProcessInnerSender self, ActorId actorId, MessageObject message)
{
Fiber fiber = self.Fiber();
// 如果发向同一个进程,则扔到消息队列中
if (actorId.Process != fiber.Process)
{
throw new Exception($"actor inner process diff: {actorId.Process} {fiber.Process}");
}
return MessageQueue.Instance.Send(fiber.Address, actorId, message);
}
Core/World/Module/Actor/MessageQueue.cs,进程级单例,全文逐行:
csharp
public class MessageQueue: Singleton<MessageQueue>, ISingletonAwake
{
private readonly ConcurrentDictionary<int, ConcurrentQueue<MessageInfo>> messages = new();
外层字典 key 是 FiberId,value 是该 Fiber 的收件队列。一个进程所有 Fiber 的进程内邮件都收在这一个字典里------它是整个框架中被多线程同时读写最频繁的基础设施,所以两层都是并发容器。
csharp
public bool Send(ActorId actorId, MessageObject messageObject)
{
return this.Send(actorId.Address, actorId, messageObject);
}
public void Reply(ActorId actorId, MessageObject messageObject)
{
this.Send(actorId.Address, actorId, messageObject);
}
public bool Send(Address fromAddress, ActorId actorId, MessageObject messageObject)
{
if (!this.messages.TryGetValue(actorId.Address.Fiber, out var queue))
{
return false;
}
queue.Enqueue(new MessageInfo() {ActorId = new ActorId(fromAddress, actorId.InstanceId), MessageObject = messageObject});
return true;
}
TryGetValue失败 → 目标 Fiber 不存在 → 返回 false(就是 Call 里快速失败的来源);- 入队的
ActorId被重写 为new ActorId(fromAddress, actorId.InstanceId):接收方看到的"发件人"是本 Fiber 的 Address + 目标 Entity 的 InstanceId------回件地址在这里一次性铸好; Reply不回传 bool:回件丢了就丢了(对端可能已销毁),不值得为它抛异常。
csharp
public void Fetch(int fiberId, int count, List<MessageInfo> list)
{
if (!this.messages.TryGetValue(fiberId, out var queue))
{
return;
}
for (int i = 0; i < count; ++i)
{
if (!queue.TryDequeue(out MessageInfo message))
{
break;
}
list.Add(message);
}
}
Fetch 由收件 Fiber 自己 在心跳里调用:最多取 count 条进自己的 list。限量就是流控(调用点传的是 1000,见下)。
csharp
public void AddQueue(int fiberId)
{
var queue = new ConcurrentQueue<MessageInfo>();
this.messages[fiberId] = queue;
}
public void RemoveQueue(int fiberId)
{
this.messages.TryRemove(fiberId, out _);
}
}
接收:每帧开箱,限量一千
csharp
[EntitySystem]
private static void Update(this ProcessInnerSender self)
{
self.list.Clear();
Fiber fiber = self.Fiber();
MessageQueue.Instance.Fetch(fiber.Id, 1000, self.list);
foreach (MessageInfo actorMessageInfo in self.list)
{
self.HandleMessage(fiber, actorMessageInfo);
}
}
Fetch(fiber.Id, 1000, ...):每帧最多处理 1000 条。一个 Fiber 被消息风暴淹没时,不会把整帧耗死在收信上------剩下的下一帧再说,同线程的其他 Fiber 饿不死。1000 这个数字不大不小:足以摊薄取队列的固定开销,又守住了帧时长;- 复用
self.list缓冲,零分配。
分拣:HandleMessage 的两刀
csharp
private static void HandleMessage(this ProcessInnerSender self, Fiber fiber, in MessageInfo messageInfo)
{
if (messageInfo.MessageObject is IResponse response)
{
self.HandleIActorResponse(response);
return;
}
第一刀按消息类型分流:响应(IResponse)不进 Mailbox ,直接走 RPC 唤醒的捷径。in MessageInfo 修饰符按只读引用传递,避免结构体拷贝。
csharp
ActorId actorId = messageInfo.ActorId;
MessageObject message = messageInfo.MessageObject;
MailBoxComponent mailBoxComponent = self.Fiber().Mailboxes.Get(actorId.InstanceId);
if (mailBoxComponent == null)
{
Log.Warning($"actor not found mailbox, from: {actorId} current: {fiber.Address} {message}");
if (message is IRequest request)
{
IResponse resp = MessageHelper.CreateResponse(request.GetType(), request.RpcId, ErrorCore.ERR_NotFoundActor);
self.Reply(actorId.Address, resp);
}
return;
}
mailBoxComponent.Add(actorId.Address, message);
}
第二刀按目标 Entity 投递:用消息里的 InstanceId 去本 Fiber 的 Mailboxes 注册表找邮箱。找不到说明目标 Entity 已销毁------不能静默丢弃:若这是一条请求(IRequest),必须回一个 ERR_NotFoundActor 错误响应,否则发送方会干等到超时。请求-响应协议的礼貌:每个请求都配得上一个响应,哪怕是报错。
邮箱与分发:Mailbox 是地址牌,不是仓库
Core/Fiber/MailBoxComponent.cs + Mailboxes.cs。Mailboxes 是 Fiber 器官之一(构造器里 new Mailboxes()),Fiber 级的 Actor 注册表,全文:
csharp
public class Mailboxes
{
private readonly Dictionary<long, MailBoxComponent> mailboxes = new();
public void Add(MailBoxComponent mailBoxComponent)
{
this.mailboxes.Add(mailBoxComponent.Parent.InstanceId, mailBoxComponent);
}
public void Remove(long instanceId)
{
this.mailboxes.Remove(instanceId);
}
public MailBoxComponent Get(long instanceId)
{
this.mailboxes.TryGetValue(instanceId, out MailBoxComponent mailBoxComponent);
return mailBoxComponent;
}
}
普通 Dictionary 即可------它在 Fiber 内部使用,单线程语义,无需并发容器。这正是两级寻址的佐证:同样的注册表逻辑,放在进程级就必须付出 ConcurrentDictionary 的代价,放在 Fiber 级就退回最简单的容器。隔离得越彻底,能用的数据结构越简单,这是 Fiber 模型贯穿始终的红利。
注册时机在 MailBoxComponent 的 Awake:
csharp
[EntitySystem]
private static void Awake(this MailBoxComponent self, MailBoxType mailBoxType)
{
Fiber fiber = self.Fiber();
self.MailBoxType = mailBoxType;
self.ParentInstanceId = self.Parent.InstanceId;
fiber.Mailboxes.Add(self);
}
MailBoxComponent 挂到哪个 Entity 上,哪个 Entity 就是 Actor------类上的注释原文:挂上这个组件表示该Entity是一个Actor,接收的消息将会队列处理。挂组件即注册地址,self.Parent.InstanceId 就是这个 Actor 的门牌号。
投递入口:
csharp
public static void Add(this MailBoxComponent self, Address fromAddress, MessageObject messageObject)
{
// 根据mailboxType进行分发处理
EventSystem.Instance.Invoke((long)self.MailBoxType, new MailBoxInvoker()
{MailBoxComponent = self, MessageObject = messageObject, FromAddress = fromAddress});
}
邮箱本身不存消息 ------它是地址牌 + 调度策略:MailBoxType(Ordered 有序 / UnOrder 无序 / Gate 网关)决定消息进哪个处理器执行,消息的真正排队发生在处理器内部(协程锁排队)。那是 Actor 模型篇的主战场,本文收住,只记住这个论断。
销毁对称:
csharp
[EntitySystem]
private static void Destroy(this MailBoxComponent self)
{
self.Fiber().Mailboxes.Remove(self.ParentInstanceId);
}
Actor 实体一销毁,地址即刻失效,后续消息落入"找不到邮箱"分支。
唤醒:响应如何回到 await
响应消息经分拣的第一刀进入:
csharp
private static void HandleIActorResponse(this ProcessInnerSender self, IResponse response)
{
if (!self.requestCallback.Remove(response.RpcId, out MessageSenderStruct actorMessageSender))
{
return;
}
Run(actorMessageSender, response);
}
Remove 一把摘掉等待表:摘得掉,正常处理;摘不掉------超时守护者已经先动手,响应作废。同一个 RpcId 在"响应"与"超时"之间只会被成功摘一次,ConcurrentDictionary.Remove 的原子性替框架完成了互斥。
csharp
private static void Run(MessageSenderStruct self, IResponse response)
{
if (response.Error == ErrorCore.ERR_MessageTimeout)
{
self.SetException(new RpcException(response.Error,
$"Rpc error: request, 注意Actor消息超时,请注意查看是否死锁或者没有reply: ..."));
return;
}
if (self.NeedException && ErrorCore.IsRpcNeedThrowException(response.Error))
{
self.SetException(new RpcException(response.Error, $"Rpc error: ..."));
return;
}
self.SetResult(response);
}
三分支:对端明确报超时(ERR_MessageTimeout,典型死因------对端 Fiber 死锁或处理完忘了 Reply)→ 抛 RpcException,错误信息直接附排查指引;业务错误码且调用方选择抛异常 → 抛 RpcException;其余 → SetResult 正常唤醒 Call 里 await messageSenderStruct.Wait() 的调用方。
最后补一个让整条链路闭环的细节------响应是怎么"寄回"的?看 Reply:
csharp
public static void Reply(this ProcessInnerSender self, Address fromAddress, IResponse message)
{
self.SendInner(new ActorId(fromAddress, 0), (MessageObject)message);
}
注意 new ActorId(fromAddress, 0):InstanceId 为 0------响应不寄给任何具体 Actor,而是寄给对端 Fiber 的"邮局"(ProcessInnerSender 组件本身)。它抵达对端后,在 HandleMessage 里要么凭 IResponse 第一刀被截走,要么按 InstanceId=0 找不到任何 Mailbox------两条路都到不了业务 Actor,只能回到 RPC 等待表。投递目标与分流逻辑严丝合缝,这个 0 是整套 RPC 协议最不起眼也最关键的一位。
唤醒的续体跑在哪?回看 Call:await 挂起点在发起 Fiber 的线程上,BCL 把续体 Post 回了发起 Fiber 的 ThreadSynchronizationContext------下帧心跳时执行。RPC 的调用方与响应处理在同一个 Fiber 内串行,全程无锁、无线程切换的认知负担。
链路四:消亡------Remove 与 Create 的镜像对称
csharp
public async ETTask Remove(int id)
{
Fiber fiber = this.Get(id);
TaskCompletionSource<bool> tcs = new();
// 要扔到fiber线程执行,否则会出现线程竞争
fiber.ThreadSynchronizationContext.Post(() =>
{
if (this.fibers.Remove(id, out Fiber f))
{
f.Dispose();
}
tcs.SetResult(true);
});
await tcs.Task;
}
与创建完全镜像 :创建是"Post 初始化进去",销毁是"Post 销毁进去";注释再次点题------要扔到fiber线程执行,否则会出现线程竞争。此刻目标 Fiber 可能正在本帧心跳里处理消息、遍历 Entity,从别的线程直接摘字典 + Dispose 就是教科书式的竞争现场。让它死在自己线程上:销毁动作排进它的信箱,自然落在本帧所有业务之后。await tcs.Task 则给调用方一个确定性的"死透了"信号------与 Create 报错文案里 please await Remove then Create 的规约首尾呼应。
Fiber.Dispose 本体反而简单:
csharp
public void Dispose()
{
if (this.IsDisposed)
{
return;
}
this.IsDisposed = true;
this.Root.Dispose();
}
幂等保护 + 置标志 + 从根 Scene 级联销毁整棵 Entity 树(树的递归销毁属于 Entity 篇,此处只强调顺序:IsDisposed = true 先行,后到的消息撞上各组件的销毁状态时能感知)。
调度侧的善后是"被动的",回看链路二已讲过的代码,此刻拼上了:
MainThreadScheduler:死亡 Fiber 出队后不再回队尾,轮转队列自然淘汰;ThreadScheduler.Loop的三种退出检查 /ThreadPoolScheduler.Loop的两种:线程观察到 Fiber 死亡后自行清字典、自行结束;ProcessInnerSender.Destroy:RemoveQueue注销收件格口,邮政系统同步销户。
没有任何一个调度器需要被"通知"------死亡信息通过共享字典 + 每轮轮询传播,简单粗暴,但在"Fiber 死亡是低频事件"的前提下足够优雅。
最后进程级收尾,FiberManager.Destroy(ISingletonReverseDispose 标记保证它先于依赖者销毁,进程关闭时先断粮再拆灶):
csharp
protected override void Destroy()
{
foreach (IScheduler scheduler in this.schedulers)
{
scheduler.Dispose();
}
foreach (var kv in this.fibers)
{
kv.Value.Dispose();
}
this.fibers = null;
}
先停调度器(线程 Join,等它们跑完当前轮退出),再销毁残留的 Fiber,最后字典置空。顺序即依赖关系:调度器跑着的时候 Fiber 字典必须有效。
总结:ET8 Fiber 机制的架构、实现与价值
总结回顾以上,ET8 的 Fiber 机制是以"串行化消歧、身份双设置、编译期隔离 "为三根支柱,通过 FiberManager 统一管理 Fiber 的全生命周期,借 MainThreadScheduler / ThreadScheduler / ThreadPoolScheduler 三种调度策略把 N 个纤程映射到 M 个线程,依托 ThreadSynchronizationContext 信箱将所有跨线程来源统一排队、按序消费,再配合 MessageQueue 与 MailBoxComponent 的两级寻址构建进程内无锁邮路,最终以"Post 到自己线程再执行"的对称手法贯通创建与销毁------用队列替代了锁,用时间片替代了线程,用 internal 替代了约定,把一个进程切成成百上千个彼此隔离、单线程语义的逻辑单元,为上层一切皆 Entity 与 Actor 模型提供了坚实的运行时地基。
Fiber 读透了,下一篇《ETTask:从零造一个可 await 的协程》,继续沿"挂起 → 状态机 → 续体回信箱"的链路,解剖 await 背后的一切。