[深入解析C#] 第6章:异步原理
📦第 6 章——异步原理
-
核心概念:
- 编译器转换:C# 编译器将
async方法编译成一个状态机,该状态机负责在await处暂停方法,并在异步操作完成后从断点处恢复执行。最终生成的 IL 代码不含async/await关键字。 - 构建器交互:框架提供的 AsyncTaskMethodBuilder(或自定义构建器)负责创建并管理
Task,将状态机的状态变化与返回的Task生命周期联动。 - 调试与发布差异:
- 调试构建:状态机生成为类(便于调试,支持编辑并继续)。
- 发布构建:状态机生成为结构体(避免堆分配,提升性能)。
- 层层递进的复杂性:异步原理的核心围绕
await表达式展开,涉及 awaiter 的获取、续延(continuation)的注册以及 执行上下文(ExecutionContext) 的贯穿。 - 可扩展性:理解状态机原理后,可自由实现自定义 task/awaitable 类型,不再局限于
Task和ValueTask。
- 编译器转换:C# 编译器将
-
Unity开发关键点:
- 性能敏感:Unity 游戏循环对 GC 极其敏感,发布构建中使用结构体状态机可避免堆分配,这正是 UniTask 实现零分配异步的核心基础(通过结构体状态机 + 自定义
AsyncUniTaskMethodBuilder)。 - IL2CPP 与状态机:AOT 编译环境下,结构体状态机的泛型特化更彻底,有助于减少元数据开销。但需注意 IL2CPP 对某些异步模式的支持限制(如
async void错误处理)。 - 主线程上下文:Unity 的
SynchronizationContext会将await后的代码调度回主线程。编译器生成的状态机会自动捕获并恢复上下文,理解这一点可避免死锁(如误用Task.Wait()或在主线程await未配置.ConfigureAwait)。 - 自定义 Awaitable:Unity 资源加载操作(
AssetBundleRequest、UnityWebRequestAsyncOperation)本身是可等待的,但不符合GetAwaiter模式的标准。可以基于状态机原理封装为UniTask,实现与async/await的无缝对接。 - 协程与异步的桥接:理解状态机后,可以轻松将协程(
IEnumerator)包装成await可用的Task或UniTask,反之亦然。
- 性能敏感:Unity 游戏循环对 GC 极其敏感,发布构建中使用结构体状态机可避免堆分配,这正是 UniTask 实现零分配异步的核心基础(通过结构体状态机 + 自定义
-
代码示例(Unity语境):
// Unity 中使用 UniTask 的异步加载示例 using Cysharp.Threading.Tasks; using UnityEngine; using UnityEngine.Networking; public class WeatherLoader : MonoBehaviour { async UniTaskVoid Start() { string json = await GetWeatherAsync("London"); Debug.Log(json); } private async UniTask<string> GetWeatherAsync(string city) { // UnityWebRequest 的 SendWebRequest 返回异步操作对象 using var request = UnityWebRequest.Get($"<https://api.weather.com/{city}>"); // UniTask 将 AsyncOperation 包装为可等待的零分配任务 await request.SendWebRequest(); return request.downloadHandler.text; } }编译器为
GetWeatherAsync生成一个结构体状态机(发布模式),所有局部变量(如request)都会被提升为状态机的字段,确保await后方法恢复时状态不丢失。 -
常见陷阱与最佳实践:
- 调试测试的假象:在 Editor 调试模式下,状态机是类,测试时 GC 分配很少;一旦发布为 IL2CPP 且使用结构体状态机,可能出现意外的性能提升或问题。务必在真实设备上使用 Deep Profiler 确认 GC 分配。
- 状态机生命周期:当异步方法挂起时,状态机可能被提升到堆上(即使本身是结构体,因为
Task内部会进行装箱或保存于堆对象中)。如果异步等待时间很长,而MonoBehaviour已被销毁,可能导致访问已销毁对象。最佳实践:始终将异步操作与生命周期绑定(如使用this.GetCancellationTokenOnDestroy()传递取消令牌)。 - 执行上下文捕获开销:默认情况下,每次
await都会捕获ExecutionContext。在 Unity 主线程中,应尽量使用UniTask.ConfigureAwait(PlayerLoopTiming.Update)或UniTask.UnityMainThread()来明确指定上下文,避免不必要的上下文捕获与切换。 - 不要依赖具体反编译结果:编译器实现细节会随版本变化,不要通过反编译编写出依赖特定生成模式的代码。应聚焦于可等待模式规范和构建器约定,它们是 C# 语言层面保证的稳定行为。
- 避免 async void:
async void方法无法被等待,异常会直接抛到 Unity 主线程导致崩溃,且难以测试。除MonoBehaviour事件函数(如Start改用async UniTaskVoid)外,始终返回Task或UniTask。
6.1 生成代码的结构
-
核心概念:
-
桩方法(Stub Method):编译器为每个
async方法生成一个与其同名的桩方法,它负责初始化状态机实例,填入参数、构建器(builder)和初始状态(通常为 -1),然后启动状态机并立即返回builder.Task。 -
状态机结构体:
- 实现
IAsyncStateMachine接口,包含MoveNext()和SetStateMachine()。 - 字段包括:状态编号 (
state)、方法构建器 (AsyncTaskMethodBuilder)、原始参数、awaiter 以及所有会被await跨越的局部变量。 - 在发布构建中为结构体,避免堆分配;调试构建中可能为类(便于编辑并继续)。
- 实现
-
状态追踪:
state = -1:未启动。state = -2:已完成(通常由构建器直接标记)。state >= 0:对应方法中第几个await的挂起点(0 为第一个await,依此类推)。
-
执行流程:

- 桩方法创建状态机,调用
builder.Start(ref machine)。 Start内部调用MoveNext(),开始执行直至遇到第一个未完成的await。- 暂停时,状态机保存
state,记录awaiter,并向awaiter注册MoveNext作为续延,然后返回。 - 操作完成后,续延被触发,再次调用
MoveNext(),从保存的state跳转继续执行,直到下一个await或方法结束。
- 桩方法创建状态机,调用
-
-
Unity开发关键点:
- 零分配异步:结构体状态机是 UniTask 实现零GC的核心基础。原生
Task会将状态机提升到堆上,而 UniTask 使用AsyncUniTaskMethodBuilder配合结构体状态机,即使在发布构建中也能保持状态机在栈上(或在UniTask内部以字段存在),避免每次await产生 GC。 - MonoBehaviour 生命周期协同:状态机会在暂停时离开方法,若此时
GameObject被销毁,后续MoveNext可能访问已被销毁的MonoBehaviour字段。必须结合 取消令牌(如destroyCancellationToken)在await后检测对象有效性。 - 自定义 Awaiter 的适配:Unity 的
YieldInstruction(如WaitForSeconds)和AsyncOperation并不原生实现INotifyCompletion,但通过理解状态机如何调用awaiter.GetResult()和awaiter.OnCompleted(),可以轻松将它们包装成可等待对象(如UniTask.Yield)。 - IL2CPP 的代码生成:结构体状态机在 IL2CPP 下会被翻译为 C++ 栈分配或成员内联,相比类状态机减少了虚函数调用和 GC 管理开销,但要注意泛型特化可能导致代码膨胀,需权衡。
- 性能优化:减少异步方法中跨越
await的局部变量数量,可以缩小状态机结构体大小,进而减少复制开销。
- 零分配异步:结构体状态机是 UniTask 实现零GC的核心基础。原生
-
代码示例(Unity语境):
// 假设我们有一个异步加载场景的方法 // 编译器生成的桩方法(伪代码) public static async Task LoadSceneAsync(string sceneName) { await SceneManager.LoadSceneAsync(sceneName); Debug.Log($"{sceneName} 加载完成"); } // 桩方法等价于: public static Task LoadSceneAsync(string sceneName) { var machine = new LoadSceneAsyncStateMachine { sceneName = sceneName, builder = AsyncTaskMethodBuilder.Create(), state = -1 }; machine.builder.Start(ref machine); return machine.builder.Task; } // 结构体状态机(概念示例) struct LoadSceneAsyncStateMachine : IAsyncStateMachine { public int state; public AsyncTaskMethodBuilder builder; public string sceneName; // 参数提升为字段 private AsyncOperationAwaiter awaiter; // SceneManager.LoadSceneAsync 的 awaiter public void MoveNext() { try { if (state == 0) goto AfterAwait; // 初始执行 var asyncOp = SceneManager.LoadSceneAsync(sceneName); awaiter = asyncOp.GetAwaiter(); if (!awaiter.IsCompleted) { state = 0; builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; } AfterAwait: awaiter.GetResult(); Debug.Log($"{sceneName} 加载完成"); } catch (Exception e) { state = -2; builder.SetException(e); return; } state = -2; builder.SetResult(); } public void SetStateMachine(IAsyncStateMachine stateMachine) { /*...*/ } }在 Unity 中,我们不会自己写状态机,但理解其形态有助于优化异步代码并避免不必要的分配。
-
常见陷阱与最佳实践:
- 直接访问
MonoBehaviour成员:异步方法挂起后,方法返回,原MonoBehaviour可能已被销毁。后续恢复时,访问this.transform会抛出异常。解决:在await后立即检查this == null或使用CancellationToken(如 UniTask 的this.GetCancellationTokenOnDestroy())。补充:gameobject被设置为false后,其脚本的异步会有何影响?如果是脚本组件enable设置为false呢? - 调试与发布的状态差异:在 Editor 下测试无分配,不代表真机也无分配。应始终在目标平台(如 IL2CPP + 发布模式)使用 Profiler 验证 GC Allocation。
- 超大状态机:异步方法内包含大量局部变量和多个
await,结构体状态机会变得很大。频繁复制可能带来隐藏性能损耗。可考虑拆分异步方法或使用引用类型包装大对象,但这会增加堆分配,需要权衡。 - 避免滥用
async void:async void方法的状态机没有Task返回,异常会直接抛出到主线程,且无法被正常捕获。除顶层事件(如 UI 按钮)外,应始终返回Task或UniTask。 - 注意构建器类型:
AsyncTaskMethodBuilder负责设置最终Task的结果、异常或标记取消。自定义Task类型需要提供配套的构建器,否则编译器会报错。Unity 中 UniTask 的构建器是AsyncUniTaskMethodBuilder,可以做到ValueTask样式的池化。
- 直接访问
6.1.1 桩方法:准备和开始第一步
-
核心概念:
- 桩方法是编译器为
async方法生成的同名普通方法,负责初始化状态机并启动异步流程。 - 初始化三要素:
- 原始参数:每个参数都被复制为状态机的公共字段。
- 构建器:
AsyncTaskMethodBuilder.Create()创建与返回类型匹配的构建器(对于Task、Task<T>、UniTask等各有不同)。 - 初始状态:
state = -1,表示“未启动”。
AsyncTaskMethodBuilder是值类型,充当异步方法基础架构的 Helper:它负责创建最终的Task,并在异步执行过程中设置结果、异常或取消标记。- 引用传递是关键:
builder.Start(ref machine)将状态机以引用传递给构建器,确保对值类型状态的修改(如state、awaiter)不会因拷贝而丢失。 - 同步启动:
Start方法调用MoveNext()同步执行,直到遇到未完成的await或方法结束,不会创建新线程。 - 任务提前返回:在
Start执行前,builder.Task就已经创建好(由Create生成)。Start返回后,调用方便获得了这个Task并可等待它。
- 桩方法是编译器为
-
Unity开发关键点:
- UniTask 的桩方法:UniTask 的
AsyncUniTaskMethodBuilder同样是值类型,利用结构体状态机和引用传递实现零堆分配异步,这是其在 Unity 中性能远超原生Task的根本原因。 - 同步上下文捕获:桩方法本身不设置任何上下文。当状态机进入暂停时,会由 awaiter 决定是否捕获
SynchronizationContext。在 Unity 主线程中调用的异步方法,其后续续延默认会回到主线程(与 UniTask 的PlayerLoopTiming有关)。 - 避免误解“异步”:很多开发者误以为
async方法会另开线程,实则桩方法只是启动状态机,实际逻辑仍在调用线程执行。若在Start中执行耗时计算(未await时),会阻塞 Unity 主线程。必须用await Task.Yield()或UniTask.Yield()显式让出。 - 生命周期绑定:桩方法立即返回
Task,但状态机可能被提升到堆上(遇到未完成 awaiter 时)。如果状态机被保存在某个MonoBehaviour的字段中,而该对象在异步操作完成前销毁,则后续MoveNext会访问已释放的字段。最佳实践:使用CancellationToken或UniTask的AttachExternalCancellation。
- UniTask 的桩方法:UniTask 的
-
代码示例(Unity语境):
// 假设我们的异步加载方法 public async UniTask LoadPlayerDataAsync(string userId) { var json = await FetchFromServerAsync(userId); var data = JsonUtility.FromJson<PlayerData>(json); // 使用数据... } // 编译器生成的桩方法等价形式(简化) [AsyncStateMachine(typeof(LoadPlayerDataAsyncStateMachine))] public UniTask LoadPlayerDataAsync(string userId) { var machine = new LoadPlayerDataAsyncStateMachine { userId = userId, builder = AsyncUniTaskMethodBuilder.Create(), state = -1 }; // 引用传递,避免状态机复制 machine.builder.Start(ref machine); return machine.builder.Task; // 返回的 Task 是 UniTask }在 Unity 中,使用
UniTask时,AsyncUniTaskMethodBuilder会将状态机与PlayerLoop集成。builder.Task实际就是一个轻量UniTask实例。 -
常见陷阱与最佳实践:
-
值类型拷贝的陷阱:永远不要复制
builder字段。编译器生成的代码直接使用machine.builder,保证操作同一实例。var builder = machine.builder; // 副本! builder.Start(ref machine); // 只修改了副本的内部状态 return builder.Task; // 返回的 Task 可能永远不会完成 -
误用桩方法开线程:以为
async天然非阻塞,而在没有await的异步方法中写耗时操作(如大量循环),会卡死主线程。应在首个await之前让出控制权。 -
忽视构建器差异:不同返回类型(
Task、ValueTask、UniTask)对应不同的构建器。若手动模拟异步,需正确使用对应的AsyncTaskMethodBuilder变体,否则状态机无法正确设置结果/异常。 -
调试时的混淆:反编译工具可能将桩方法还原为
async风格,掩盖了状态机细节。当出现异步完成不触发时,理解桩方法机制有助于排查构建器的SetResult未被调用的问题。 -
state = -1的必要性:初始状态1表示“尚未开始”。如果状态机被意外重用(如池化时未重置),将导致执行从错误的state恢复。UniTask 的零分配状态机池化需要谨慎重置此字段。
-
6.1.2 状态机的结构——字段分类与优化
-
核心概念:
- 状态机接口:
IAsyncStateMachine要求实现MoveNext()(核心逻辑)和SetStateMachine()(将状态机与构建器关联)。 - 字段分类:
- 状态编号:
state整型,-1(未启动/执行中),-2(完成),≥0(在某个await处挂起)。 - 构建器:
AsyncTaskMethodBuilder等,负责创建Task、处理结果和异常。 - Awaiter 字段:每种
await表达式的 awaiter 类型各占一个字段(相同类型复用)。 - 跨
await的局部变量:在await之后仍需使用的局部变量会被提升为字段,仅在await之间使用的变量保持为局部变量。 - 临时栈变量:当
await是复杂表达式的一部分时,需要暂存中间值(如a + b * await task),编译器生成临时字段。
- 状态编号:
- 字段复用策略:
- Awaiter 字段:按类型复用,多个相同类型的
await共享一个字段(因为它们不会同时存在)。 - 局部变量:编译器精确分析变量的活跃区间,只有活跃期跨越
await的变量才提升为字段。两个不相交作用域的变量即使类型相同,当前编译器版本可能不会复用字段(未来可能优化)。 - 临时栈变量:相同类型的中间值复用字段,减少状态机体积。
- Awaiter 字段:按类型复用,多个相同类型的
- 状态机接口:
-
Unity开发关键点:
- 优化状态机字段:减少跨
await的局部变量数量和类型,可以缩小状态机结构体大小,减少复制开销(尤其当状态机被装箱到堆上时)。- 在 Unity 中,如果使用 UniTask 且状态机始终处于栈上,过大的结构体可能增加栈压力,需注意深度递归或深层异步调用链。
- 避免不必要的提升:将只在
await之前使用的变量作用域缩小(如用{ }包裹),可以避免它们变成字段,减少内存占用。 - 临时栈变量:复杂表达式嵌入
await(如text = "Score: " + await GetScoreAsync())会生成临时字段。在性能敏感路径中,先var score = await GetScoreAsync();再计算,可简化状态机。 - 字段与 GC:如果状态机被迫堆分配(比如作为
Task的续延),额外字段意味着更多 GC 压力。UniTask 框架尽量让状态机保持在UniTask结构体内部或栈上,但调用链较深时仍需留意。 - 理解 awaiter 复用:自定义 awaiter 或非泛型
Task的 awaiter 会被复用一个字段。了解这一点有助于审视反编译后的状态机是否如预期,并避免意外捕获不必要的对象。
- 优化状态机字段:减少跨
-
代码示例(Unity语境):
// 性能敏感示例:一个异步方法等待多个相同类型的任务 public async UniTask ProcessMultipleAsync() { // 以下三个 await 共用同一个 TaskAwaiter<int> 字段 int a = await FetchDataAsync(1); int b = await FetchDataAsync(2); int c = await FetchDataAsync(3); // a, b, c 跨 await 使用,所以它们会被提升为字段 Debug.Log(a + b + c); } // 优化建议:避免在表达式内部 await,减少临时字段 public async UniTask LoadAndDisplayAsync() { // 不好:now.Second 和 now.Hours 在 await 前必须保存,产生临时字段 Task<int> task = Task.FromResult(10); DateTime now = DateTime.UtcNow; int result = now.Second + now.Hours * await task; // 隐式临时字段 // 更好:先等待,再计算 int value = await task; int result2 = now.Second + now.Hours * value; // 无临时字段 } // 作用域控制,避免不必要的提升 public async UniTask SmartScopeAsync() { { int temp = CalculateSomeValue(); Debug.Log(temp); } // temp 在 await 之前失效,不会被提升为字段 await UniTask.Delay(100); // temp 已经不可用,状态机无需保存 } -
常见陷阱与最佳实践:
- 忘记变量会被提升:在异步方法中将大型对象(如数组、
List<T>)声明为局部变量,并在await之后使用,会导致整个对象被提升为字段并可能被装箱到堆上。对于大对象,可考虑提取为类成员或使用UniTask的结构体存储,但需权衡生命周期。 - 误判作用域:看似在
await之后不再需要的变量,可能由于编译器保守分析仍被提升。可通过明确使用块限定作用域来辅助编译器优化(虽然编译器已很智能,但显式控制无坏处)。 - 过度关注字段数量:除非是极热路径或状态机极其复杂,否则少量额外字段影响甚微。不要为了减少字段而牺牲代码可读性。优先用 Profiler 验证,而非预判。
- 自定义 awaiter 的字段管理:如果为 Unity 实现自定义 awaitable(如
WaitForAnimation),其Awaiter类型会被状态机存储为字段。确保该结构体小巧且无托管引用,以免拖累状态机。 - 发布模式与调试模式的差异:调试模式下状态机是类,字段复用规则可能不同。不要在调试器观察到的状态机字段上做性能假设,始终以发布构建为准。
- 忘记变量会被提升:在异步方法中将大型对象(如数组、
6.1.3 MoveNext()方法(整体介绍)
这张流程图就够复杂惹,就不放其他东西了

6.1.4 SetStateMachine方法以及状态机的装箱事宜
-
核心概念:
-
SetStateMachine的作用:将状态机实例与AsyncTaskMethodBuilder绑定,确保构建器持有状态机的引用(通常是装箱后的堆引用),以便在异步操作完成后能正确调用MoveNext()。 -
装箱发生的时机:
- 状态机最初是栈上的结构体局部变量。
- 当异步方法在
await处暂停时,状态机必须被装箱到堆上,以保证其生命周期长于当前栈帧。 - 装箱后立即调用
boxed.SetStateMachine(boxed),把装箱对象的引用存入构建器。
-
关键代码逻辑(伪码):
void BoxAndRemember<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { IAsyncStateMachine boxed = stateMachine; // 装箱 boxed.SetStateMachine(boxed); // 构建器记住装箱引用 } -
调试与发布的差异:
- 发布构建:状态机是结构体,
SetStateMachine实现为builder.SetStateMachine(stateMachine),完成装箱后绑定。 - 调试构建:状态机本身就是类(堆对象),
SetStateMachine方法体为空,因为无需再装箱。
- 发布构建:状态机是结构体,
-
后续操作都在装箱值上:一旦装箱,所有续延委托都指向该装箱实例的
MoveNext,保证状态一致。
-
-
Unity开发关键点:
- GC 分配的直接来源:每个在
await处暂停的async Task方法都会导致状态机至少一次堆分配(装箱 +Task对象本身)。这是原生Task在 Unity 中导致高频 GC 的关键原因之一。 - UniTask 的规避策略:
- UniTask 的
AsyncUniTaskMethodBuilder不要求装箱。它通过将状态机直接嵌入UniTask的IAwaiter接口字段,或利用自定义的“状态机跑道”(StateMachineRunner)在栈或静态区域保存状态,从而完全避免了状态机装箱。 - 这是 UniTask 实现零分配异步的核心技术之一(还需要
ValueTask样式的UniTask结构体配合)。
- UniTask 的
- IL2CPP 下的代价:
- IL2CPP 中,装箱会生成托管对象并触发垃圾回收。频繁的异步调用(如每帧执行的
async方法)会迅速产生大量临时堆对象,导致手机端卡顿。 - 使用
UniTask或纯协程(IEnumerator)替代热路径中的原生Task,是常见的性能优化手段。
- IL2CPP 中,装箱会生成托管对象并触发垃圾回收。频繁的异步调用(如每帧执行的
- MonoBehaviour 生命周期:装箱后的状态机脱离栈保护,如果
await之后没有检查this是否为null,而对象已被销毁,会导致访问已销毁对象。必须配合取消令牌(CancellationToken)及时中断异步操作。
- GC 分配的直接来源:每个在
-
代码示例(Unity语境):
// 原生 Task 异步方法:每次 await 都可能装箱状态机 public async Task<int> CalculateDamageAsync() { int baseDmg = await GetBaseDamageFromServer(); // 暂停,状态机可能装箱 float multiplier = await GetMultiplierFromConfig(); return (int)(baseDmg * multiplier); } // 对于热路径,使用 UniTask 避免装箱 public async UniTask<int> CalculateDamageOptimizedAsync() { int baseDmg = await GetBaseDamageFromServerAsync(); float multiplier = await GetMultiplierFromConfigAsync(); return (int)(baseDmg * multiplier); // 状态机保持在 UniTask 内部或栈上,无额外装箱 } // 危险示例:状态机装箱后,MonoBehaviour 可能被销毁 public async void OnDamageEvent() { await Task.Delay(5000); // 状态机装箱到堆上 // 如果这个 MonoBehaviour 在 5 秒内被销毁,下一行会崩溃 this.GetComponent<Health>().TakeDamage(10); } -
常见陷阱与最佳实践:
- 误解“无分配”异步:认为所有
async方法都无额外分配是错的。原生Task在暂停时必然装箱状态机(如果尚未被提升到别处)。理解这一点才能正确使用UniTask或评估性能。 - 调试与发布的不一致:在编辑器调试模式下,状态机是类,没有装箱开销,观察不到 GC。一旦发布到设备,装箱发生,GC 突然飙升。务必在目标平台用 Profiler 确认。
- 盲目使用
async void:async void方法的状态机也会被装箱,但其任务无法被等待,异常处理也更难。在 Unity 中,仅在顶层事件(如按钮onClick)使用,并尽快调用await转移到可等待的UniTask。 - 热路径避免异步:在
Update、FixedUpdate等频繁调用的方法中,避免使用任何形式的async(包括UniTask),因为即使没有装箱,状态机的MoveNext调用链也有开销。用传统代码或协程代替。 - 状态机与闭包:若异步方法内引用了外部变量(闭包),这些变量也会被提升并跟随装箱。意外捕获大型对象会导致内存泄漏。使用
UniTask的结构体闭包或显式传递参数减少捕获。
- 误解“无分配”异步:认为所有
6.2 一个简单的MoveNext()实现
6.2.1 一个完整的具体示例
核心概念
MoveNext() 是状态机的心脏,编译器将 async 方法的流程重写为一个带有 switch/goto 的状态机驱动代码。以下基于代码清单6-1的 PrintAndWait 方法反编译结果,归纳其通用结构。
通用结构模式
整个 MoveNext() 方法遵循如下固定框架:
- 保存初始状态:
int num = this.state; - 进入 try 块:包裹几乎所有逻辑,用于统一异常捕获。
switch跳转调度:state = -1或默认:跳转到方法起点MethodStart。state = 0, 1, ...:对应第1个、第2个……await表达式的恢复点,直接跳转到对应的goto FirstAwaitContinuation等标签。
- 每个
await表达式生成三段式代码:- A. 判断阶段:
- 调用
GetAwaiter()。 - 若
IsCompleted == true,直接跳转到 C. 获取结果 阶段,避免挂起。
- 调用
- B. 挂起阶段(未完成时):
- 保存
state值(对应此await的编号)。 - 将 awaiter 存入
this.awaiter字段。 - 调用
this.builder.AwaitUnsafeOnCompleted(ref awaiter, ref this)注册续延。 return,退出MoveNext(),控制权返回给调用者。
- 保存
- C. 恢复与获取结果阶段:
- 恢复点标签(
FirstAwaitContinuation等):从this.awaiter取出 awaiter 并清空。 - 重置
this.state = -1(回到执行中状态)。 - 获取结果标签(
GetFirstAwaitResult等):调用awaiter.GetResult()。 - 同步完成路径:直接从判断阶段跳转至此,避免
state和 awaiter 的读写。
- 恢复点标签(
- A. 判断阶段:
- 方法结束:
this.builder.SetResult()标记任务成功完成。 - 异常处理(catch):
this.builder.SetException(exception)标记任务失败。
Unity开发关键点
- 性能开销可视化:此代码揭示了原生
Task的性能代价:- 每个未完成的
await导致对状态机的装箱(builder.AwaitUnsafeOnCompleted内部会执行BoxAndRemember操作)。 AsyncTaskMethodBuilder内部会创建并返回一个新的Task对象。- 结论:在热路径中使用原生
async Task会导致大量 GC 分配。
- 每个未完成的
- UniTask 的优化对比:
- UniTask 的
AsyncUniTaskMethodBuilder.AwaitUnsafeOnCompleted不会对状态机进行装箱。 - 它将状态机直接存储到
UniTask或IAsyncStateMachine的引用字段中,巧妙利用值类型在接口引用下的已有装箱,或使用对象池,实现零附加分配。
- UniTask 的
- 理解
IsCompleted快速路径:- Unity 引擎内部某些操作可能同步完成(如资源已缓存时的
Resources.LoadAsync?实际上这返回非null,但异步操作对象可能立即完成)。 - 实践:编译器生成的
if (awaiter.IsCompleted)路径确保在同步完成时完全避免状态机的挂起、装箱和上下文恢复开销。这就是ValueTask和UniTask在同步完成场景下高性能的原因。
- Unity 引擎内部某些操作可能同步完成(如资源已缓存时的
- 主线程上下文恢复:
AwaitUnsafeOnCompleted注册的续延最终会调用此MoveNext()方法。理解调用时机和线程至关重要。- 对于原生
Task,若await前捕获了 Unity 主线程的SynchronizationContext,续延会被调度回主线程执行MoveNext()。UniTask 则通过PlayerLoop机制更轻量地调度回主线程,避免SynchronizationContext.Post的额外开销。
- 异常处理统一化:所有在异步方法中抛出的异常(无论在哪个
await挂起/恢复阶段)都会被catch块捕获,并通过builder.SetException存储在Task中。关键点:这意味着调用者必须await才能观察到异常;未等待的Task会默默丢失异常(TaskScheduler.UnobservedTaskException是最后防线)。
代码示例(结合Unity语境)
以下是 MoveNext() 反编译模式在Unity场景中的直观转化,展示编译器如何处理两个异步加载操作。
// 原始 Unity 异步方法(概念层面)
public async UniTask<Texture2D> LoadAndCombineTexturesAsync(string path1, string path2)
{
var tex1 = await Resources.LoadAsync<Texture2D>(path1) as Texture2D;
var tex2 = await Resources.LoadAsync<Texture2D>(path2) as Texture2D;
// 假设有个合并纹理的操作(主线程)
Texture2D combined = Combine(tex1, tex2);
return combined;
}
// 编译器生成的 MoveNext() 简化模式(伪代码,使用UniTask)
void IAsyncStateMachine.MoveNext()
{
try
{
switch (this.state)
{
default: goto Start;
case 0: goto Await1_Continuation;
case 1: goto Await2_Continuation;
}
Start:
// --- 第一个 await 资源加载 ---
var awaiter1 = Resources.LoadAsync<Texture2D>(this.path1).GetAwaiter(); // 注意:Resources.LoadAsync 返回 ResourceRequest,需包装
if (awaiter1.IsCompleted) goto GetResult1; // 同步完成快速路径
this.state = 0;
this.awaiter1 = awaiter1;
// UniTask的构建器不会在此处装箱状态机,而是注册到PlayerLoop
this.builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this);
return; // 等待异步加载完成
Await1_Continuation:
awaiter1 = this.awaiter1; this.awaiter1 = default; this.state = -1;
GetResult1:
this.tex1 = awaiter1.GetResult() as Texture2D;
// --- 第二个 await 资源加载 ---
var awaiter2 = Resources.LoadAsync<Texture2D>(this.path2).GetAwaiter();
if (awaiter2.IsCompleted) goto GetResult2;
this.state = 1;
this.awaiter2 = awaiter2;
this.builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
return; // 再次等待
Await2_Continuation:
awaiter2 = this.awaiter2; this.awaiter2 = default; this.state = -1;
GetResult2:
this.tex2 = awaiter2.GetResult() as Texture2D;
// 方法完成
this.combined = Combine(this.tex1, this.tex2);
this.builder.SetResult(this.combined);
}
catch (Exception e)
{
this.state = -2;
this.builder.SetException(e);
}
}
常见陷阱与最佳实践
goto语句的迷惑性:反编译出的goto和标签,本质上是实现状态机最有效的方式。不要在业务代码中模仿这种写法,而应始终使用await。理解其模式仅用于调试和性能分析。state字段的竞争条件误解:反编译代码中的this.state = num = 0;看似有冗余,但确保了状态在调用AwaitUnsafeOnCompleted之前被正确设置。在自定义 awaiter 或手动注册续延时,必须先设置状态,再注册续延,以防续延被立即同步执行。- 忘记
GetResult():awaiter.GetResult()不仅返回结果,还会重新抛出异步操作中的异常(不同于Task.Wait()的AggregateException)。理解这一点有助于正确处理异常,并明白为何原生Task的异常能被catch块直接捕获。 - 异步方法的
return与MoveNext的return:- 异步方法中的
return;语句(无返回值)对应builder.SetResult(),是方法成功完成的路径。 - 反编译代码中的
return;(在AwaitUnsafeOnCompleted之后)是暂时退出MoveNext(),等待续延。初学者极易混淆这两者。
- 异步方法中的
- 性能关键路径的强制同步:如果确定某个异步操作总是同步完成(如从已加载的缓存中读取),利用
IsCompleted快速路径可完全避免状态机挂起的开销。但这依赖于具体实现,不能作为普遍假设。
6.2.2 MoveNext()方法的通用结构
-
核心概念:
- MoveNext() 是状态机核心:异步方法第一次调用时执行,之后每次从
await恢复时也执行一次。若所有await都走快速路径,则MoveNext()只执行一次。 - 两大执行路径:
- 快速路径:被
await的操作已完成(IsCompleted == true),状态机不暂停,直接取结果并继续。 - 慢速路径:操作未完成,状态机保存状态、注册续延,然后
return交出控制权。
- 快速路径:被
- 伪代码关键元素:
try/catch全覆盖:整个原始异步方法的逻辑被包裹在try块中,任何异常(throw、faulted 任务、同步异常)都会被catch统一捕获,通过builder.SetException()填充到 Task,而非直接抛出。switch跳转表:根据this.state决定从哪个标签恢复执行:default(-1):首次启动,跳转到MethodStart。0, 1, 2…:对应第几个await的恢复点,跳转到Label0A、Label1A等。
- 方法完成:所有逻辑执行完毕后,在
try/catch外调用builder.SetResult()标记 Task 成功完成。
- 状态码细节:
1:未启动/正在执行(两者共用,因为MoveNext不会在这些状态下被重入调用)。2:已完成(仅用于外部检查,MoveNext内部从不读取此状态)。≥0:在某个await处挂起。
- 重要区分:
- 状态机中的
return:出现在慢速路径中,用于在注册续延后退出MoveNext(),控制权交还调用者。 - 原始代码中的
return:被转换为builder.SetResult()/SetException(),位于try/catch之后,表示异步方法整体结束。
- 状态机中的
- MoveNext() 是状态机核心:异步方法第一次调用时执行,之后每次从
-
Unity开发关键点:
- 快速路径与 GC:若异步操作同步完成(如从已加载的缓存中取资源),状态机走快速路径,不挂起、不装箱,几乎零开销。这是
UniTask能在大量同步完成场景(如UniTask.CompletedTask或缓存命中)保持零分配的基础。 - 慢速路径的代价:
- 原生
Task:慢速路径导致状态机装箱(堆分配)+Task对象分配,每帧高频调用会触发 GC。 UniTask:通过将状态机保存在UniTask内部或与PlayerLoop协同,规避装箱,但仍有少量结构体复制与上下文调度开销。
- 原生
- 异常处理统一性:异步方法内的任何异常(包括同步部分)都被
catch捕获并存入Task。这意味着:- 如果调用者不
await这个Task,异常会被静默丢失(直至UnobservedTaskException)。 - 在 Unity 中,必须对异步操作附加生命周期检查或
CancellationToken,避免对象已销毁后异步 Task 仍因异常而触发全局错误。
- 如果调用者不
- switch 跳转的性能:
switch基于整型state的跳转非常高效。但深层异步调用链中,每个MoveNext都会执行自身的switch,嵌套层级过多时可能增加指令开销。应避免过度嵌套异步方法。 - 协程对比:Unity 协程(
IEnumerator)的MoveNext也有类似状态机逻辑,但它是基于yield return的模式,无try/catch统一异常包装,异常处理更脆弱。异步方法的状态机在异常处理和组合性上更优。
- 快速路径与 GC:若异步操作同步完成(如从已加载的缓存中取资源),状态机走快速路径,不挂起、不装箱,几乎零开销。这是
-
代码示例(Unity语境):
// 业务层异步方法:混合快速路径和慢速路径 public async UniTask<int> GetPlayerRankAsync(string playerId) { // 同步检查缓存(可能同步完成) if (cache.TryGetValue(playerId, out int rank)) { // 缓存命中,快速路径:直接返回,MoveNext 不会挂起 return rank; } // 缓存未命中,走慢速路径:请求服务器 rank = await FetchRankFromServerAsync(playerId); cache.Set(playerId, rank); return rank; } // 编译器生成的 MoveNext() 伪代码 void IAsyncStateMachine.MoveNext() { try { switch (this.state) { default: goto MethodStart; case 0: goto AwaitContinuation; // 从 FetchRankFromServerAsync 恢复 } MethodStart: // --- 同步缓存检查部分(无需挂起) --- if (this.cache.TryGetValue(this.playerId, out int rank)) { this.result = rank; goto Completion; // 快速完成,无任何 await 挂起 } // --- 慢速路径开始 --- var awaiter = this.FetchRankFromServerAsync(this.playerId).GetAwaiter(); if (awaiter.IsCompleted) goto GetResult; // 万一也同步完成 this.state = 0; this.awaiter = awaiter; this.builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; // << 状态机中的 return:暂停并等待 AwaitContinuation: awaiter = this.awaiter; this.awaiter = default; this.state = -1; GetResult: rank = awaiter.GetResult(); this.cache.Set(this.playerId, rank); this.result = rank; Completion: // 原始代码中的 return 最终走到这里 this.builder.SetResult(this.result); } catch (Exception e) { this.state = -2; this.builder.SetException(e); } }在 Unity 中,若缓存命中,则整个异步调用在
MoveNext()一次性走完,完全不进入暂停,无 GC 分配,性能接近普通方法调用。 -
常见陷阱与最佳实践:
- 误解快速路径无分配:快速路径虽然不挂起,但
Task/UniTask对象本身可能已在桩方法中创建。对于UniTask,若使用池化或返回已完成常量(UniTask.CompletedTask),可连UniTask实例也省去分配。 return的混淆:修改或手动实现状态机时,容易在try块内写return想表达方法结束,实际上这会退出MoveNext()但未调用SetResult(),导致任务永不完成。务必区分“暂停时的返回”和“完成时的 SetResult”。- 异常被吞:若调用异步方法但不
await,且异步方法内部抛异常,该异常只会存储在返回的Task中而不会崩溃 Unity。这在 UI 按钮等场景下可能造成“按钮无效但无报错”的隐蔽 Bug。最佳实践:总是await或显式处理Task异常。 - 重入风险:
MoveNext()设计上不应被重入(同时被多次调用)。如果自定义 awaiter 在OnCompleted中同步调用续延,可能导致MoveNext()在上一帧还未返回时再次进入,破坏state一致性。UniTask 的PlayerLoop调度天然避免了这种重入。 - 调试状态机的帮助:当异步方法卡死、任务永远不完成时,可检查状态机的
state字段值:若为某个非负值,说明卡在对应的await处;若为1,则可能在await之后的同步代码中死循环。
- 误解快速路径无分配:快速路径虽然不挂起,但
6.2.3 详探await表达式
-
核心概念:
编译器将一行await表达式拆解为精确的10个步骤,并生成对应的switch/goto代码。执行流程表:
- 获取 Awaiter:调用操作数的
GetAwaiter(),将 awaiter 存入栈。 - 检查完成状态:判断
awaiter.IsCompleted。 - 分支选择:
- 已完成(快速路径):直接跳转到步骤 9(获取结果)。
- 未完成(慢速路径):进入步骤 4。
- 记录位置:设置
this.state为当前await对应的编号。 - 保存 Awaiter:将栈上的 awaiter 复制到
this.awaiter字段。 - 注册续延与装箱:调用
builder.AwaitUnsafeOnCompleted(ref awaiter, ref this)。内部可能会对状态机进行装箱,并将MoveNext注册为 awaiter 的续延。 - 暂停并返回:
return(退出MoveNext(),控制权交还调用方/续延安排者)。 - 恢复与清理:续延被触发时,
MoveNext再次进入,通过switch跳转到恢复标签。将 awaiter 从字段取出到栈,并将字段置为default(辅助 GC)。 - 获取结果/抛出异常:调用
awaiter.GetResult()。若异步操作失败,在此处重新抛出异常。 - 执行后续代码:继续执行
await之后的原始代码。
关键对比:
- 快速路径:跳过步骤 4-8,无挂起、无字段保存、无装箱。
- 慢速路径:完整经历 10 步,涉及状态保存、装箱、续延调度和上下文捕获。
- 获取 Awaiter:调用操作数的
-
Unity开发关键点:
- 快速路径的极致性能:Unity 中加载已缓存的资源(如
Addressables已加载的资源)时,异步操作可能直接同步完成。编译器生成的快速路径确保了这种场景下零挂起、零装箱,这是UniTask在许多场景比原生Task更快的基础。 GetResult()与异常传播:所有异常(包括OperationCanceledException)都在步骤 9 的GetResult()中抛出。理解这一点能避免混淆:即使不访问Result属性,异常仍会在此处抛出,并被MoveNext的try/catch捕获。AwaitUnsafeOnCompleted的“不安全”:与AwaitOnCompleted不同,此方法不捕获ExecutionContext,性能更高。UniTask 大量依赖此方法以减少分配。但在需要模拟环境上下文(如AsyncLocal)时必须注意。- awaiter 字段的复用与 GC:步骤 8 中的
this.awaiter = default并非总是发生(发布构建可能省略,因为状态机可能要装箱且字段随对象回收)。但如果 awaiter 持有大型资源引用,不及时清空会延长生命周期,导致内存峰值。 - 状态机重入防护:如果 awaiter 在
OnCompleted中同步调用续延(步骤 6 立即触发步骤 8),MoveNext会发生重入。AsyncTaskMethodBuilder内部有保护机制,但自定义 awaiter 时必须避免。
- 快速路径的极致性能:Unity 中加载已缓存的资源(如
-
代码示例(结合Unity语境):
// 原始代码 public async UniTask<Texture2D> LoadTextureAsync(string path) { // 假设 LoadFromCacheAsync 内部会先检查缓存,可能同步完成 Texture2D tex = await LoadFromCacheOrBundleAsync(path); return tex; } // 编译器为其中一行 await 生成的展开代码(伪代码) void IAsyncStateMachine.MoveNext() { // ... switch ... MethodStart: // 1. 获取Awaiter ResourceRequestAwaiter awaiter = LoadFromCacheOrBundleAsync(this.path).GetAwaiter(); // 2. 检查是否已完成(快速路径:资源已缓存) if (awaiter.IsCompleted) { goto GetResult; // 跳转到第9步 } // ---- 慢速路径:资源需从 AssetBundle 加载 ---- // 4. 记录位置 this.state = 0; // 5. 保存Awaiter this.awaiterField = awaiter; // 6. 注册续延并装箱 this.builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); // 7. 暂停并返回 return; // 8. 恢复标签 AwaitContinuation: awaiter = this.awaiterField; this.awaiterField = default; // 清理字段,避免 Texture 引用被状态机一直抓着 this.state = -1; // 9. 获取结果(快速和慢速路径汇合点) GetResult: Texture2D tex = awaiter.GetResult(); // 若加载失败,异常在此抛出 // 10. 后续代码 this.result = tex; // ... 进入完成路径 } -
常见陷阱与最佳实践:
- 修改共享状态的位置错误:在
await前后修改共享状态(如 UI 的interactable),务必注意快速路径下修改是同步连续发生的,而慢速路径是挂起后恢复发生。时序差异可能导致逻辑 Bug。 - 忘记
GetResult()会抛异常:以为只有task.Result会抛AggregateException,但await的GetResult()直接抛出原始异常。在需要类型化捕获异常时,需考虑异常类型。 AsyncLocal<T>与AwaitUnsafeOnCompleted:若在 Unity 中模拟AsyncLocal实现上下文传递,AwaitUnsafeOnCompleted不会自动恢复ExecutionContext,导致上下文丢失。此时需显式使用AwaitOnCompleted或手动处理。- 操作数求值的顺序:步骤 1 之前,
await的操作数已完全求值。例如await GetTaskAsync()中的GetTaskAsync()在GetAwaiter()调用前就已执行。确保操作数求值无副作用,或副作用时序符合预期。 - 性能权衡:对于极度高频且几乎总是同步完成的异步操作,构建一个永远走快速路径的自定义 awaitable(类似
ValueTask)可以完全消除状态机挂起开销,但实现复杂度极高,一般直接用UniTask即可。
- 修改共享状态的位置错误:在
6.3 控制流如何影响MoveNext()
6.3.1 await表达式之间的控制流很简单
-
核心概念:
await之间的普通控制流:如果循环、条件等控制流不包含await表达式,它们只是MoveNext()方法内的普通同步代码,状态机复杂度不会增加。- 局部变量(如循环计数器
i)的生命周期不跨越await,因此不会被提升为字段。 - 反编译后的代码变化仅是将原来的单条语句替换为相应的控制流结构。
- 局部变量(如循环计数器
- 循环内部包含
await时:状态机的复杂性会急剧上升,因为编译器必须处理:- 循环变量的提升:
for循环的迭代变量(或foreach的隐式变量)必须跨越await存活,因此会变成状态机的字段。 - 循环状态的保存:需要在暂停后从循环中间恢复执行,涉及额外的状态编号和跳转标签。
- 可能的死循环风险:异步循环若被无限制地挂起恢复,需确保条件变量正确更新。
- 循环变量的提升:
-
Unity开发关键点:
- 避免在热路径的循环内
await:如果在Update、FixedUpdate或每帧调用的方法中,有while循环内await,会导致状态机频繁挂起和恢复,产生 GC 分配,并可能阻塞主线程逻辑。 - 常见场景:
- 等待条件满足:例如
while (!player.IsReady) await UniTask.Yield();每帧检查,此模式会生成一个带循环的状态机,每帧MoveNext被调用一次。 - 异步资源批量加载:
foreach (var path in paths) { textures.Add(await LoadAsync(path)); }会导致状态机反复挂起,每次恢复执行foreach的下一次迭代。
- 等待条件满足:例如
- 性能优化的替代方案:
- 使用
UniTask.WhenAll并行加载,而非循环内await。 - 对于等待条件,考虑使用事件或
UniTask.WaitUntil,它内部已优化了轮询和取消。
- 使用
foreach与异步的特殊问题:foreach使用IEnumerator<T>,若循环内有await,编译器会将枚举器提升为字段,并在每次恢复时继续迭代。注意IEnumerator<T>是引用类型,可能造成意外堆分配。
- 避免在热路径的循环内
-
代码示例(Unity语境):
// 简单情况:循环在await之间,无额外复杂度 public async UniTask SimpleLoopBetweenAwaits() { await UniTask.Delay(1000); // 这个循环不包含await,是纯同步代码 for (int i = 0; i < 100; i++) { Debug.Log("Tick: " + i); } await UniTask.Delay(1000); } // 复杂情况:循环内部有await,导致i提升为字段 public async UniTask LoopWithAwaitInside() { // i会被提升为状态机字段 for (int i = 0; i < 100; i++) { Debug.Log("Before await: " + i); await UniTask.Yield(); // 暂停,后续帧恢复 Debug.Log("After await: " + i); } }在
LoopWithAwaitInside中,编译器生成的状态机大致结构:// 伪代码 void MoveNext() { switch (this.state) { case 0: goto AfterFirstAwait; ... } // 循环初始化 for (this.i = 0; this.i < 100; this.i++) { Debug.Log("Before..."); awaiter = UniTask.Yield().GetAwaiter(); if (!awaiter.IsCompleted) { this.state = 0; this.awaiter = awaiter; builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; // 暂停 } AfterFirstAwait: awaiter.GetResult(); Debug.Log("After..."); // 循环继续 } // 完成 } -
常见陷阱与最佳实践:
- 无限循环与取消令牌:
while(true) { await ... }必须结合CancellationToken,否则 GameObject 销毁后循环仍在后台运行,导致无法回收或崩溃。使用UniTask.WaitUntil或UniTask.WaitWhile时务必传入cancellationToken。 - 循环中异常处理:若循环体内
await抛出异常,且被状态机catch捕获,循环可能提前退出。确保业务逻辑符合预期,或使用try/catch包裹单个迭代。 foreach的堆分配:foreach内部有await时,枚举器对象可能被装箱到状态机字段,产生额外 GC。在性能敏感处,可改用for循环和索引访问。- 状态机体积膨胀:深层嵌套的循环 +
await会生成大量状态码和goto标签,导致 IL 体积增大,可能影响 AOT 编译时间。保持异步方法职责单一。 - 调试困难:循环内部
await的状态跳转逻辑在调试器中很反直觉,单步执行会不断进出MoveNext。了解其原理有助于理解调用堆栈。
- 无限循环与取消令牌:
6.3.2 在循环中使用await
-
核心概念:
- 状态机逻辑“去结构化”:当
await出现在循环体内部时,编译器无法用原生的 C#for/while结构直接表示生成代码,因为从await恢复时需要 从循环体中间跳转进入,这违反了 C# 的goto规则(不能从外部跳入循环作用域)。 - 编译器必须做的事:
- 解构循环:将
for循环拆解为等效的goto与标签组合,手动实现初始化、条件判断和迭代步进。 - 提升循环变量:循环变量(如
i)必须提升为状态机字段,以便在暂停和恢复时保持值。 - 多个入口点:需要设计标签使执行流能直接跳入循环体(
ForLoopBody)或循环条件判断处(ForLoopCondition)。
- 解构循环:将
- 生成代码特征(见代码清单6-8):
- 初始化:
this.i = 0; goto ForLoopCondition; - 循环体入口:
ForLoopBody:标签,包含await前的代码。 - 挂起与恢复:在
await处慢速路径挂起,续延恢复后跳转到AwaitContinuation,然后继续执行循环剩余部分(Console.WriteLine("After..."); this.i++;)。 - 条件判断:
ForLoopCondition:检查this.i < 3,决定是跳回ForLoopBody还是退出循环。 - 无原生循环:最终 IL 中没有
for或while结构体,全为goto跳转。
- 初始化:
- 反编译器的局限:反编译器可能尝试将
goto/标签重构成while/for,但往往生成非法 C# 代码(如用goto跳入while循环体内部),因为 IL 允许而 C# 语法不允许。手动查看原始 IL 或信任编译器生成是必要的。
- 状态机逻辑“去结构化”:当
-
Unity开发关键点:
-
每帧执行的异步循环:
这种模式被编译为带有循环条件判断和goto的状态机,每一帧MoveNext被调用一次,沿着ForLoopCondition→ForLoopBody→ 挂起 → 恢复的路径执行。理解这一点有助于分析帧内耗时。while (!player.IsReady) { await UniTask.Yield(); // 每帧检查一次 } -
性能影响:
- 分配:这类状态机在被挂起时仍可能被装箱(取决于
UniTask的具体版本和上下文),产生 GC 压力。UniTask.Yield每帧创建新的YieldAwaitable实例(但可池化)。 - 体积:复杂的嵌套循环 +
await会生成庞大的switch/goto结构,增加 IL 体积,在 IL2CPP 下可能导致较大的 C++ 生成代码。
- 分配:这类状态机在被挂起时仍可能被装箱(取决于
-
取消令牌的必需性:异步循环如果没有
CancellationToken,当MonoBehaviour被销毁或场景卸载时,状态机会继续尝试恢复,访问已销毁的对象或进入无效状态。务必在循环条件中包含!cancellationToken.IsCancellationRequested检查,或使用UniTask.WaitUntil的带取消重载。 -
替代方案:
- 对于“等待条件”类循环,优先用
UniTask.WaitUntil(() => player.IsReady, PlayerLoopTiming.Update, cancellationToken),它内部优化了轮询逻辑,且自带取消支持。 - 对于“重复执行”类循环,考虑使用
UniTask.AsyncReactiveProperty或事件驱动模式,完全避免状态机。
- 对于“等待条件”类循环,优先用
-
-
代码示例(Unity语境):
// 业务代码:等待玩家就绪,然后执行操作 public async UniTask WaitForPlayerReadyAsync(Player player, CancellationToken token) { Debug.Log("Before loop"); // i 会被提升为状态机字段 for (int i = 0; i < 3; i++) { Debug.Log($"Before await in loop, attempt {i}"); // 等待一秒或直到取消 await UniTask.Delay(1000, cancellationToken: token); Debug.Log($"After await in loop, attempt {i}"); } Debug.Log("After loop delay"); } // 编译器为上述方法生成的 MoveNext() 核心逻辑(概念模式) void IAsyncStateMachine.MoveNext() { try { switch (this.state) { default: goto MethodStart; case 0: goto AwaitContinuation; } MethodStart: Debug.Log("Before loop"); this.i = 0; goto ForLoopCondition; ForLoopBody: Debug.Log($"Before await in loop, attempt {this.i}"); var awaiter = UniTask.Delay(1000, cancellationToken: this.token).GetAwaiter(); if (awaiter.IsCompleted) goto GetResult; this.state = 0; this.awaiter = awaiter; this.builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; // 挂起,等待1秒或取消 AwaitContinuation: awaiter = this.awaiter; this.awaiter = default; this.state = -1; GetResult: awaiter.GetResult(); // 如果是取消,Token 会在此抛出异常 Debug.Log($"After await in loop, attempt {this.i}"); this.i++; ForLoopCondition: if (this.i < 3) goto ForLoopBody; Debug.Log("After loop delay"); // ... 完成 } catch (Exception e) { /* 设置异常 */ } } -
常见陷阱与最佳实践:
- 忘记
CancellationToken:在异步循环内部,即使外层调用传递了CancellationToken,也必须在每次await时传入(如await UniTask.Delay(..., cancellationToken: token))。否则,循环可能在收到取消信号后仍继续挂起并等待,导致任务泄露。 - 反编译误读:在调试器中看到生成的
goto跳入看似“非法”的循环结构,不要惊慌。这是 IL 层面的正常行为,不是错误。应直接关注this.state和this.i的值来追踪逻辑。 - 循环内无条件
await:如果循环体内必然走慢速路径,每执行一次循环体就挂起一次,恢复时继续。要确保循环有明确的退出条件,且退出条件检查在await之后,否则可能多执行一次迭代。 - 异常导致循环退出:循环体内的
await如果抛出异常(如取消异常),会直接跳出整个try块(循环被整个包在try内),循环提前终止。如果想只中断本次迭代,需要在循环内部添加单独的try/catch。 - IL2CPP 的限制:某些复杂的
goto/标签组合可能触发 IL2CPP 编译器的边缘问题,特别是在旧版本 Unity 中。保持异步方法逻辑尽量简单,避免在循环内部嵌套更多try/catch和using。
- 忘记
6.3.3 在try/finnaly块中使用await表达式
-
核心概念:
- “蹦床”技巧:当
await在try块内部时,从暂停恢复的控制流需要跳转到try块内部,但 IL 规则禁止从外部直接跳入受保护区域。
编译器生成的解决方案:在try块最前面放置一个内层switch跳转表作为“蹦床”,恢复时先跳到try块的起始标签(AwaitContinuationTrampoline),再由蹦床内的switch跳转到try块内部真正的恢复点(AwaitContinuation)。 finally块的条件执行:finally块在三种情况下执行:①try块正常结束;②try块抛出异常;③await导致状态机暂停。- 关键例外:当状态机暂停时(
await未完成),原代码的finally不应该执行,因为try块并未结束,只是暂时挂起。 - 编译器通过检查
num(state字段的局部副本)来区分:num < 0:状态机仍在执行(未暂停),此时finally块正常执行。num >= 0:状态机已暂停,则跳过finally块,等恢复后在try块内继续,finally将在最终离开try时执行。
- 生成代码结构(见代码清单 6-10):
switch (num) { default: goto MethodStart; case 0: goto AwaitContinuationTrampoline; } MethodStart: Console.WriteLine("Before try"); AwaitContinuationTrampoline: try { switch (num) { default: goto TryBlockStart; case 0: goto AwaitContinuation; } TryBlockStart: Console.WriteLine("Before await"); TaskAwaiter awaiter = Task.Delay(this.delay).GetAwaiter(); if (awaiter.IsCompleted) { goto GetAwaitResult; } this.state = num = 0; this.awaiter = awaiter; this.builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; AwaitContinuation: awaiter = this.awaiter; this.awaiter = default(TaskAwaiter); this.state = num = -1; GetAwaitResult: awaiter.GetResult(); Console.WriteLine("After await"); } finally { if (num < 0) { Console.WriteLine("In finally block"); } } Console.WriteLine("After finally block");- 外层
switch根据state跳转到AwaitContinuationTrampoline(state == 0)或MethodStart(首次)。 AwaitContinuationTrampoline位于try块外面,但紧接着进入try块。try块内部有自己的内层switch:default跳到TryBlockStart(首次执行),case 0跳到AwaitContinuation(恢复点)。finally块包含if (num < 0)判断,仅在状态机未暂停时执行清理代码。
- 与
using语句的关系:using编译为try/finally(Dispose在finally中调用),因此异步方法中使用using会自动应用上述所有规则。
- “蹦床”技巧:当
-
Unity开发关键点:
- 异步资源清理的陷阱:当你在
using块内await时,若异步操作挂起,Dispose不会在挂起时调用。这正符合预期,因为资源仍在使用中。但需注意:如果取消操作发生,异常可能使try提前退出,finally才执行清理。 - 与协程的对比:Unity 协程不支持
try/finally跨越yield return,导致清理代码必须手动写在所有退出路径。异步方法通过编译器生成的“蹦床”和条件finally,能自动保证资源在异步操作完成或异常时被清理,极大提升安全性。 await与MonoBehaviour生命周期:- 若
try块内await时GameObject被销毁,后续恢复时finally可能仍会执行(取决于异常传播)。 - 最佳实践:在
finally中检查this != null或使用CancellationToken提前中断,避免在已销毁对象上执行清理。
- 若
- 性能影响:
- 蹦床结构增加了一级额外的
switch跳转和内层标签,但开销极微。 finally的条件检查(if (num < 0))在每个await挂起时执行,几乎无性能负担。- 更重要的是避免滥用:每个
using都会生成try/finally,深层嵌套会显著增加状态机复杂度。
- 蹦床结构增加了一级额外的
- UniTask 的特殊处理:UniTask 完全遵循相同的编译器生成模式,但其
AsyncUniTaskMethodBuilder可避免状态机装箱,即使包含复杂的try/finally结构,也能保持低 GC 压力。
- 异步资源清理的陷阱:当你在
-
代码示例(结合Unity语境):
// Unity 业务代码:使用 using 和 await 加载并处理网络流 public async UniTask DownloadAndProcessAsync(string url, CancellationToken token) { Debug.Log("Before try block"); await UniTask.Yield(); // 让出主线程一帧 try { Debug.Log("Before await in try"); using (var request = UnityWebRequest.Get(url)) { // 异步发送请求 await request.SendWebRequest().WithCancellation(token); Debug.Log("After await in try (download complete)"); // 处理下载数据... } // using 退出时调用 request.Dispose() } finally { Debug.Log("In finally block"); // 无论成功、异常或取消,最终都会执行 } Debug.Log("After finally block"); }编译器为上述
try/finally部分生成的核心逻辑:// MoveNext() 伪代码 void MoveNext() { switch (this.state) { default: goto MethodStart; case 0: goto AwaitContinuationTrampoline; } MethodStart: Console.WriteLine("Before try block"); await UniTask.Yield(); // ... (省略第一个 await 的详细代码,假设它不挂起) // 进入蹦床 AwaitContinuationTrampoline: try { switch (this.state) { default: goto TryBlockStart; case 0: goto AwaitContinuation; } TryBlockStart: Console.WriteLine("Before await in try"); var request = UnityWebRequest.Get(this.url); var awaiter = request.SendWebRequest().WithCancellation(this.token).GetAwaiter(); if (awaiter.IsCompleted) goto GetResult; this.state = 0; this.awaiter = awaiter; this.builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; // 挂起等待网络请求 AwaitContinuation: awaiter = this.awaiter; this.awaiter = default; this.state = -1; GetResult: awaiter.GetResult(); // 获取结果或抛出异常 Console.WriteLine("After await in try"); // 离开 using,request.Dispose() 在此处调用(正常执行路径) } finally { if (this.state < 0) // 即 num < 0,状态机未暂停 { Console.WriteLine("In finally block"); // 如果 try 中发生异常或完成,执行清理;如果暂停,则跳过 } } Console.WriteLine("After finally block"); // ... 完成 } -
常见陷阱与最佳实践:
- 在
finally中执行异步操作:虽然 C# 6 允许finally中使用await,但会引入额外的状态机和蹦床,使逻辑高度复杂。Unity 中应尽量避免,可改用try-catch加标志位,或使用UniTask的Forget谨慎处理。 - 误解
finally的执行时机:如果状态机在await处挂起,finally不会立即执行。这意味着在try块内获取的锁或非托管资源,在挂起期间仍然持有。务必在await前释放不必要的锁,或使用SemaphoreSlim等异步兼容的同步原语。 - 取消令牌与
finally的结合:当CancellationToken触发取消时,await抛出OperationCanceledException,导致try块退出,finally块会执行。这是清理资源的理想时机,但要确保清理代码本身不依赖已取消的操作。 - 蹦床与调试:反编译代码中看到多个嵌套
switch和goto时,不要慌张。只需追踪this.state的值,即可判断当前在哪个await步骤。蹦床标签通常以Trampoline命名,表明其跳板作用。 - 避免过深的
using嵌套:每个using都会增加一层try/finally,导致状态机字段增多、蹦床层级加深。对于多个顺序资源,可考虑一个using内嵌多个await,而非多个独立using,或使用DisposeAsync(C# 8)简化。 finally中的异常:如果finally块自身抛出异常,且try块也有异常,则原异常会被覆盖。在 Unity 中,这可能掩盖网络请求的真实错误,导致难以排查。应确保finally中的清理代码是安全的。
- 在
6.4 执行上下文与 Await 的上下文贯穿
-
核心概念:
- 执行上下文(ExecutionContext):它是所有其他上下文(
SecurityContext、CallContext等)的容器,用于在不同代码间透明地传递环境信息。异步方法在await恢复后,必须确保代码在同一个执行上下文中运行(即使线程可能改变)。 - 上下文的“贯穿”:执行上下文从
await表达式自动传递到续延的过程称为“贯穿”。 - 两个
OnCompleted方法的区别:INotifyCompletion.OnCompleted:普通方法,任何代码都可调用。实现者内部应负责捕获并恢复执行上下文(通过ExecutionContext.Capture()和ExecutionContext.Run()),以确保上下文贯穿。ICriticalNotifyCompletion.UnsafeOnCompleted:被[SecurityCritical]标记,仅可信代码(如框架的AsyncTaskMethodBuilder)可调用。它允许不贯穿上下文,而由调用方(基础设施)在外部完成一次统一的捕获和恢复,避免多次重复捕获(优化)。
- 编译器的选择:
- 如果 awaiter 实现了
ICriticalNotifyCompletion,编译器调用builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine)。 - 否则调用
builder.AwaitOnCompleted(...)。 AsyncTaskMethodBuilder的两个方法都会保证上下文贯穿:AwaitOnCompleted依赖 awaiter 自身的实现,AwaitUnsafeOnCompleted则先捕获上下文,再调用 awaiter 的UnsafeOnCompleted。
- 如果 awaiter 实现了
- ConfigureAwait 的不同:
Task.ConfigureAwait(false)影响的是同步上下文(SynchronizationContext)的捕获,而非执行上下文。执行上下文总是需要贯穿(安全需求),不能通过ConfigureAwait阻断。
- 执行上下文(ExecutionContext):它是所有其他上下文(
-
Unity开发关键点:
- 主线程上下文与执行上下文:
- Unity 的主线程
SynchronizationContext负责将延续调度回主线程。即使执行上下文贯穿(保持不变),但线程可能仍是主线程。 - 若使用
ConfigureAwait(false),会跳过同步上下文捕获,续延可能在线程池线程执行,此时不能访问 Unity 对象。但执行上下文依然贯穿,不影响安全性。
- Unity 的主线程
UnsafeOnCompleted的性能优化:- 几乎所有 .NET 框架的 awaiter(包括
TaskAwaiter)都实现了ICriticalNotifyCompletion,因此编译器会走AwaitUnsafeOnCompleted路径。 - 这避免了
AsyncTaskMethodBuilder和 awaiter 各捕获一次上下文的重复开销。 - UniTask 的
Awaiter同样实现了ICriticalNotifyCompletion,结合结构体状态机,可达到极低的分配与上下文传递开销。
- 几乎所有 .NET 框架的 awaiter(包括
- 自定义 awaiter 时的注意事项:
- 如果在 Unity 中为
AsyncOperation或UnityWebRequest编写自定义 awaiter,若打算实现ICriticalNotifyCompletion,应让UnsafeOnCompleted不处理执行上下文,交给构建器负责。若只实现INotifyCompletion,则需手动在OnCompleted中调用ExecutionContext.Capture/Run或使用AsyncOperation.completed时注意线程安全。 - 在实际项目中,极少需要自行处理执行上下文,因为 UniTask 等框架已封装好。只需明白为何有两套方法即可。
- 如果在 Unity 中为
- IL2CPP 下的
ExecutionContext:- IL2CPP 环境下
ExecutionContext仍然存在,主要用于安全相关场景。大多数 Unity 项目(移动端)不依赖代码访问安全,但AsyncLocal<T>依赖执行上下文流动。 - 使用
AsyncLocal<T>时,确保其值会在await后正确恢复。由于执行上下文总是贯穿,该功能在 IL2CPP 中同样有效。
- IL2CPP 环境下
AwaitUnsafeOnCompleted与状态机装箱的关系:builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine)内部可能会触发状态机装箱(对于Task),并注册续延。理解此调用是执行上下文贯穿和装箱的汇合点,对调试异步代码很重要。
- 主线程上下文与执行上下文:
-
代码示例(结合Unity语境):
// 示例:演示 AsyncLocal 在异步方法中的流动(依赖执行上下文) private static AsyncLocal<string> currentUser = new AsyncLocal<string>(); public async UniTask ProcessUserRequestAsync(string userId) { currentUser.Value = userId; // 设置执行上下文中的值 Debug.Log($"Before await, current user: {currentUser.Value}"); // 假设进行网络请求 await UniTask.Delay(1000); // 状态机在此挂起 // 恢复后,由于执行上下文贯穿,currentUser.Value 仍然有效 Debug.Log($"After await, current user: {currentUser.Value}"); // 即使线程可能改变(如果 ConfigureAwait(false)),执行上下文不丢失 }在 Unity 中,
AsyncLocal可用于无侵入地传递每帧或每个请求的状态,例如性能监控的 TraceId。 -
常见陷阱与最佳实践:
- 混淆执行上下文与同步上下文:
- 常见误区:认为
ConfigureAwait(false)会阻止所有上下文传递。实际上它只跳过SynchronizationContext,执行上下文仍会贯穿。因此AsyncLocal仍可用。
- 常见误区:认为
- 自定义 awaiter 忘记实现
ICriticalNotifyCompletion:- 如果自定义 awaiter 只实现
INotifyCompletion,则每次await都会触发 awaiter 内部的上下文捕获,可能导致性能下降。优先实现ICriticalNotifyCompletion,并让UnsafeOnCompleted不做上下文处理。
- 如果自定义 awaiter 只实现
- 在
UnsafeOnCompleted中调用ExecutionContext.Run:- 非框架代码不应在
UnsafeOnCompleted中手动贯穿上下文,这可能导致上下文重复捕获或死锁。应遵守约定:只有构建器负责上下文。
- 非框架代码不应在
- IL2CPP 中
AsyncLocal的内存泄漏:AsyncLocal的值在每次await时被捕获,若保存大对象且异步操作长时间未完成,可能导致内存占用持续增长。在 Unity 中注意及时重置或取消长时间异步操作。
- 调试上下文丢失:
- 当异步方法中的
AsyncLocal值意外丢失时,检查是否使用了不正确的自定义 awaiter 或在unsafe上下文中手动调用UnsafeOnCompleted并绕过了构建器。
- 当异步方法中的
- 混淆执行上下文与同步上下文:
6.5 自定义 Task 类型与 Builder 模式
-
核心概念:
- 自定义 Task 的条件:必须提供一个配套的 Builder 类型,并用
[AsyncMethodBuilder]特性标注。编译器会为async方法生成调用 Builder 各方法的代码,方式与AsyncTaskMethodBuilder完全一致。 - Builder 的核心方法(按调用顺序):
Create():静态方法,创建 Builder 实例,由桩方法调用。Start(ref TStateMachine):启动状态机,内部调用stateMachine.MoveNext(),然后返回Task。Task属性:返回与异步方法对应的Task对象(例如CustomTask<T>)。AwaitOnCompleted(ref TAwaiter, ref TStateMachine)/AwaitUnsafeOnCompleted(...):在await表达式挂起时调用,负责捕获执行上下文、处理状态机装箱并注册续延。SetStateMachine(IAsyncStateMachine):在状态机装箱后被调用,确保 Builder 持有装箱后的引用。SetResult(T)/SetException(Exception):异步方法成功完成或失败时,由状态机在try/catch之后调用,设置最终结果。
- 与标准
Task的等价性:编译器不关心 Builder 内部实现,只要方法签名和特性匹配即可。因此可设计出零分配、可池化的Task类型(如ValueTask和UniTask)。
- 自定义 Task 的条件:必须提供一个配套的 Builder 类型,并用
-
Unity开发关键点:
- UniTask 的本质:
UniTask和UniTask<T>正是利用自定义 Builder(AsyncUniTaskMethodBuilder)实现的高性能异步类型,完全替代了原生Task。 - 零分配的秘密:
AsyncUniTaskMethodBuilder的Start方法不会立即创建Task对象,而是将状态机存储在一个UniTask值类型的字段中(或使用对象池),直到需要时才创建。AwaitUnsafeOnCompleted中,UniTask 直接将续延注册到PlayerLoop,避免装箱状态机,也避免了原生Task的堆分配。
- 与 Unity 生命周期的集成:
- UniTask 的 Builder 通过
PlayerLoopTimer等机制将回调调度到主线程,同时支持CancellationToken自动取消。 - 通过自定义 Builder,可以在
SetResult时唤醒等待的协程或直接调用MoveNext,无需额外的Task包装。
- UniTask 的 Builder 通过
- 性能收益:在每帧频繁调用的异步方法(如资源加载、网络轮询)中,使用 UniTask 替代
Task可消除几乎全部的 GC 分配,显著降低手机端的卡顿。 - 自定义 Builder 的扩展点:除了 UniTask,还可为特定场景设计更专用的异步类型,例如用于 ECS 的异步 Builder(直接与
EntityCommandBuffer交互),但通常直接使用 UniTask 已足够。
- UniTask 的本质:
-
代码示例(结合Unity语境):
// 使用 UniTask 的自定义 Task 类型(无需知道 Builder 细节) public async UniTask<int> LoadPlayerLevelAsync(string playerId) { // 这段代码会被编译器转换为对 AsyncUniTaskMethodBuilder<int> 的调用 string json = await FetchPlayerDataAsync(playerId); int level = JsonUtility.FromJson<PlayerData>(json).level; return level; } // 如果你想自定义一个极简的异步类型(仅作教学,实际使用 UniTask) // 1. 定义 Task 类 [AsyncMethodBuilder(typeof(MyTaskBuilder<>))] public class MyTask<T> { // 内部状态... } // 2. 定义 Builder 结构体(必须满足约定) public struct MyTaskBuilder<T> { public static MyTaskBuilder<T> Create() => new MyTaskBuilder<T>(); public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine { stateMachine.MoveNext(); // 同步执行第一步 } public MyTask<T> Task { get; private set; } public void AwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine { // 简化:直接将 MoveNext 作为回调 var boxed = stateMachine; // 装箱 boxed.SetStateMachine(boxed); awaiter.OnCompleted(boxed.MoveNext); } public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : ICriticalNotifyCompletion where TStateMachine : IAsyncStateMachine { var boxed = stateMachine; boxed.SetStateMachine(boxed); awaiter.UnsafeOnCompleted(boxed.MoveNext); } public void SetStateMachine(IAsyncStateMachine stateMachine) { } public void SetResult(T result) { /* 完成Task */ } public void SetException(Exception e) { /* 记录异常 */ } }在 Unity 项目中几乎不需要手写 Builder,但理解其机制有助于深入掌握 UniTask 的优化原理。
-
常见陷阱与最佳实践:
- 忘记
SetStateMachine的装箱处理:如果 Builder 的AwaitUnsafeOnCompleted内没有正确调用boxed.SetStateMachine(boxed),可能导致异步操作完成后无法恢复状态机,Task 永远不完成。 - Builder 必须是值类型:编译器会将 Builder 作为状态机的一个字段,且为了性能,Builder 应设计为结构体,避免额外的堆分配。但结构体内部可能持有引用类型(如
Task)。 SetResult和SetException的线程安全:异步操作可能在后台线程完成,确保这两个方法内部能安全地转换状态并将结果传回消费者(通常需使用Interlocked或ManualResetValueTaskSourceCore等)。- 避免在 Builder 中创建不必要的对象:原生
AsyncTaskMethodBuilder在每次Start时都会 new 一个Task。UniTask 的优化正是利用对象池和状态机嵌入来避免这一点。自定义 Builder 时也应考虑池化。 - 不要忘记
[AsyncMethodBuilder]特性:缺少特性会导致编译错误,因为编译器不知如何为async方法生成桩代码。 - IL2CPP 兼容性:自定义 Builder 涉及泛型结构体和接口约束,在 IL2CPP 下必须确保所有用到的类型都能被 AOT 编译器正确静态分析,否则可能引发
MissingMethodException。使用 UniTask 已解决此问题。
- 忘记

浙公网安备 33010602011771号