深入剖析 ET8 框架的 ETTask 机制:await 的挂起、唤醒、异常与零 GC

深入剖析 ET8 框架的 ETTask 机制

引言:Fiber 管"回哪个线程",ETTask 管"回去干什么"

上一篇把 Fiber 读透了,留下了一个反复出现、却始终没有展开的承诺:Fiber 线程里的 await,续体经由 SynchronizationContext 回到 Fiber 的队列------"回家"的路是 Fiber 修的,但回家之后要执行什么,是 ETTask 的事。

.NET 自带 Task 与 async/await,ET 为什么还要自己造一个 ETTask?读完本篇的源码,答案会具体到三个数字:

  1. 两次分配变零次 :.NET 的 async Task 方法默认要为状态机装箱、为 Task 对象分配内存;ET 用两个对象池(ETTask 池上限 1000、状态机包装池上限 100)把这两次分配都吃掉了;
  2. 3 个状态、1 个字段 :ETTask 的全部状态机就是 Pending / Succeeded / Faulted 三态枚举,承载续体的 callback 字段一物两用(挂起时存续体、失败时存异常),没有等待队列、没有锁;
  3. 1 个静态兜底 :ETTask.ExceptionHandler,框架启动时挂上 Log.Error(Loader/MonoBehaviour/Init.cs:31),没人认领的异常最终都打到日志里------协程世界没有"未观察异常"的沉默爆炸。

本文围绕四个问题展开:一个 async ETTask 方法出生时发生了什么?await 怎么挂起?SetResult 怎么唤醒?异常去了哪里? 所有代码来自 Unity/Assets/Scripts/ThirdParty/ETTask/,改动仅限排版。

