资讯动态

08-06-优化-async状态机与Task的GC优化

发布时间:2026/10/30 2:31:30 来源:尧图企业网站定制
async 状态机与 Task 的 GC 优化篇章08-核心技术-性能优化篇 |阅读时间约 45 分钟一、引言C# 的async/await语法极大简化了异步编程但优雅的语法糖背后隐藏着编译器生成的状态机和大量堆分配。每次调用async方法都会分配状态机对象、Task对象、可能的委托和闭包。在 Unity 中异步操作网络请求、资源加载、延迟逻辑频繁使用async/await时GC 压力不容忽视。理解async/await的分配机制是优化异步 GC 行为的前提。本章将从编译器生成的状态机结构出发深入分析Task和ValueTask的分配行为探讨IValueTaskSource池化、UniTask替代方案以及完整的异步 GC 优化策略。在展开之前先把本章的定位说清楚。08-05 值类型篇分析了struct的内存模型ValueTaskT和IValueTaskSource都是基于struct的零分配设计需要回指 08-05 的ref struct知识。08-03 闭包篇分析了闭包捕获的 GC 分配本章的闭包部分会与之呼应。二、async/await 的编译器变换2.1 async/await 的分配成本全景在深入状态机细节之前先从一个宏观视角理解 async/await 的 GC 成本。每次 async 方法调用可能产生 1 到 4 次堆分配Task 对象每次 async 方法调用至少分配一个 Task约 32-40 字节。状态机装箱如果 await 的 Task 未完成状态机结构体被装箱约 48-96 字节。闭包对象如果方法捕获了局部变量或 this 引用编译器生成闭包对象约 16-32 字节。委托对象如果使用 ContinueWith 或注册回调可能分配委托约 16 字节。一个典型的 async 方法有 await、有闭包可能产生 100-200 字节的 GC 分配。在热路径中如每帧调用 1000 次这意味着 100-200KB 的每秒 GC 分配。2.2 状态机生成的完整过程编译器将每个async方法转换为一个实现了IAsyncStateMachine接口的结构体。这个转换过程比大多数人想象的要复杂——编译器不仅要生成状态机结构还要处理异常传播、同步上下文捕获、返回值传递等多个方面。// 源代码 public async Taskint GetDataAsync(int id) { int data await LoadFromCacheAsync(id); if (data 0) data await LoadFromDatabaseAsync(id); return data; }编译器生成的代码远比源代码复杂。让我们逐层拆解第一层方法入口// 编译器生成的方法入口 public Taskint GetDataAsync(int id) { // 创建状态机结构体栈上分配 var stateMachine new GetDataAsyncStateMachine { _state -1, // 初始状态 _id id, // 捕获方法参数 _builder AsyncTaskMethodBuilderint.Create() }; // Start 方法会同步执行到第一个 await 点 // 如果第一个 await 的 Task 已完成整个方法同步完成 stateMachine._builder.Start(ref stateMachine); // 返回 Task调用方 await 这个 Task return stateMachine._builder.Task; }注意一个关键细节状态机结构体在栈上创建。GetDataAsyncStateMachine是一个struct它的创建只在栈上分配内存不产生 GC 分配。栈上分配的大小等于结构体字段大小通常在 32-64 字节之间。第二层状态机结构体// 编译器生成的状态机结构体简化 struct GetDataAsyncStateMachine : IAsyncStateMachine { // 状态机核心字段 public int _state; // 当前状态-1初始, 0第一次await后, 1第二次await后 public AsyncTaskMethodBuilderint _builder; // Task 构建器 // 捕获的方法参数和局部变量 public int _id; // 方法参数 id public int _data; // 局部变量 data public TaskAwaiterint _awaiter1; // 第一个 await 的 awaiter public TaskAwaiterint _awaiter2; // 第二个 await 的 awaiter // 闭包捕获的变量如果有 // public SomeClass _capturedThis; // this 引用如果是实例方法 // IAsyncStateMachine 接口实现 public void MoveNext() { // 状态机核心逻辑 } public void SetStateMachine(IAsyncStateMachine sm) { // 由 AsyncTaskMethodBuilder 调用设置状态机引用 } }状态机结构体的大小取决于方法参数数量每个参数占用一个字段。局部变量数量每个在await之间存活的局部变量占用一个字段。await 数量每个await需要一个Awaiter字段。闭包捕获每个捕获的变量占用一个字段。一个典型的async方法生成的状态机结构体大小在 32-128 字节之间。第三层MoveNext 执行逻辑public void MoveNext() { uint num (_state ! -1) ? (uint)_state : 0u; try { TaskAwaiterint awaiter; switch (num) { case 0: // 第一次 await 返回 goto case 1; case 1: // 第一次 await 结果 _data _awaiter1.GetResult(); if (_data 0) { _state num 2; awaiter LoadFromDatabaseAsync(_id).GetAwaiter(); if (!awaiter.IsCompleted) { _awaiter1 awaiter; _state num 2; _builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); // ← 这里将状态机装箱到堆上 return; } } goto case 2; case 2: // 第二次 await 结果 if (_data 0) _data _awaiter1.GetResult(); goto default; default: _builder.SetResult(_data); break; } } catch (Exception exception) { _state -2; _builder.SetException(exception); } }这个状态机逻辑的核心是switch语句每个case对应一个await点。MoveNext从当前状态继续执行直到遇到下一个await或方法结束。2.2 装箱的完整时机分析状态机初始是struct在以下情况被装箱到堆上时机 1await 未完成的 Task 时// 当 await 的 Task 未完成时AwaitUnsafeOnCompleted 需要注册回调 // 回调需要引用状态机所以状态机必须存活到回调触发 // 因此状态机被装箱到堆上 stateMachine._builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); // ↑ this 是 ref struct 参数AwaitUnsafeOnCompleted 内部将其装箱装箱的触发条件是awaiter.IsCompleted false。如果await的 Task 已经完成同步完成路径MoveNext会直接继续执行不需要注册回调状态机不会被装箱。时机 2通过 interface 调用状态机时// 如果状态机通过 IAsyncStateMachine interface 引用传递会装箱 IAsyncStateMachine sm stateMachine; // 装箱时机 3状态机作为字段存储时// 如果状态机存储在 class 字段中本身不装箱 // 但 class 实例在堆上状态机随 class 一起分配 class AsyncHolder { public MyStateMachine _stateMachine; // struct 字段随 class 在堆上 }2.3 同步完成路径的优化编译器对同步完成路径有专门的优化如果async方法中第一个await的 Task 已经完成整个方法会同步执行完毕不创建状态机对象。// ✅ 同步完成编译器优化不分配状态机 public async Taskint FastPathAsync() { return 42; // 编译器直接返回 Task.FromResult(42)不生成状态机 } // ⚠️ 有 await 但同步完成可能不装箱 public async Taskint MaybeFastAsync(bool fast) { if (fast) return await Task.FromResult(42); // Task 已完成MoveNext 同步执行 return await SlowAsync(); // Task 未完成状态机装箱 } // ❌ 异步完成状态机装箱 public async Taskint SlowPathAsync() { await Task.Delay(1000); // 未完成 → 状态机装箱 return 42; }这个优化非常重要——它意味着同步完成路径的 async 方法可以零分配。这也是ValueTaskT的设计基础让高频的同步路径零分配低频的异步路径接受装箱。三、Task 的分配分析3.1 Task 对象的完整分配链每次async方法调用可能产生多个堆分配。让我们逐一分析分配 1Task 对象public async Taskint ComputeAsync() { await Task.Yield(); // 强制异步 return 42; } // 分配: Taskint 对象 (~24-40 字节) // 包含: 结果值、状态、回调链、异常信息TaskT对象的大小取决于平台32 位/64 位和 .NET 版本。在 64 位平台上一个Taskint大约占用 32-40 字节对象头 12-16 字节 字段数据。分配 2状态机装箱如果 await 未完成// 如果 async 方法中有 await 且 Task 未完成 public async Taskint WithAwaitAsync() { await Task.Delay(100); // 未完成 → 状态机装箱 return 42; } // 额外分配: 状态机结构体 (~48-96 字节)分配 3闭包对象如果有变量捕获public async Taskint WithClosureAsync(int multiplier) { int offset 10; // 局部变量 // await 之间引用了 offset 和 multiplier → 编译器生成闭包 await Task.Delay(100); return offset multiplier * 2; } // 额外分配: 闭包对象 (~16-32 字节) // 闭包对象包含: this 引用如果是实例方法 捕获的变量分配 4委托对象如果使用 ContinueWith// ContinueWith 需要委托委托是 class分配在堆上 task.ContinueWith(t ProcessResult(t.Result)); // 额外分配: 委托对象 (~16 字节)总分配量一个典型的async方法有 await、有闭包可能产生 3-4 次堆分配总计 100-200 字节。3.2 已完成 Task 的缓存机制.NET 对已完成 Task 有内置缓存避免重复分配// Task.FromResult 对常用值有缓存 Taskint t1 Task.FromResult(0); // 缓存命中零分配 Taskint t2 Task.FromResult(1); // 缓存命中零分配 // 通常缓存 -1 到 10 的整数值 // Task.CompletedTask 始终缓存单例 Task t3 Task.CompletedTask; // 零分配返回单例 // Taskint.Completed 在 .NET 6 中缓存 Taskint t4 Taskint.Completed; // 零分配 // 但大多数异步操作仍分配 Taskint t5 Task.Run(() 42); // 分配 Task 委托 闭包缓存机制的局限性值范围有限FromResult只缓存小整数通常 -1 到 10。引用类型不缓存FromResult对引用类型不缓存每次调用都分配。异步操作不缓存Task.Run、Task.Delay等异步操作每次都分配新 Task。3.3 async void 的危险性async void方法不仅难以追踪和错误处理还额外产生状态机装箱// ❌ async void无法 await异常直接抛到 SynchronizationContext public async void OnClick() { await Task.Delay(1000); // 异常会直接抛到 SynchronizationContext可能导致进程崩溃 // 状态机装箱OnClick 的状态机在第一个 await 时装箱 } // ✅ async Task可以 await异常绑定到返回的 Task public async Task OnClickAsync() { await Task.Delay(1000); // 异常绑定到返回的 Task调用方可以捕获 }async void的主要使用场景是事件处理器如async void OnClick()因为事件签名要求返回void。但即使在这种情况下也应该尽量避免在事件处理器中使用async void。3.2 Task 分配的量化分析理解 Task 分配的量级对于制定优化策略至关重要。以下是一个典型 async 方法的分配成本分解分配项大小触发条件优化手段TaskT32-40B每次调用ValueTask状态机装箱48-96Bawait 未完成ValueTask 同步快速路径闭包对象16-32B有捕获变量避免捕获委托对象16BContinueWith避免使用一个典型的 async 方法有 await、有闭包可能产生 100-200 字节的 GC 分配。在热路径中如每帧调用 1000 次这意味着 100-200KB 的每秒 GC 分配。四、ValueTask减少 Task 分配4.1 ValueTask 的设计哲学ValueTaskT是 .NET Core 2.1 引入的值类型异步结果包装器。它的核心设计哲学是同步完成路径零分配异步路径接受 Task 分配。public ValueTaskint GetDataAsync(int id) { // 同步路径缓存命中直接返回值零分配 if (_cache.TryGetValue(id, out int value)) return new ValueTaskint(value); // 直接在栈上构造零分配 // 异步路径返回 Taskint接受分配 return LoadFromDatabaseAsync(id); // 返回 Taskint装箱 }ValueTaskT的同步路径零分配是因为它直接存储结果值不创建TaskT对象// ValueTaskT 的内部结构简化 public readonly struct ValueTaskTResult { // 同步完成时_result 存储结果值 private readonly TResult? _result; // 异步完成时_obj 存储 TaskTResult private readonly object? _obj; // 用于 IValueTaskSource 的 token private readonly short _token; // 构造函数同步完成 public ValueTask(TResult result) { _result result; _obj null; _token 0; } // 构造函数异步完成 public ValueTask(TaskTResult task) { _result default; _obj task; _token 0; } }ValueTaskT的大小为 16-24 字节取决于TResult的大小和平台完全在栈上操作。4.2 ValueTask 的正确使用模式模式 1缓存 异步回退public ValueTaskCacheEntry GetAsync(string key) { if (_cache.TryGetValue(key, out var entry)) return new ValueTaskCacheEntry(entry); // 同步零分配 return new ValueTaskCacheEntry(LoadAsync(key)); // 异步返回 Task }模式 2IValueTaskSource 池化// 通过 IValueTaskSource 实现完全零分配的异步 public class PooledAsyncOperation : IValueTaskSourceint { private static readonly ConcurrentBagPooledAsyncOperation _pool new(); private int _result; private Action? _continuation; private short _token; public static ValueTaskint RentAndStart() { var op _pool.TryTake(out var existing) ? existing : new PooledAsyncOperation(); op._token; op.Start(); // 返回 ValueTask指向池化对象零分配 return new ValueTaskint(op, op._token); } public ValueTaskSourceStatus GetStatus(short token) /* ... */; public void OnCompleted(Action continuation, short token, ValueTaskSourceOnCompletedFlags flags) { _continuation continuation; // 注册回调到异步操作 } public int GetResult(short token) { var result _result; _continuation null; _pool.Add(this); // 归还到池 return result; } }模式 3批量异步操作// ❌ 逐个 await每个都分配状态机 public async TaskListT ProcessAllAsyncT(ListFuncValueTaskT operations) { var results new ListT(operations.Count); foreach (var op in operations) { results.Add(await op()); // 每次 await 可能装箱 } return results; } // ✅ 批量并行一次 await public async TaskListT ProcessAllAsyncT(ListFuncValueTaskT operations) { var tasks operations.Select(op op()).ToArray(); var results await Task.WhenAll(tasks); // 一次 await return results.ToList(); }4.3 ValueTask 的限制和陷阱陷阱 1只能 await 一次ValueTaskint vt GetDataAsync(42); int result1 await vt; // OK int result2 await vt; // 错误可能抛出 ObjectDisposedException陷阱 2不能存储为字段class MyClass { // ❌ ValueTask 不能作为字段因为它是 struct状态可能过期 // private ValueTaskint _asyncResult; // ✅ 存储为 Task private Taskint _asyncResult; }陷阱 3AsTask() 产生分配ValueTaskint vt GetDataAsync(42); Taskint task vt.AsTask(); // 如果 vt 是同步完成分配 Taskint陷阱 4异常处理复杂// ValueTask 的异常处理需要额外注意 try { await GetDataAsync(42); } catch (Exception ex) { // 如果 ValueTask 是异步路径返回 Taskint异常正常传播 // 如果 ValueTask 是同步路径直接返回值异常在 await 前抛出 }五、ConfigureAwait 的 GC 影响5.1 ConfigureAwait(false) 的优化原理ConfigureAwait(false)告诉运行时不需要在await后恢复同步上下文。这看似与 GC 无关但实际上有间接的 GC 影响// ConfigureAwait(false) 的优化效果 // 1. 不捕获 SynchronizationContext // 2. 不通过 SynchronizationContext.Post 调度回调 // 3. 避免了 Post 内部的委托分配和队列操作 // 4. 间接减少了 GC 分配 public async Task LoadAsync() { // 不使用 ConfigureAwait(false) // await 后需要回到 UnitySynchronizationContext // SynchronizationContext.Post 可能分配委托对象 var data await File.ReadAllBytesAsync(path).ConfigureAwait(false); // 使用 ConfigureAwait(false) // 不需要回到主线程直接继续执行 // 避免了同步上下文切换的开销和潜在分配 }在 Unity 中ConfigureAwait(false)的 GC 影响更显著因为 Unity 默认使用UnitySynchronizationContext每次await后回到主线程时通过Post调度。Post内部可能分配委托对象和队列节点。5.2 Unity 同步上下文的分配行为// Unity 的同步上下文行为 // 1. 默认捕获 UnitySynchronizationContext // 2. await 后通过 SynchronizationContext.Post 回到主线程 // 3. Post 内部将回调放入队列可能分配 // ❌ 每次 await 都通过同步上下文调度 public async Task LoadAssetAsync(string path) { var data await LoadDataAsync(path); // 回到主线程 ProcessData(data); // 在主线程执行 } // ✅ 不需要回到主线程时使用 ConfigureAwait(false) public async Taskbyte[] LoadAssetDataAsync(string path) { var data await LoadDataAsync(path).ConfigureAwait(false); // 不需要回到主线程直接返回 return data; } // ✅ 需要回到主线程时分离关注点 public async TaskGameObject LoadAndSpawnAsync(string path) { var data await LoadAssetDataAsync(path); // ConfigureAwait(false) return SpawnGameObject(data); // 在主线程执行无 await }六、async 状态机的 GC 优化策略6.1 同步快速路径设计异步方法的设计应该优先考虑同步快速路径// 设计原则高频路径同步完成零分配低频路径异步执行 public ValueTaskbool TryGetFromCacheAsync(int key) { if (_cache.TryGetValue(key, out var value)) return new ValueTaskbool(true); // 同步完成零分配 return new ValueTaskbool(LoadFromServerAsync(key)); // 异步路径 } private async ValueTaskbool LoadFromServerAsync(int key) { // 这个 async 方法几乎不会走到这里缓存未命中是低频路径 // 即使产生状态机装箱对整体 GC 影响也很小 var result await _httpClient.GetAsync(key).ConfigureAwait(false); _cache[key] result; return result; }6.2 避免不必要的 async 包装// ❌ 不必要的 async 转发只是转发 await产生多余状态机 public async Taskint GetCountAsync() { return await _repository.CountAsync(); // 多余的状态机 } // ✅ 直接返回 Task零状态机分配 public Taskint GetCountAsync() { return _repository.CountAsync(); // 无额外分配 } // ❌ async lambda 包装同步方法 FuncTask wrapper async () SyncMethod(); // 分配状态机 // ✅ 直接返回 Task FuncTask wrapper () Task.Run(SyncMethod);6.3 批量异步操作// ❌ 逐个 await每个都分配状态机 public async Task ProcessAllAsync(Listint ids) { foreach (var id in ids) { await ProcessOneAsync(id); // 每次 await 可能装箱 } } // ✅ 批量并行一次 await public async Task ProcessAllAsync(Listint ids) { var tasks ids.Select(id ProcessOneAsync(id)).ToArray(); await Task.WhenAll(tasks); // 一次 await一次装箱 } // ✅ 使用 ValueTask 的 WhenAll 变体 public async Task ProcessAllAsync(Listint ids) { var valueTasks ids.Select(id ProcessOneValueAsync(id)); await Task.WhenAll(valueTasks.Select(vt vt.AsTask())); }七、Unity 中的异步优化7.1 UniTask 替代方案UniTask是 Unity 社区最流行的零分配异步库由 Cysharp 开发。它的设计目标是完全消除 async/await 的 GC 分配using Cysharp.Threading.Tasks; // UniTask 的状态机是 ref struct不会装箱 public async UniTask LoadSceneAsync(string sceneName) { var handle SceneManager.LoadSceneAsync(sceneName); await handle.ToUniTask(); // 零 GC 分配 } // UniTaskVoid 用于不需要等待的异步操作类似 async void但安全 public async UniTaskVoid FireAndForget() { await UniTask.Delay(1000); DoSomething(); } void Start() { FireAndForget().Forget(); // 不等待不产生异常 }UniTask的零分配原理状态机是ref struct编译器保证不会装箱。使用UniTaskCompletionSource池化异步完成器从对象池获取归还到对象池。UniTask.Yield零分配替代Task.Yield()不分配委托。UniTask.Delay零分配基于 Unity 的Coroutine或Time实现不分配Task。7.2 CancellationToken 优化// ❌ 每次创建新的 CancellationTokenSource public async Task LoadAsync() { using var cts new CancellationTokenSource(); // 堆分配 await Task.Delay(1000, cts.Token); } // ✅ 复用 CancellationTokenSource private CancellationTokenSource? _cts; public async Task LoadAsync() { _cts ?? new CancellationTokenSource(); await Task.Delay(1000, _cts.Token); } // ✅ UniTask 的 CancellationToken 池化 var cts CancellationTokenSource.CreateLinkedTokenSource(_cancelToken); await SomeOperationAsync(ccts.Token);7.3 替代协程// ❌ 协程分配 IEnumerator 状态机 IEnumerator DelayRoutine() { yield return new WaitForSeconds(1f); // 分配 WaitForSeconds 对象 DoSomething(); } StartCoroutine(DelayRoutine()); // 分配 IEnumerator // ✅ UniTask 替代零分配 async UniTaskVoid DelayAsync() { await UniTask.Delay(1000); // 零分配 DoSomething(); } DelayAsync().Forget();八、async GC 分配量化8.1 分配对比表场景分配优化方案async TaskT同步完成TaskT 对象ValueTask\T\async TaskT异步完成TaskT 状态机装箱ValueTask\T\ 池化Task.FromResult(42)0缓存已优化Task.Run(lambda)Task 委托 闭包避免热路径使用await task已完成0快速路径await task未完成状态机装箱无法避免async void状态机 危险避免ConfigureAwait(false)减少上下文分配推荐使用UniTask0Unity 首选new CancellationTokenSource()CTS 对象池化/复用8.2 典型 async 方法的分配成本典型 async 方法有 1 个 await有 2 个捕获变量 分配项 大小 何时分配 ───────────────────────────────────────────── 状态机结构体栈上 48-96 B 每次调用 TaskT 32-40 B await 未完成时 状态机装箱 48-96 B await 未完成时 闭包对象 16-32 B 有捕获变量时 委托对象ContinueWith 16 B 使用 ContinueWith 时 ───────────────────────────────────────────── 最小分配同步完成 32-40 B 仅 TaskT 典型分配异步完成 144-264 B Task 状态机 闭包九、实战零分配异步资源加载9.1 基于 UniTask 的加载器public class ZeroAllocAssetLoader { private readonly Dictionarystring, UnityEngine.Object _cache new(); // 同步路径缓存命中零分配 // 异步路径UniTask 零分配 public async UniTaskT LoadAsyncT(string path) where T : UnityEngine.Object { if (_cache.TryGetValue(path, out var cached)) return (T)cached; // 同步完成零分配 // 异步路径UniTask 零分配 var handle Addressables.LoadAssetAsyncT(path); var asset await handle.ToUniTask(); // 零 GC _cache[path] asset; return asset; } // 批量加载UniTask.WhenAll 零分配 public async UniTask LoadAllAsync(string[] paths) { var tasks new UniTask[paths.Length]; for (int i 0; i paths.Length; i) tasks[i] LoadAsyncUnityEngine.Object(paths[i]); await UniTask.WhenAll(tasks); // 一次 await } }9.2 基于 ValueTask 的缓存服务public class CachedDataService { private readonly Dictionaryint, int _cache new(); private readonly HttpClient _http; // 高频路径缓存命中ValueTask 同步完成零分配 // 低频路径缓存未命中ValueTask 包装 Task接受分配 public ValueTaskint GetAsync(int key) { if (_cache.TryGetValue(key, out var value)) return new ValueTaskint(value); // 零分配 return new ValueTaskint(FetchAsync(key)); // 异步 } private async Taskint FetchAsync(int key) { var response await _http.GetAsync($/api/data/{key}).ConfigureAwait(false); var json await response.Content.ReadAsStringAsync().ConfigureAwait(false); var value int.Parse(json); _cache[key] value; return value; } }十、总结与实践建议10.1 核心要点回顾async/await的 GC 优化核心策略优先 ValueTask同步路径零分配异步路径接受 Task 分配。避免不必要的 async直接返回 Task不产生多余状态机。ConfigureAwait(false)减少同步上下文切换开销和间接分配。UniTask 替代 TaskUnity 中完全零分配状态机不装箱。池化 IValueTaskSource异步路径也零分配。批量 awaitTask.WhenAll/UniTask.WhenAll减少装箱次数。复用 CancellationTokenSource避免重复创建。避免 async void使用UniTaskVoid或async Task。10.2 实践建议高频异步方法用 ValueTask缓存服务、查询接口等高频调用的异步方法。Unity 项目用 UniTask特别是热路径中的异步操作网络请求、资源加载。异步方法参数用 ConfigureAwait(false)不需要回到主线程的操作。避免 async 转发直接返回底层 Task不包装多余状态机。批量并行异步操作用WhenAll替代foreach await。复用 CancellationTokenSource在对象生命周期内复用。监控 async 分配使用 Profiler 或 AllocChecker 监控async方法的 GC 分配。10.3 async GC 优化决策树需要异步操作 │ ├─ Unity 项目 │ ├─ Yes → 考虑 UniTask │ │ └─ 零分配状态机专为 Unity 设计 │ └─ No → 继续 │ ├─ 高频调用每帧/每秒多次 │ ├─ Yes → ValueTaskT │ │ └─ 同步路径零分配 │ └─ No → 继续 │ ├─ 需要同步快速路径 │ ├─ Yes → ValueTaskT 缓存 │ └─ No → 继续 │ ├─ 需要回到主线程Unity │ ├─ Yes → 不用 ConfigureAwait(false) │ └─ No → ConfigureAwait(false) │ └─ 使用标准 Task └─ 接受状态机装箱异步路径

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