深入剖析 ET8 框架的 Fiber 机制:一个纤程的诞生、心跳、传信与消亡

深入剖析 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 背后的一切。

相关推荐
无毁的湖光Al2 小时前
解Bug之路-隔离故障点后业务不恢复?-热点Key处理与亚稳态故障
后端
Wx-bishekaifayuan2 小时前
springboot公考微信小程序35511-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
徐小黑ACG2 小时前
Golang goroutine、通道、锁
开发语言·后端·golang
ThinkerQAQ_2 小时前
并发编程(1.5):操作系统层——Thread、Scheduler 与 Runtime 调度
后端
SimonKing2 小时前
零大模型依赖!SQLite FTS5 + jieba 打造纯本地知识库搜索
后端·python·程序员
十年Java程序媛2 小时前
Java 接口和抽象类对比|抛弃老旧八股,正确选型
java·开发语言·spring boot·后端
Eyfcom3 小时前
快递管理系统中的电子围栏实战:高德与谷歌双平台技术选型与实现
java·后端·快递管理系统
Rain的Java大神之路3 小时前
Java PDF导出,90%的人选错库!11个生产坑+选型+代码,一次讲透
java·后端·面试
MacroZheng3 小时前
这可能是Windows下最适合Claude Code/Codex的终端了,真的夯!
人工智能·后端·ai编程