AIGC标识 [深入解析C#] 第6章:异步原理

📦第 6 章——异步原理

  • 核心概念

    • 编译器转换:C# 编译器将 async 方法编译成一个状态机,该状态机负责在 await 处暂停方法,并在异步操作完成后从断点处恢复执行。最终生成的 IL 代码不含 async/await 关键字。
    • 构建器交互:框架提供的 AsyncTaskMethodBuilder(或自定义构建器)负责创建并管理 Task,将状态机的状态变化与返回的 Task 生命周期联动。
    • 调试与发布差异
      • 调试构建:状态机生成为(便于调试,支持编辑并继续)。
      • 发布构建:状态机生成为结构体(避免堆分配,提升性能)。
    • 层层递进的复杂性:异步原理的核心围绕 await 表达式展开,涉及 awaiter 的获取、续延(continuation)的注册以及 执行上下文(ExecutionContext) 的贯穿。
    • 可扩展性:理解状态机原理后,可自由实现自定义 task/awaitable 类型,不再局限于 TaskValueTask
  • Unity开发关键点

    • 性能敏感:Unity 游戏循环对 GC 极其敏感,发布构建中使用结构体状态机可避免堆分配,这正是 UniTask 实现零分配异步的核心基础(通过结构体状态机 + 自定义 AsyncUniTaskMethodBuilder)。
    • IL2CPP 与状态机:AOT 编译环境下,结构体状态机的泛型特化更彻底,有助于减少元数据开销。但需注意 IL2CPP 对某些异步模式的支持限制(如 async void 错误处理)。
    • 主线程上下文:Unity 的 SynchronizationContext 会将 await 后的代码调度回主线程。编译器生成的状态机会自动捕获并恢复上下文,理解这一点可避免死锁(如误用 Task.Wait() 或在主线程 await 未配置 .ConfigureAwait)。
    • 自定义 Awaitable:Unity 资源加载操作(AssetBundleRequestUnityWebRequestAsyncOperation)本身是可等待的,但不符合 GetAwaiter 模式的标准。可以基于状态机原理封装为 UniTask,实现与 async/await 的无缝对接。
    • 协程与异步的桥接:理解状态机后,可以轻松将协程(IEnumerator)包装成 await 可用的 TaskUniTask,反之亦然。
  • 代码示例(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 voidasync void 方法无法被等待,异常会直接抛到 Unity 主线程导致崩溃,且难以测试。除 MonoBehaviour 事件函数(如 Start 改用 async UniTaskVoid)外,始终返回 TaskUniTask

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,依此类推)。
    • 执行流程

      img

      1. 桩方法创建状态机,调用 builder.Start(ref machine)
      2. Start 内部调用 MoveNext(),开始执行直至遇到第一个未完成的 await
      3. 暂停时,状态机保存 state,记录 awaiter,并向 awaiter 注册 MoveNext 作为续延,然后返回。
      4. 操作完成后,续延被触发,再次调用 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 的局部变量数量,可以缩小状态机结构体大小,进而减少复制开销。
  • 代码示例(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 voidasync void 方法的状态机没有 Task 返回,异常会直接抛出到主线程,且无法被正常捕获。除顶层事件(如 UI 按钮)外,应始终返回 TaskUniTask
    • 注意构建器类型AsyncTaskMethodBuilder 负责设置最终 Task 的结果、异常或标记取消。自定义 Task 类型需要提供配套的构建器,否则编译器会报错。Unity 中 UniTask 的构建器是 AsyncUniTaskMethodBuilder,可以做到 ValueTask 样式的池化。

6.1.1 桩方法:准备和开始第一步

  • 核心概念

    • 桩方法是编译器为 async 方法生成的同名普通方法,负责初始化状态机并启动异步流程。
    • 初始化三要素
      1. 原始参数:每个参数都被复制为状态机的公共字段。
      2. 构建器AsyncTaskMethodBuilder.Create() 创建与返回类型匹配的构建器(对于 TaskTask<T>UniTask 等各有不同)。
      3. 初始状态state = -1,表示“未启动”。
    • AsyncTaskMethodBuilder 是值类型,充当异步方法基础架构的 Helper:它负责创建最终的 Task,并在异步执行过程中设置结果、异常或取消标记。
    • 引用传递是关键builder.Start(ref machine) 将状态机以引用传递给构建器,确保对值类型状态的修改(如 stateawaiter)不会因拷贝而丢失。
    • 同步启动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 会访问已释放的字段。最佳实践:使用 CancellationTokenUniTaskAttachExternalCancellation
  • 代码示例(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 之前让出控制权。

    • 忽视构建器差异:不同返回类型(TaskValueTaskUniTask)对应不同的构建器。若手动模拟异步,需正确使用对应的 AsyncTaskMethodBuilder 变体,否则状态机无法正确设置结果/异常。

    • 调试时的混淆:反编译工具可能将桩方法还原为 async 风格,掩盖了状态机细节。当出现异步完成不触发时,理解桩方法机制有助于排查构建器的 SetResult 未被调用的问题。

    • state = -1 的必要性:初始状态 1 表示“尚未开始”。如果状态机被意外重用(如池化时未重置),将导致执行从错误的 state 恢复。UniTask 的零分配状态机池化需要谨慎重置此字段。

6.1.2 状态机的结构——字段分类与优化

  • 核心概念

    • 状态机接口IAsyncStateMachine 要求实现 MoveNext()(核心逻辑)和 SetStateMachine()(将状态机与构建器关联)。
    • 字段分类
      1. 状态编号state 整型,-1(未启动/执行中),-2(完成),≥0(在某个 await 处挂起)。
      2. 构建器AsyncTaskMethodBuilder 等,负责创建 Task、处理结果和异常。
      3. Awaiter 字段:每种 await 表达式的 awaiter 类型各占一个字段(相同类型复用)。
      4. await 的局部变量:在 await 之后仍需使用的局部变量会被提升为字段,仅在 await 之间使用的变量保持为局部变量。
      5. 临时栈变量:当 await 是复杂表达式的一部分时,需要暂存中间值(如 a + b * await task),编译器生成临时字段。
    • 字段复用策略
      • Awaiter 字段:按类型复用,多个相同类型的 await 共享一个字段(因为它们不会同时存在)。
      • 局部变量:编译器精确分析变量的活跃区间,只有活跃期跨越 await 的变量才提升为字段。两个不相交作用域的变量即使类型相同,当前编译器版本可能不会复用字段(未来可能优化)。
      • 临时栈变量:相同类型的中间值复用字段,减少状态机体积。
  • 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()方法(整体介绍)

这张流程图就够复杂惹,就不放其他东西了

img

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 不要求装箱。它通过将状态机直接嵌入 UniTaskIAwaiter 接口字段,或利用自定义的“状态机跑道”(StateMachineRunner)在栈或静态区域保存状态,从而完全避免了状态机装箱。
      • 这是 UniTask 实现零分配异步的核心技术之一(还需要 ValueTask 样式的 UniTask 结构体配合)。
    • IL2CPP 下的代价
      • IL2CPP 中,装箱会生成托管对象并触发垃圾回收。频繁的异步调用(如每帧执行的 async 方法)会迅速产生大量临时堆对象,导致手机端卡顿。
      • 使用 UniTask 或纯协程(IEnumerator)替代热路径中的原生 Task,是常见的性能优化手段。
    • MonoBehaviour 生命周期:装箱后的状态机脱离栈保护,如果 await 之后没有检查 this 是否为 null,而对象已被销毁,会导致访问已销毁对象。必须配合取消令牌(CancellationToken)及时中断异步操作。
  • 代码示例(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 voidasync void 方法的状态机也会被装箱,但其任务无法被等待,异常处理也更难。在 Unity 中,仅在顶层事件(如按钮 onClick)使用,并尽快调用 await 转移到可等待的 UniTask
    • 热路径避免异步:在 UpdateFixedUpdate 等频繁调用的方法中,避免使用任何形式的 async(包括 UniTask),因为即使没有装箱,状态机的 MoveNext 调用链也有开销。用传统代码或协程代替。
    • 状态机与闭包:若异步方法内引用了外部变量(闭包),这些变量也会被提升并跟随装箱。意外捕获大型对象会导致内存泄漏。使用 UniTask 的结构体闭包或显式传递参数减少捕获。

6.2 一个简单的MoveNext()实现

6.2.1 一个完整的具体示例

核心概念

MoveNext() 是状态机的心脏,编译器将 async 方法的流程重写为一个带有 switch/goto状态机驱动代码。以下基于代码清单6-1的 PrintAndWait 方法反编译结果,归纳其通用结构。

通用结构模式

整个 MoveNext() 方法遵循如下固定框架:

  1. 保存初始状态int num = this.state;
  2. 进入 try 块:包裹几乎所有逻辑,用于统一异常捕获
  3. switch 跳转调度
    • state = -1 或默认:跳转到方法起点 MethodStart
    • state = 0, 1, ...:对应第1个、第2个…… await 表达式的恢复点,直接跳转到对应的 goto FirstAwaitContinuation 等标签。
  4. 每个 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 的读写。
  5. 方法结束this.builder.SetResult() 标记任务成功完成。
  6. 异常处理(catch)this.builder.SetException(exception) 标记任务失败。

Unity开发关键点

  • 性能开销可视化:此代码揭示了原生 Task 的性能代价:
    • 每个未完成的 await 导致对状态机的装箱builder.AwaitUnsafeOnCompleted 内部会执行 BoxAndRemember 操作)。
    • AsyncTaskMethodBuilder 内部会创建并返回一个新的 Task 对象。
    • 结论:在热路径中使用原生 async Task 会导致大量 GC 分配。
  • UniTask 的优化对比
    • UniTask 的 AsyncUniTaskMethodBuilder.AwaitUnsafeOnCompleted 不会对状态机进行装箱。
    • 它将状态机直接存储到 UniTaskIAsyncStateMachine 的引用字段中,巧妙利用值类型在接口引用下的已有装箱,或使用对象池,实现零附加分配。
  • 理解 IsCompleted 快速路径
    • Unity 引擎内部某些操作可能同步完成(如资源已缓存时的 Resources.LoadAsync?实际上这返回非null,但异步操作对象可能立即完成)。
    • 实践:编译器生成的 if (awaiter.IsCompleted) 路径确保在同步完成时完全避免状态机的挂起、装箱和上下文恢复开销。这就是 ValueTaskUniTask 在同步完成场景下高性能的原因。
  • 主线程上下文恢复
    • 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 块直接捕获。
  • 异步方法的 returnMoveNextreturn
    • 异步方法中的 return; 语句(无返回值)对应 builder.SetResult(),是方法成功完成的路径。
    • 反编译代码中的 return;(在 AwaitUnsafeOnCompleted 之后)是暂时退出 MoveNext(),等待续延。初学者极易混淆这两者。
  • 性能关键路径的强制同步:如果确定某个异步操作总是同步完成(如从已加载的缓存中读取),利用 IsCompleted 快速路径可完全避免状态机挂起的开销。但这依赖于具体实现,不能作为普遍假设。

6.2.2 MoveNext()方法的通用结构

  • 核心概念

    • MoveNext() 是状态机核心:异步方法第一次调用时执行,之后每次从 await 恢复时也执行一次。若所有 await 都走快速路径,则 MoveNext() 只执行一次。
    • 两大执行路径
      • 快速路径:被 await 的操作已完成IsCompleted == true),状态机不暂停,直接取结果并继续。
      • 慢速路径:操作未完成,状态机保存状态、注册续延,然后 return 交出控制权。
    • 伪代码关键元素
      1. try/catch 全覆盖:整个原始异步方法的逻辑被包裹在 try 块中,任何异常(throw、faulted 任务、同步异常)都会被 catch 统一捕获,通过 builder.SetException() 填充到 Task,而非直接抛出。
      2. switch 跳转表:根据 this.state 决定从哪个标签恢复执行:
        • default(-1):首次启动,跳转到 MethodStart
        • 0, 1, 2…:对应第几个 await 的恢复点,跳转到 Label0ALabel1A 等。
      3. 方法完成:所有逻辑执行完毕后,在 try/catch 外调用 builder.SetResult() 标记 Task 成功完成。
    • 状态码细节
      • 1:未启动/正在执行(两者共用,因为 MoveNext 不会在这些状态下被重入调用)。
      • 2:已完成(仅用于外部检查,MoveNext 内部从不读取此状态)。
      • ≥0:在某个 await 处挂起。
    • 重要区分
      • 状态机中的 return:出现在慢速路径中,用于在注册续延后退出 MoveNext(),控制权交还调用者。
      • 原始代码中的 return:被转换为 builder.SetResult() / SetException(),位于 try/catch 之后,表示异步方法整体结束。
  • 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 统一异常包装,异常处理更脆弱。异步方法的状态机在异常处理和组合性上更优。
  • 代码示例(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 代码。

    执行流程表

    1. 获取 Awaiter:调用操作数的 GetAwaiter(),将 awaiter 存入栈。
    2. 检查完成状态:判断 awaiter.IsCompleted
    3. 分支选择
      • 已完成(快速路径):直接跳转到步骤 9(获取结果)。
      • 未完成(慢速路径):进入步骤 4。
    4. 记录位置:设置 this.state 为当前 await 对应的编号。
    5. 保存 Awaiter:将栈上的 awaiter 复制到 this.awaiter 字段。
    6. 注册续延与装箱:调用 builder.AwaitUnsafeOnCompleted(ref awaiter, ref this)。内部可能会对状态机进行装箱,并将 MoveNext 注册为 awaiter 的续延。
    7. 暂停并返回return(退出 MoveNext(),控制权交还调用方/续延安排者)。
    8. 恢复与清理:续延被触发时,MoveNext 再次进入,通过 switch 跳转到恢复标签。将 awaiter 从字段取出到栈,并将字段置为 default(辅助 GC)。
    9. 获取结果/抛出异常:调用 awaiter.GetResult()。若异步操作失败,在此处重新抛出异常
    10. 执行后续代码:继续执行 await 之后的原始代码。

    关键对比

    • 快速路径:跳过步骤 4-8,无挂起、无字段保存、无装箱。
    • 慢速路径:完整经历 10 步,涉及状态保存、装箱、续延调度和上下文捕获。
  • Unity开发关键点

    • 快速路径的极致性能:Unity 中加载已缓存的资源(如 Addressables 已加载的资源)时,异步操作可能直接同步完成。编译器生成的快速路径确保了这种场景下零挂起、零装箱,这是 UniTask 在许多场景比原生 Task 更快的基础。
    • GetResult() 与异常传播:所有异常(包括 OperationCanceledException)都在步骤 9 的 GetResult() 中抛出。理解这一点能避免混淆:即使不访问 Result 属性,异常仍会在此处抛出,并被 MoveNexttry/catch 捕获。
    • AwaitUnsafeOnCompleted 的“不安全”:与 AwaitOnCompleted 不同,此方法不捕获 ExecutionContext,性能更高。UniTask 大量依赖此方法以减少分配。但在需要模拟环境上下文(如 AsyncLocal)时必须注意。
    • awaiter 字段的复用与 GC:步骤 8 中的 this.awaiter = default 并非总是发生(发布构建可能省略,因为状态机可能要装箱且字段随对象回收)。但如果 awaiter 持有大型资源引用,不及时清空会延长生命周期,导致内存峰值。
    • 状态机重入防护:如果 awaiter 在 OnCompleted 中同步调用续延(步骤 6 立即触发步骤 8),MoveNext 会发生重入。AsyncTaskMethodBuilder 内部有保护机制,但自定义 awaiter 时必须避免。
  • 代码示例(结合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,但 awaitGetResult() 直接抛出原始异常。在需要类型化捕获异常时,需考虑异常类型。
    • 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:如果在 UpdateFixedUpdate 或每帧调用的方法中,有 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.WaitUntilUniTask.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 规则(不能从外部跳入循环作用域)。
    • 编译器必须做的事
      1. 解构循环:将 for 循环拆解为等效的 goto 与标签组合,手动实现初始化、条件判断和迭代步进。
      2. 提升循环变量:循环变量(如 i)必须提升为状态机字段,以便在暂停和恢复时保持值。
      3. 多个入口点:需要设计标签使执行流能直接跳入循环体(ForLoopBody)或循环条件判断处(ForLoopCondition)。
    • 生成代码特征(见代码清单6-8):
      • 初始化this.i = 0; goto ForLoopCondition;
      • 循环体入口ForLoopBody: 标签,包含 await 前的代码。
      • 挂起与恢复:在 await 处慢速路径挂起,续延恢复后跳转到 AwaitContinuation,然后继续执行循环剩余部分(Console.WriteLine("After..."); this.i++;)。
      • 条件判断ForLoopCondition: 检查 this.i < 3,决定是跳回 ForLoopBody 还是退出循环。
      • 无原生循环:最终 IL 中没有 forwhile 结构体,全为 goto 跳转。
    • 反编译器的局限:反编译器可能尝试将 goto/标签重构成 while/for,但往往生成非法 C# 代码(如用 goto 跳入 while 循环体内部),因为 IL 允许而 C# 语法不允许。手动查看原始 IL 或信任编译器生成是必要的。
  • Unity开发关键点

    • 每帧执行的异步循环
      这种模式被编译为带有循环条件判断和 goto 的状态机,每一帧 MoveNext 被调用一次,沿着 ForLoopConditionForLoopBody → 挂起 → 恢复的路径执行。理解这一点有助于分析帧内耗时。

      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.statethis.i 的值来追踪逻辑。
    • 循环内无条件 await:如果循环体内必然走慢速路径,每执行一次循环体就挂起一次,恢复时继续。要确保循环有明确的退出条件,且退出条件检查在 await 之后,否则可能多执行一次迭代。
    • 异常导致循环退出:循环体内的 await 如果抛出异常(如取消异常),会直接跳出整个 try 块(循环被整个包在 try 内),循环提前终止。如果想只中断本次迭代,需要在循环内部添加单独的 try/catch
    • IL2CPP 的限制:某些复杂的 goto/标签组合可能触发 IL2CPP 编译器的边缘问题,特别是在旧版本 Unity 中。保持异步方法逻辑尽量简单,避免在循环内部嵌套更多 try/catchusing

6.3.3 在try/finnaly块中使用await表达式

  • 核心概念

    • “蹦床”技巧:当 awaittry 块内部时,从暂停恢复的控制流需要跳转到 try 块内部,但 IL 规则禁止从外部直接跳入受保护区域。
      编译器生成的解决方案:在 try 块最前面放置一个内层 switch 跳转表作为“蹦床”,恢复时先跳到 try 块的起始标签(AwaitContinuationTrampoline),再由蹦床内的 switch 跳转到 try 块内部真正的恢复点(AwaitContinuation)。
    • finally 块的条件执行
      • finally 块在三种情况下执行:① try 块正常结束;② try 块抛出异常;③ await 导致状态机暂停。
      • 关键例外:当状态机暂停时(await 未完成),原代码的 finally 不应该执行,因为 try 块并未结束,只是暂时挂起。
      • 编译器通过检查 numstate 字段的局部副本)来区分:
        • 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");
    
    1. 外层 switch 根据 state 跳转到 AwaitContinuationTrampolinestate == 0)或 MethodStart(首次)。
    2. AwaitContinuationTrampoline 位于 try 块外面,但紧接着进入 try 块。
    3. try 块内部有自己的内层 switchdefault 跳到 TryBlockStart(首次执行),case 0 跳到 AwaitContinuation(恢复点)。
    4. finally 块包含 if (num < 0) 判断,仅在状态机未暂停时执行清理代码。
    • using 语句的关系using 编译为 try/finallyDisposefinally 中调用),因此异步方法中使用 using 会自动应用上述所有规则。
  • Unity开发关键点

    • 异步资源清理的陷阱:当你在 using 块内 await 时,若异步操作挂起,Dispose 不会在挂起时调用。这正符合预期,因为资源仍在使用中。但需注意:如果取消操作发生,异常可能使 try 提前退出,finally 才执行清理。
    • 与协程的对比:Unity 协程不支持 try/finally 跨越 yield return,导致清理代码必须手动写在所有退出路径。异步方法通过编译器生成的“蹦床”和条件 finally,能自动保证资源在异步操作完成或异常时被清理,极大提升安全性。
    • awaitMonoBehaviour 生命周期
      • try 块内 awaitGameObject 被销毁,后续恢复时 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 加标志位,或使用 UniTaskForget 谨慎处理。
    • 误解 finally 的执行时机:如果状态机在 await 处挂起,finally 不会立即执行。这意味着在 try 块内获取的锁或非托管资源,在挂起期间仍然持有。务必在 await 前释放不必要的锁,或使用 SemaphoreSlim 等异步兼容的同步原语。
    • 取消令牌与 finally 的结合:当 CancellationToken 触发取消时,await 抛出 OperationCanceledException,导致 try 块退出,finally会执行。这是清理资源的理想时机,但要确保清理代码本身不依赖已取消的操作。
    • 蹦床与调试:反编译代码中看到多个嵌套 switchgoto 时,不要慌张。只需追踪 this.state 的值,即可判断当前在哪个 await 步骤。蹦床标签通常以 Trampoline 命名,表明其跳板作用。
    • 避免过深的 using 嵌套:每个 using 都会增加一层 try/finally,导致状态机字段增多、蹦床层级加深。对于多个顺序资源,可考虑一个 using 内嵌多个 await,而非多个独立 using,或使用 DisposeAsync(C# 8)简化。
    • finally 中的异常:如果 finally 块自身抛出异常,且 try 块也有异常,则原异常会被覆盖。在 Unity 中,这可能掩盖网络请求的真实错误,导致难以排查。应确保 finally 中的清理代码是安全的。

6.4 执行上下文与 Await 的上下文贯穿

  • 核心概念

    • 执行上下文(ExecutionContext):它是所有其他上下文(SecurityContextCallContext 等)的容器,用于在不同代码间透明地传递环境信息。异步方法在 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
    • ConfigureAwait 的不同Task.ConfigureAwait(false) 影响的是同步上下文SynchronizationContext)的捕获,而非执行上下文。执行上下文总是需要贯穿(安全需求),不能通过 ConfigureAwait 阻断。
  • Unity开发关键点

    • 主线程上下文与执行上下文
      • Unity 的主线程 SynchronizationContext 负责将延续调度回主线程。即使执行上下文贯穿(保持不变),但线程可能仍是主线程。
      • 若使用 ConfigureAwait(false),会跳过同步上下文捕获,续延可能在线程池线程执行,此时不能访问 Unity 对象。但执行上下文依然贯穿,不影响安全性。
    • UnsafeOnCompleted 的性能优化
      • 几乎所有 .NET 框架的 awaiter(包括 TaskAwaiter)都实现了 ICriticalNotifyCompletion,因此编译器会走 AwaitUnsafeOnCompleted 路径。
      • 这避免了 AsyncTaskMethodBuilder 和 awaiter 各捕获一次上下文的重复开销。
      • UniTask 的 Awaiter 同样实现了 ICriticalNotifyCompletion,结合结构体状态机,可达到极低的分配与上下文传递开销。
    • 自定义 awaiter 时的注意事项
      • 如果在 Unity 中为 AsyncOperationUnityWebRequest 编写自定义 awaiter,若打算实现 ICriticalNotifyCompletion,应让 UnsafeOnCompleted 处理执行上下文,交给构建器负责。若只实现 INotifyCompletion,则需手动在 OnCompleted 中调用 ExecutionContext.Capture/Run 或使用 AsyncOperation.completed 时注意线程安全。
      • 在实际项目中,极少需要自行处理执行上下文,因为 UniTask 等框架已封装好。只需明白为何有两套方法即可。
    • IL2CPP 下的 ExecutionContext
      • IL2CPP 环境下 ExecutionContext 仍然存在,主要用于安全相关场景。大多数 Unity 项目(移动端)不依赖代码访问安全,但 AsyncLocal<T> 依赖执行上下文流动。
      • 使用 AsyncLocal<T> 时,确保其值会在 await 后正确恢复。由于执行上下文总是贯穿,该功能在 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 不做上下文处理。
    • UnsafeOnCompleted 中调用 ExecutionContext.Run
      • 非框架代码不应在 UnsafeOnCompleted 中手动贯穿上下文,这可能导致上下文重复捕获或死锁。应遵守约定:只有构建器负责上下文。
    • IL2CPP 中 AsyncLocal 的内存泄漏
      • AsyncLocal 的值在每次 await 时被捕获,若保存大对象且异步操作长时间未完成,可能导致内存占用持续增长。在 Unity 中注意及时重置或取消长时间异步操作。
    • 调试上下文丢失
      • 当异步方法中的 AsyncLocal 值意外丢失时,检查是否使用了不正确的自定义 awaiter 或在 unsafe 上下文中手动调用 UnsafeOnCompleted 并绕过了构建器。

6.5 自定义 Task 类型与 Builder 模式

  • 核心概念

    • 自定义 Task 的条件:必须提供一个配套的 Builder 类型,并用 [AsyncMethodBuilder] 特性标注。编译器会为 async 方法生成调用 Builder 各方法的代码,方式与 AsyncTaskMethodBuilder 完全一致。
    • Builder 的核心方法(按调用顺序)
      1. Create():静态方法,创建 Builder 实例,由桩方法调用。
      2. Start(ref TStateMachine):启动状态机,内部调用 stateMachine.MoveNext(),然后返回 Task
      3. Task 属性:返回与异步方法对应的 Task 对象(例如 CustomTask<T>)。
      4. AwaitOnCompleted(ref TAwaiter, ref TStateMachine) / AwaitUnsafeOnCompleted(...):在 await 表达式挂起时调用,负责捕获执行上下文、处理状态机装箱并注册续延。
      5. SetStateMachine(IAsyncStateMachine):在状态机装箱后被调用,确保 Builder 持有装箱后的引用。
      6. SetResult(T) / SetException(Exception):异步方法成功完成或失败时,由状态机在 try/catch 之后调用,设置最终结果。
    • 与标准 Task 的等价性:编译器不关心 Builder 内部实现,只要方法签名和特性匹配即可。因此可设计出零分配、可池化的 Task 类型(如 ValueTaskUniTask)。
  • Unity开发关键点

    • UniTask 的本质UniTaskUniTask<T> 正是利用自定义 Builder(AsyncUniTaskMethodBuilder)实现的高性能异步类型,完全替代了原生 Task
    • 零分配的秘密
      • AsyncUniTaskMethodBuilderStart 方法不会立即创建 Task 对象,而是将状态机存储在一个 UniTask 值类型的字段中(或使用对象池),直到需要时才创建。
      • AwaitUnsafeOnCompleted 中,UniTask 直接将续延注册到 PlayerLoop避免装箱状态机,也避免了原生 Task 的堆分配。
    • 与 Unity 生命周期的集成
      • UniTask 的 Builder 通过 PlayerLoopTimer 等机制将回调调度到主线程,同时支持 CancellationToken 自动取消。
      • 通过自定义 Builder,可以在 SetResult 时唤醒等待的协程或直接调用 MoveNext,无需额外的 Task 包装。
    • 性能收益:在每帧频繁调用的异步方法(如资源加载、网络轮询)中,使用 UniTask 替代 Task 可消除几乎全部的 GC 分配,显著降低手机端的卡顿。
    • 自定义 Builder 的扩展点:除了 UniTask,还可为特定场景设计更专用的异步类型,例如用于 ECS 的异步 Builder(直接与 EntityCommandBuffer 交互),但通常直接使用 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)。
    • SetResultSetException 的线程安全:异步操作可能在后台线程完成,确保这两个方法内部能安全地转换状态并将结果传回消费者(通常需使用 InterlockedManualResetValueTaskSourceCore 等)。
    • 避免在 Builder 中创建不必要的对象:原生 AsyncTaskMethodBuilder 在每次 Start 时都会 new 一个 Task。UniTask 的优化正是利用对象池和状态机嵌入来避免这一点。自定义 Builder 时也应考虑池化。
    • 不要忘记 [AsyncMethodBuilder] 特性:缺少特性会导致编译错误,因为编译器不知如何为 async 方法生成桩代码。
    • IL2CPP 兼容性:自定义 Builder 涉及泛型结构体和接口约束,在 IL2CPP 下必须确保所有用到的类型都能被 AOT 编译器正确静态分析,否则可能引发 MissingMethodException。使用 UniTask 已解决此问题。
posted @ 2026-07-28 19:29  绘星tsuki  阅读(2)  评论(0)    收藏  举报