阅读约定 :与第一篇相同,完整类源码中的区块注释(// ─── xxx ───)为阅读添加,行号与 release 8.1 一致。

设计概览:组件图谱与四条链路

ETTask 在 await 全景中的位置

一次真实的 await 涉及三个角色,ETTask 只占其一:

text 复制代码
编译器:把 async 方法切成状态机(IAsyncStateMachine),
        挂上 [AsyncMethodBuilder] 指定的构建器
   │
   ▼
ETTask:状态机的"锚"------存状态、存续体(callback),
        SetResult 时 Invoke 续体让状态机接着跑
   │
   ▼
ThreadSynchronizationContext(第一篇):续体的"路"------
        真正的 IO/定时器完成时,把 SetResult 的调用送回 Fiber 线程

一句话:ETTask 管"回去干什么",Fiber 的同步上下文管"回哪个线程"。本篇讲前者,结尾与第一篇合龙。

核心组件图谱

  • 任务本体 :ETTask / ETTask<T>------可 await 的对象,自等待器(自己就是自己的 GetAwaiter())。
  • 状态 :AwaiterStatus 三态枚举(IAwaiter.cs,全文 14 行)。
  • 编译器合同 :ETAsyncTaskMethodBuilder / ETAsyncTaskMethodBuilder<T> / AsyncETVoidMethodBuilder / AsyncETTaskCompletedMethodBuilder------[AsyncMethodBuilder] 特性指定的 8 成员构建器。
  • 状态机包装 :StateMachineWrap<T>------把编译器生成的结构体状态机包成 Action(MoveNext),独立池化。
  • 组合原语 :ETTaskHelper.WaitAny / WaitAll;同步直通 :ETTaskCompleted(await 它零成本)。

四条链路一览

诞生:调用 async ETTask 方法 → 编译器生成的状态机创建 → builder.Create() 从池取一个 ETTask → builder.Start 同步跑到第一个真 await

挂起:GetAwaiter() 返回自己 → IsCompleted 为 false → UnsafeOnCompleted 把续体存进 callback → builder 把状态机包成 MoveNext 一并存入

唤醒:SetResult 翻转状态 → callback.Invoke()(就是 MoveNext)→ 状态机续跑到方法末尾 → builder 回收状态机包装、给外层 ETTask SetResult → 递归唤醒整条 await 链

异常:SetException 把 callback 换成 ExceptionDispatchInfo → 唤醒后 GetResult 里 Throw()(保留原始堆栈)→ 无人 await 的异常交给 ExceptionHandler

全文涉及 7 个类文件、约 860 行源码,全部完整呈现。


前置知识:await 的四步合同与状态机

在拆 ETTask 之前,先把两块背景板立起来。第一块是 C# 的 await 模式:一个类型能被 await,编译器只要求它提供四样东西------GetAwaiter() 返回等待器、等待器上有 IsCompleted、OnCompleted/UnsafeOnCompleted、GetResult()。ETTask 四样俱全,且 GetAwaiter 返回 this------自己就是自己的等待器 (ETTask.cs:80-84)。

第二块是编译器对 async 方法的处理:方法体被切成状态机(实现 IAsyncStateMachine 接口),方法开头变成"建 builder → 建状态机 → builder.Start"。状态机的类型上挂着 [AsyncMethodBuilder(typeof(ETAsyncTaskMethodBuilder))](ETTask.cs:10),编译器照此契约生成调用。这就是本篇四类 builder 的存在意义------它们是编译器与 ETTask 之间的合同。


链路一:诞生------一个 async 方法的第一次分配

ETTask 全貌

源码位于 ThirdParty/ETTask/ETTask.cs,全文件 313 行,含 ETTask 与 ETTask<T> 两个类。后者是前者的泛型孪生(多一个 value 字段),结构完全同构------我们逐行解剖非泛型版,泛型版的差异在行内标注。

csharp 复制代码
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.ExceptionServices;

namespace ET
{
    // ─── 编译器合同:这个类可以被 async 方法返回 ───
    [AsyncMethodBuilder(typeof (ETAsyncTaskMethodBuilder))]
    public class ETTask: ICriticalNotifyCompletion
    {
        // ─── 无人认领异常的兜底(启动时挂 Log.Error) ───
        public static Action<Exception> ExceptionHandler;

        // ─── 同步直通货:await 它零成本 ───
        public static ETTaskCompleted CompletedTask
        {
            get
            {
                return new ETTaskCompleted();
            }
        }

        // ─── 对象池(上限1000) ───
        private static readonly ConcurrentQueue<ETTask> queue = new();

        /// <summary>
        /// 请不要随便使用ETTask的对象池,除非你完全搞懂了ETTask!!!
        /// 假如开启了池,await之后不能再操作ETTask,否则可能操作到再次从池中分配出来的ETTask,产生灾难性的后果
        /// SetResult的时候请现将tcs置空,避免多次对同一个ETTask SetResult
        /// </summary>
        public static ETTask Create(bool fromPool = false)
        {
            if (!fromPool)
            {
                return new ETTask();
            }
            if (!queue.TryDequeue(out ETTask task))
            {
                return new ETTask() {fromPool = true}; 
            }
            return task;
        }

        // ─── 回收:重置状态后回池,池满即弃 ───
        private void Recycle()
        {
            if (!this.fromPool)
            {
                return;
            }
            
            this.state = AwaiterStatus.Pending;
            this.callback = null;
            // 太多了
            if (queue.Count > 1000)
            {
                return;
            }
            queue.Enqueue(this);
        }

        // ─── 三个字段撑起全部状态 ───
        private bool fromPool;
        private AwaiterStatus state;
        private object callback; // Action or ExceptionDispatchInfo

        private ETTask()
        {
        }

        // ─── Coroutine:忘 await 保护 ───
        [DebuggerHidden]
        private async ETVoid InnerCoroutine()
        {
            await this;
        }

        [DebuggerHidden]
        public void Coroutine()
        {
            InnerCoroutine().Coroutine();
        }

        // ─── await 四步合同 ───
        [DebuggerHidden]
        public ETTask GetAwaiter()
        {
            return this;
        }

        public bool IsCompleted
        {
            [DebuggerHidden]
            get
            {
                return this.state != AwaiterStatus.Pending;
            }
        }

        [DebuggerHidden]
        public void UnsafeOnCompleted(Action action)
        {
            if (this.state != AwaiterStatus.Pending)
            {
                action?.Invoke();
                return;
            }

            this.callback = action;
        }

        [DebuggerHidden]
        public void OnCompleted(Action action)
        {
            this.UnsafeOnCompleted(action);
        }

        [DebuggerHidden]
        public void GetResult()
        {
            switch (this.state)
            {
                case AwaiterStatus.Succeeded:
                    this.Recycle();
                    break;
                case AwaiterStatus.Faulted:
                    ExceptionDispatchInfo c = this.callback as ExceptionDispatchInfo;
                    this.callback = null;
                    this.Recycle();
                    c?.Throw();
                    break;
                default:
                    throw new NotSupportedException("ETTask does not allow call GetResult directly when task not completed. Please use 'await'.");
            }
        }

        // ─── 完成端:谁 SetResult,谁唤醒 ───
        [DebuggerHidden]
        public void SetResult()
        {
            if (this.state != AwaiterStatus.Pending)
            {
                throw new InvalidOperationException("TaskT_TransitionToFinal_AlreadyCompleted");
            }

            this.state = AwaiterStatus.Succeeded;

            Action c = this.callback as Action;
            this.callback = null;
            c?.Invoke();
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        [DebuggerHidden]
        public void SetException(Exception e)
        {
            if (this.state != AwaiterStatus.Pending)
            {
                throw new InvalidOperationException("TaskT_TransitionToFinal_AlreadyCompleted");
            }

            this.state = AwaiterStatus.Faulted;

            Action c = this.callback as Action;
            this.callback = ExceptionDispatchInfo.Capture(e);
            c?.Invoke();
        }
    }

    // ─── 泛型孪生:多一个 value 字段,结构同构 ───
    [AsyncMethodBuilder(typeof (ETAsyncTaskMethodBuilder<>))]
    public class ETTask<T>: ICriticalNotifyCompletion
    {
        // ... Create/Recycle/GetAwaiter/GetResult/IsCompleted/OnCompleted/SetResult/SetException
        // 与 ETTask 逐行对应,差异仅在:
        //   字段多一个 private T value;
        //   GetResult 的 Succeeded 分支:T v = this.value; this.Recycle(); return v;
        //   SetResult 带参数:this.value = result;
    }
}

三个字段(ETTask.cs:60-62)撑起全部状态:fromPool(池化标记)、state(三态枚举)、callback(一物两用 :挂起时是续体 Action,失败时是 ExceptionDispatchInfo------第 62 行的注释写明了这件事)。构造函数是 private,实例只能经由 Create 诞生。

Create 与池化纪律

Create(bool fromPool = false)(第 30-41 行)是本篇第一个关键决策点:

  • fromPool = false(默认):直接 new,用完即弃------简单,但每次分配;
  • fromPool = true:先 TryDequeue 取池中实例;池空则 new 并打上 fromPool = true 标记。注意:从池中取出的实例标记早在入池前就打好了 ,所以 Recycle 时不需要重新判断。

第 25-29 行那段警告注释是池化的"使用许可证",值得逐句读:

假如开启了池,await 之后不能再操作 ETTask,否则可能操作到再次从池中分配出来的ETTask,产生灾难性的后果

意思是:Recycle 发生在 GetResult 里(链路三会看到)------await 一返回,这个 ETTask 可能已经被下一个 Create(true) 领走复用。你还攥着旧引用去 SetResult,就是在别人的任务上点火。这个纪律的直接推论是:ETTask 是"一次性契约" ------一个实例一生只服务一次 await,完成后立即回到池中待命。这也是 UnsafeOnCompleted 用 = 而不是 += 的原因(链路二展开)。

Recycle(第 43-58 行)的三步:非池化直接返回;重置 state = Pending、callback = null(两个清空的顺序有讲究------先清状态再入队,避免"重置了一半就被领走"的窗口);池满 1000 直接丢弃(第 52-56 行注释"太多了")------对象池的通用防线:回收速度超过消耗速度时,宁可丢掉也不让池无限膨胀。

泛型版 ETTask<T> 的 Recycle 多一步 this.value = default(第 196 行),防止引用类型结果常驻内存。

builder.Create:每个 async 方法的第一跳

现在看编译器合同如何消费 Create。ETAsyncTaskMethodBuilder(AsyncETTaskMethodBuilder.cs:8-79)是 struct------它本身不分配内存:

csharp 复制代码
public struct ETAsyncTaskMethodBuilder
{
    private IStateMachineWrap iStateMachineWrap;
    private ETTask tcs;

    [DebuggerHidden]
    public static ETAsyncTaskMethodBuilder Create()
    {
        ETAsyncTaskMethodBuilder builder = new() { tcs = ETTask.Create(true) };
        return builder;
    }

    [DebuggerHidden]
    public ETTask Task => this.tcs;
    // ... SetException / SetResult / AwaitOnCompleted / AwaitUnsafeOnCompleted / Start / SetStateMachine
}

Create(第 16-20 行)只做一件事:从池里取一个 ETTask 当作这个方法的"任务本体" 。所以你每调用一次 async ETTask 方法,发生的实际成本是------一次池 dequeue(命中时)或一次 new(池空时)。这就是引言里"两次分配变零次"的第一半。

builder.Start(ref stateMachine)(第 69-72 行)同样简单到出乎意料:stateMachine.MoveNext()------同步执行第一跳。状态机一路跑到第一个尚未完成的 await 为止,这段期间你的 async 方法和普通同步方法没有区别。

动手验证① :在 ETTask.Create 的 queue.TryDequeue 处打断点,Watch queue.Count------连续调用同一个 async ETTask 方法,第一次命中 new 分支,之后 dequeue 恒命中、Count 稳定,这就是池在干活。再在 builder.Create 打断点看调用栈,能看到编译器生成的 <YourMethod>d__xx 状态机类名。


链路二:挂起------await 一个未完成 ETTask 的四步

同步直通:IsCompleted 的分水岭

await 触发后,编译器生成的状态机先问第一个问题:IsCompleted(ETTask.cs:87-94)------state != Pending 即为真。已完成则完全不走挂起路径 :直接 GetResult() 取结果,一次 await 的代价接近一次普通方法调用。

这正是 ETTaskCompleted(ETTaskCompleted.cs,全文 34 行)的设计动机:

csharp 复制代码
[AsyncMethodBuilder(typeof (AsyncETTaskCompletedMethodBuilder))]
public struct ETTaskCompleted: ICriticalNotifyCompletion
{
    public ETTaskCompleted GetAwaiter() => this;
    public bool IsCompleted => true;
    public void GetResult() { }
    public void OnCompleted(Action continuation) { }
    public void UnsafeOnCompleted(Action continuation) { }
}

IsCompleted 恒为 true、四个方法全空------await ETTask.CompletedTask 在运行时什么都不发生 ,连状态机挂起都不用。配套的 AsyncETTaskCompletedMethodBuilder(AsyncETTaskCompletedMethodBuilder.cs,全文 63 行)更极端:Create 返回空 builder、Task 返回 default、SetResult 什么都不做,只有 SetException 干一件事------ETTask.ExceptionHandler.Invoke(e)。这对组合服务于"同步写完的 async 方法":方法里没有真 await 时,编译器走这个 builder,整个状态机在 Start 里同步跑完,零分配零挂起。ET 框架里大量"形式上 async、实际上同步"的方法靠它免单。

真挂起:UnsafeOnCompleted 存下"下一跳"

IsCompleted 为 false 时进入挂起路径。状态机调用 UnsafeOnCompleted(Action)(ETTask.cs:97-106):

csharp 复制代码
public void UnsafeOnCompleted(Action action)
{
    if (this.state != AwaiterStatus.Pending)
    {
        action?.Invoke();
        return;
    }

    this.callback = action;
}

两个分支,两重细心:

  • 已完成则立即执行 (第 99-103 行):这是与 IsCompleted 检查之间的竞速兜底------两次检查之间任务恰好完成了,续体不能丢,直接同步 Invoke;
  • 未完成则 this.callback = action :注意是赋值不是累加------ETTask 只支持一个等待者 。这不是偷懒,而是"一次性契约"的必然:一个 ETTask 实例服务一次 await,完成后立刻回池等待复用,哪来的第二个等待者?如果真有第二个 action 到来,它会悄悄覆盖第一个,制造"等待者失踪"的灵异 bug------这正是池化纪律(await 之后不碰 ETTask)要防的事故之一。

状态机打包:builder 的最后一道工序

存进 callback 的 action 是什么?不是编译器状态机本身(它是 struct),而是 builder 在 AwaitUnsafeOnCompleted(AsyncETTaskMethodBuilder.cs:61-65)里包装出来的:

csharp 复制代码
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
    ref TAwaiter awaiter, ref TStateMachine stateMachine)
    where TAwaiter : ICriticalNotifyCompletion
    where TStateMachine : IAsyncStateMachine
{
    this.iStateMachineWrap ??= StateMachineWrap<TStateMachine>.Fetch(ref stateMachine);
    awaiter.UnsafeOnCompleted(this.iStateMachineWrap.MoveNext);
}

两步:StateMachineWrap.Fetch(ref stateMachine) 把状态机 struct 装箱进一个包装类(包装类独立池化,上限 100),包装类持有一个 moveNext Action(StateMachineWrap.cs:37-57:构造时 this.moveNext = this.Run,Run 里调 this.StateMachine.MoveNext());然后把这个 MoveNext 交给 ETTask 存起来。

看 StateMachineWrap 全貌(StateMachineWrap.cs,全文 59 行):

csharp 复制代码
public interface IStateMachineWrap
{
    Action MoveNext { get; }
    void Recycle();
}

public class StateMachineWrap<T>: IStateMachineWrap where T: IAsyncStateMachine
{
    private static readonly ConcurrentQueue<StateMachineWrap<T>> queue = new();

    public static StateMachineWrap<T> Fetch(ref T stateMachine)
    {
        if (!queue.TryDequeue(out var stateMachineWrap))
        {
            stateMachineWrap = new StateMachineWrap<T>();
        }
        stateMachineWrap.StateMachine = stateMachine;
        return stateMachineWrap;
    }

    public void Recycle()
    {
        if (queue.Count > 100)
        {
            return;
        }
        this.StateMachine = default;
        queue.Enqueue(this);
    }

    private readonly Action moveNext;

    public Action MoveNext => this.moveNext;

    private T StateMachine;

    private StateMachineWrap()
    {
        this.moveNext = this.Run;
    }

    private void Run()
    {
        this.StateMachine.MoveNext();
    }
}

为什么要多包一层,而不是直接存状态机?因为 callback 的类型是 Action------状态机 struct 不会隐式变成 Action。包装类把"调状态机"固化为一个实例方法(Run),构造时绑定成 moveNext Action,之后每复用一次只需换里面的 StateMachine 引用(Fetch 第 17-25 行)。这是"两次分配变零次"的第二半:状态机的装箱分配被这 100 个池化包装消化了。

至此挂起完成。此刻世界的样子:async 方法停在某个 await 处,它的续体(MoveNext)躺在某个 ETTask 的 callback 字段里,执行权已经还给调用方。挂起没有线程阻塞,只是一个对象引用------这是协程与线程的本质区别。

动手验证② :在 UnsafeOnCompleted 的 this.callback = action; 处打断点,Watch action------它的目标对象是 StateMachineWrap<你的状态机类型>,展开能看到 StateMachine 字段就是编译器生成的状态机实例。再 Watch action.Method------Run 方法。续体=包装的状态机,眼见为实。


链路三:唤醒------一次 SetResult 引发的连锁反应

SetResult:翻转、取回、点火

完成端调用 SetResult(ETTask.cs:134-146):

csharp 复制代码
public void SetResult()
{
    if (this.state != AwaiterStatus.Pending)
    {
        throw new InvalidOperationException("TaskT_TransitionToFinal_AlreadyCompleted");
    }

    this.state = AwaiterStatus.Succeeded;

    Action c = this.callback as Action;
    this.callback = null;
    c?.Invoke();
}

三步各司其职:

  1. 状态守卫(第 136-139 行):已完成再 SetResult 直接抛异常------一次性契约的双向 enforcement,复用前的误用在这里被当场抓获;
  2. 翻转状态 (第 141 行):Succeeded。这一步先于取 callback------此后任何迟来的 UnsafeOnCompleted 都会走"已完成则立即执行"分支,续体不丢;
  3. 取出并点火 (第 143-145 行):callback 转回 Action,先置空再 Invoke ------警告注释"SetResult的时候请现将tcs置空"就是这里:置空在前,即使 Invoke 的同步执行立刻触发 GetResult → Recycle → 实例被下一个人领走,callback 也已是干净的 null,新主人拿到的是全新状态。

第 3 步的 c?.Invoke() 就是链路二存进去的那个 MoveNext------状态机被原地唤醒,从上次停住的 await 处继续跑。

连锁反应:方法跑完,唤醒外层

状态机续跑后通常走到方法末尾,触发 builder.SetResult()(AsyncETTaskMethodBuilder.cs:40-48):

csharp 复制代码
public void SetResult()
{
    if (this.iStateMachineWrap != null)
    {
        this.iStateMachineWrap.Recycle();
        this.iStateMachineWrap = null;
    }
    this.tcs.SetResult();
}

先回收状态机包装(连同它里面装的状态机一起归位,等待下一次 Fetch),再给自己方法的 ETTask SetResult------如果这个 ETTask 正被外层方法 await 着(它的 callback 存着外层的 MoveNext),外层状态机被唤醒......唤醒沿着 await 链逐层向外传播 ,直到链上某个调用方本就同步等待、或者链的根是个 Coroutine 兜底。

把双层 await 的完整时间线演算一遍:

时刻 动作 内层方法的 ETTask 外层方法的 ETTask
t0 外层 await 内层方法 创建,state=Pending callback 存外层 MoveNext,state=Pending
t1 内层 await 某个 IO callback 存内层 MoveNext 不变
t2 IO 完成(另一线程) SetResult:翻转→取 callback→Invoke 不变
t3 内层方法跑完 builder 回收包装;tcs.SetResult 翻转→取 callback→Invoke 外层 MoveNext
t4 外层方法续跑 (实例已回池) 继续业务

注意 t2 的"IO 完成(另一线程)"------SetResult 由谁调用、在哪个线程调用,ETTask 完全不管 。它只保证"callback 会被 Invoke"。如果完成事件来自 Socket 线程,那这个 Invoke 就发生在 Socket 线程------状态机会在 Socket 线程上续跑!这正是第一篇 2.4 节存在的理由:ET8 的组件(TimerComponent、网络层)在回调 Fiber 业务前,会先把完成事件 Post 回 ThreadSynchronizationContext,让 SetResult 发生在 Fiber 自己的线程上。ETTask 负责"点火",Fiber 负责"在正确的灶上点火"------两篇文章在此合龙。

Coroutine:忘 await 的兜底

挂起路径还有一条分支值得单独讲:SomeAsyncMethod().Coroutine()(第一篇 3.2 节的 Timeout().Coroutine() 就是它)。看实现(ETTask.cs:69-78):

csharp 复制代码
private async ETVoid InnerCoroutine()
{
    await this;
}

public void Coroutine()
{
    InnerCoroutine().Coroutine();
}

一个私有 async ETVoid 方法帮你 await 它。作用有二:一是把"即发即弃"写进类型系统------调用方不再处理返回值;二是异常有了去处 ------内层方法若 SetException,续体在内层状态机里醒来,GetResult 抛出,被 InnerCoroutine 的 builder(AsyncETVoidMethodBuilder)接住,最终流向 ETTask.ExceptionHandler(AsyncETVoidMethodBuilder.cs:26-35,链路四详述)。忘了写 await 的异步方法,异常不会凭空蒸发。

动手验证③ :在 SetResult 的 c?.Invoke() 处打断点,Watch this.callback 在 Invoke 前后------Invoke 前是 Action、Invoke 后已是 null(置空在前的证据)。再用两段嵌套的 async 方法各打一条日志,观察内层 SetResult 后外层日志紧接着输出------唤醒链的同步传播,两条日志之间没有任何线程切换的痕迹(同一线程 Id)。


链路四:异常------SetException、ExceptionDispatchInfo 与最后的兜底

callback 字段的一物两用

失败路径 SetException(ETTask.cs:150-162)与 SetResult 几乎逐行对应,除了第 160 行:

csharp 复制代码
public void SetException(Exception e)
{
    if (this.state != AwaiterStatus.Pending)
    {
        throw new InvalidOperationException("TaskT_TransitionToFinal_AlreadyCompleted");
    }

    this.state = AwaiterStatus.Faulted;

    Action c = this.callback as Action;
    this.callback = ExceptionDispatchInfo.Capture(e);
    c?.Invoke();
}

续体照常 Invoke(异常也要唤醒等待者,让它自己去面对失败),但 callback 字段被换上了 ExceptionDispatchInfo.Capture(e) ------这就是"一物两用":挂起时它存"接下来要跑什么",失败后它存"刚才出了什么错"。为什么敢这么用?因为时序保证:Failed 之后不会再有人来挂起(状态守卫挡路),这个字段空出来正好借给异常。等下一个主人(池复用)到来时,Recycle 会把它清成 null。

GetResult:把原始现场还给 catch

唤醒后的状态机在 GetResult 里取结果(ETTask.cs:115-131):

csharp 复制代码
public void GetResult()
{
    switch (this.state)
    {
        case AwaiterStatus.Succeeded:
            this.Recycle();
            break;
        case AwaiterStatus.Faulted:
            ExceptionDispatchInfo c = this.callback as ExceptionDispatchInfo;
            this.callback = null;
            this.Recycle();
            c?.Throw();
            break;
        default:
            throw new NotSupportedException("ETTask does not allow call GetResult directly when task not completed. Please use 'await'.");
    }
}

Faulted 分支的四步里,c?.Throw() 是灵魂:ExceptionDispatchInfo.Throw 按捕获时的原始堆栈 重新抛出,而不是"在这里新抛一个"。你在日志里看到的堆栈能一路指回 SetException 的案发现场------这对分布式排查至关重要(第一篇 3.6 节的 RpcException 就靠它保留对端错误现场)。顺序同样是先取空、再回收、后抛出:抛出后流程就进了 catch,这个实例必须已经是干净的。

default 分支(第 128-129 行)是第三道防线:还没完成就手动调 GetResult(绕过 await)直接抛 NotSupportedException------同步等待在 ETTask 里被明令禁止。

无人认领的异常:ExceptionHandler

最后一块拼图:如果 SetException 时根本没有人 await (callback 是 null),异常去哪?答案是 c?.Invoke() 的空传播救不了它------没人唤醒,异常躺在 callback 字段里,实例一回池就被清掉了。这种情况的兜底在 builder 层。

看 AsyncETVoidMethodBuilder.SetException(AsyncETVoidMethodBuilder.cs:26-35)------async ETVoid 方法(即发即弃协程,如 Timeout().Coroutine() 展开后的 InnerCoroutine)没有返回值、没有等待者,异常走它的 builder:

csharp 复制代码
public void SetException(Exception e)
{
    if (this.iStateMachineWrap != null)
    {
        this.iStateMachineWrap.Recycle();
        this.iStateMachineWrap = null;
    }
    ETTask.ExceptionHandler.Invoke(e);
}

直接 ETTask.ExceptionHandler.Invoke(e)。这个静态字段由框架启动时赋值(Loader/MonoBehaviour/Init.cs:31):ETTask.ExceptionHandler += Log.Error;------所有无人认领的协程异常,最终都打进全局日志。AsyncETTaskCompletedMethodBuilder.SetException(第 23-26 行)和 ETCancellationToken.cs:50 也是同一出口。

于是 ETTask 世界的异常哲学完整了:每个异常要么被某个 await 的 GetResult 当面抛给 catch,要么最终被 ExceptionHandler 接进日志------不存在第三种"静默蒸发"的去处。

动手验证④ :写个 async ETTask 方法,第一行 throw new Exception("test"),然后只调 .Coroutine() 不 await------日志里会出现带完整堆栈的 test 异常(经 ExceptionHandler)。再把调用改成 await,catch 到的异常堆栈与前者一致(ExceptionDispatchInfo 的效果)。最后试一次"await 之后手动 SetResult",会撞上 InvalidOperationException("TaskT_TransitionToFinal_AlreadyCompleted")------状态守卫在值班。


支线一:池化三件套------零 GC 是怎么炼成的

把全文的分配点列一张账:

分配点 归宿 上限
每个 async ETTask 方法的任务本体 ETTask 静态池(ETTask.cs:23) 1000(Recycle 第 53 行)
每次挂起的状态机装箱 StateMachineWrap<T> 静态池(StateMachineWrap.cs:15) 100(Recycle 第 29 行)
builder 本身 struct,零分配 ------
同步写完的 async 方法 AsyncETTaskCompletedMethodBuilder,连 ETTask 都不建 ------

一个高频 async 方法(比如第一篇 WaitFrameFinish)在稳态下跑一百万次,堆分配趋近于零------代价是两条纪律:await 之后不再碰实例 (Create 的警告注释)、别手动 SetResult 两次(状态守卫)。池的两个上限(1000/100)是同一个意图:池是缓冲不是仓库,回收比消耗快时宁可丢。

支线二:WaitAny / WaitAll------计数器与组合

ETTaskHelper(ETTaskHelper.cs,全文 126 行)提供两个组合原语,核心是私有类 CoroutineBlocker:

csharp 复制代码
private class CoroutineBlocker
{
    private int count;
    private ETTask tcs;

    public CoroutineBlocker(int count) { this.count = count; }

    public async ETTask RunSubCoroutineAsync(ETTask task)
    {
        try
        {
            await task;
        }
        finally
        {
            --this.count;
            if (this.count <= 0 && this.tcs != null)
            {
                ETTask t = this.tcs;
                this.tcs = null;
                t.SetResult();
            }
        }
    }

    public async ETTask WaitAsync()
    {
        if (this.count <= 0) { return; }
        this.tcs = ETTask.Create(true);
        await tcs;
    }
}

WaitAll 给 blocker 传入 tasks.Length,每个子任务套一层 RunSubCoroutineAsync 后 .Coroutine() 发出去,然后 await WaitAsync()------每个子任务完成都 finally 里递减计数,归零那一刻给主 ETTask SetResult。WaitAny 的玩法更巧:blocker 初始计数传 1 ,任何一个子任务先到,计数归零下主任务就放行了------其余子任务后来完成时,面对的是 tcs == null(第 38 行的空检查),静默收场。注意 t.SetResult 前先把 this.tcs 置空(第 40-41 行)------还是那条纪律:置空先于点火。

支线三:与第一篇的合龙------一次真实 await 的完整时间线

把两篇的知识拼成一次完整的"跨 Fiber RPC await"(第一篇 3.1-3.6 的 Call 内部,就有 await messageSenderStruct.Wait()):

  1. Realm Fiber 的 Call 挂起在 Wait() 上------它的状态机(ETTask 承载)停在半路,续体是 MoveNext;
  2. 40 秒超时守护协程同时在等 TimerComponent------同样停在半路;
  3. Location Fiber 回响应,消息抵达 Realm Fiber 所在进程,ProcessInnerSender.Update 在 Realm 自己的线程 上 Fetch 到它;
  4. HandleIActorResponse → SetResult → callback.Invoke → MoveNext → Call 的状态机在 Realm 线程上原地醒来。

关键在第 3、4 步的线程归属:消息投递(第一篇的 MessageQueue)和响应唤醒(本篇的 SetResult)都自动发生在正确的 Fiber 线程上------前者靠按 FiberId 分拣的队列,后者靠第一篇 2.4 的 SynchronizationContext 续体回归。第一篇与第二篇是同一枚硬币的两面:Fiber 修好了路,ETTask 造好了车。


总结:ET8 ETTask 机制的架构、实现与价值

总结回顾以上,ET8 的 ETTask 机制是以"三态单字段、一次性契约、双池零 GC "为核心设计,通过 GetAwaiter/IsCompleted/UnsafeOnCompleted/GetResult 四步合同接入编译器的 await 模式,借 [AsyncMethodBuilder] 让 async ETTask 方法获得定制的状态机构建器,用 StateMachineWrap 池消化状态机装箱、用 ETTask 池消化任务本体,以 callback 字段的一物两用承载"续体与异常"两种使命,最终由 ExceptionDispatchInfo 保留现场、ExceptionHandler 兜住无人认领的失败------在约四百行有效代码内,再造了一个比 BCL Task 更轻、与 Fiber 单线程世界严丝合缝的协程底座。

ETTask 读透了,下一篇《一切皆 Entity:Scene 树与 InstanceId》,兑现第一篇 2.5 节埋下的 IScene 缓存之谜------解剖 Entity 的树状世界。

相关推荐
星轨zb1 小时前
[支付专题4]支付回调幂等性和状态:状态机与终态检查的边界
后端·支付架构学习
会编程的吕洞宾1 小时前
Spring AI 2.0 结构化输出实战:让大模型返回精准 JSON 不再“抽风“,准确率从 78% 拉到 99%
java·后端
用户298698530141 小时前
Python 实现 Word 与 TIFF 双向转换的方案
后端·python·api
cpp_w2 小时前
C#基础3- 面向对象中级应用3
后端·c#
代码小学僧2 小时前
如何低成本部署网站?Vercel 和 Cloudflare 怎么选?
前端·后端·serverless
MetWeave2 小时前
从零跑一个气象数据管道:抓取、清洗、对账、节奏
后端·python
gopher95112 小时前
RUST:序列化与反序列化 serde + derive 详解
开发语言·后端·rust
铁皮饭盒2 小时前
CPU就行, 老照片变清晰, 本地部署582mb腾讯的模型, 本地部署, 过节了给长辈们变个小魔术
前端·javascript·后端
七夜zippoe2 小时前
Spring Boot 接通义千问:从拿 Key 到第一个对话接口跑通(保姆级)
java·spring boot·后端