深入剖析 ET8 框架的 ETTask 机制
引言:Fiber 管"回哪个线程",ETTask 管"回去干什么"
上一篇把 Fiber 读透了,留下了一个反复出现、却始终没有展开的承诺:Fiber 线程里的 await,续体经由 SynchronizationContext 回到 Fiber 的队列------"回家"的路是 Fiber 修的,但回家之后要执行什么,是 ETTask 的事。
.NET 自带 Task 与 async/await,ET 为什么还要自己造一个 ETTask?读完本篇的源码,答案会具体到三个数字:
- 两次分配变零次 :.NET 的
async Task方法默认要为状态机装箱、为 Task 对象分配内存;ET 用两个对象池(ETTask 池上限 1000、状态机包装池上限 100)把这两次分配都吃掉了; - 3 个状态、1 个字段 :ETTask 的全部状态机就是
Pending / Succeeded / Faulted三态枚举,承载续体的callback字段一物两用(挂起时存续体、失败时存异常),没有等待队列、没有锁; - 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处打断点,Watchqueue.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;处打断点,Watchaction------它的目标对象是StateMachineWrap<你的状态机类型>,展开能看到StateMachine字段就是编译器生成的状态机实例。再 Watchaction.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();
}
三步各司其职:
- 状态守卫(第 136-139 行):已完成再 SetResult 直接抛异常------一次性契约的双向 enforcement,复用前的误用在这里被当场抓获;
- 翻转状态 (第 141 行):
Succeeded。这一步先于取 callback------此后任何迟来的UnsafeOnCompleted都会走"已完成则立即执行"分支,续体不丢; - 取出并点火 (第 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()处打断点,Watchthis.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()):
- Realm Fiber 的
Call挂起在Wait()上------它的状态机(ETTask 承载)停在半路,续体是MoveNext; - 40 秒超时守护协程同时在等 TimerComponent------同样停在半路;
- Location Fiber 回响应,消息抵达 Realm Fiber 所在进程,
ProcessInnerSender.Update在 Realm 自己的线程 上Fetch到它; 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 的树状世界。