ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

10-04-高级-async-await的编译器重写与状态机

2026/9/13 22:56:07 拓冰建站 浏览量
10-04-高级-async-await的编译器重写与状态机 async/await 的编译器重写状态机、上下文与资源边界系列C# 与常用数据结构源码剖析 · 高级特性篇版本边界C# 12、Roslyn 与 .NET 8生成细节以实际 SDK/Release 构建为准核心观点await 暂停的是方法状态不是把线程保存起来一、async 不等于创建线程async允许方法包含 await并让编译器按异步方法 builder 模式重写。调用 async 方法时它会在当前线程同步执行直到完成、抛出或遇到一个尚未完成的 await。未完成时状态机登记 continuation 后返回将来 awaiter 通知完成continuation 再推进状态机。I/O 操作通常由操作系统或运行时完成不需要一条线程停在那里等待。CPU 密集代码也不会因加 async 自动转移到线程池static async Taskint ComputeAsync() { int value ExpensiveCpuWork(); // 仍在调用线程同步执行 await Task.Yield(); return value; }需要线程池执行 CPU 工作是另一个调度决策例如合适场景下Task.Run服务器代码盲目 Task.Run 又可能浪费线程。async 解决的是挂起与恢复的控制流不是通用并行化。本文的状态机均标为概念伪代码不冒充 Roslyn 逐字输出。字段数量、状态编号、awaiter 清理和 builder 调用随方法、Debug/Release、编译器和目标框架变化。业务只能依赖语言与 awaitable 契约不能反射私有状态机字段作为协议。二、一个 async 方法如何被拆成启动器和 MoveNext源方法static async Taskint ComputeAsync(CancellationToken token) { int a await Step1Async(token); int b await Step2Async(a, token).ConfigureAwait(false); return checked(a b); }编译器生成一个带IAsyncStateMachine.MoveNext的状态机并把跨 await 仍需使用的参数、局部变量与 awaiter提升为字段。入口方法初始化 builder、state 与参数然后让 builder 启动状态机并返回 Task。// 概念伪代码只说明角色不是 Roslyn 输出。 struct ComputeStateMachine : IAsyncStateMachine { public int state; public AsyncTaskMethodBuilderint builder; public CancellationToken token; private int a; private TaskAwaiterint awaiter1; private ConfiguredTaskAwaitableint.ConfiguredTaskAwaiter awaiter2; public void MoveNext() { try { // 根据 state 区分首次进入和各恢复点 // GetResult 取得结果或重新抛出被等待操作的异常 // 最终 builder.SetResult。 } catch (Exception error) { // 最终 builder.SetException(error)。 } } public void SetStateMachine(IAsyncStateMachine machine) { } }只在 await 前使用且无需跨挂起的局部变量可能不成为字段。两个生命周期不重叠的 awaiter也可能复用存储Debug 版本为了调试会保留更多结构。AsyncStateMachineAttribute可把源方法关联到生成类型但名称是实现细节。2.1 MoveNext 是恢复入口MoveNext 每次从 state 对应位置继续。首次从方法开头执行遇到未完成 await 时记录下一状态、保存 awaiter、注册 continuation 并 return恢复后取回 awaiter重置字段调用 GetResult然后继续普通代码。return 值通过 builder.SetResult 完成 Task未处理异常通过 SetException 完成 Task 为 faulted/canceled 状态。MoveNext 可能在调用 async 方法的线程、捕获上下文线程、线程池线程甚至 awaiter同步调用 continuation 的路径执行。async 没有“固定异步线程”。下图把同步完成快路、真正挂起与两种终止结果放在同一个状态转移中。它描述的是 awaitable/builder 协议不是某个 Roslyn 版本的私有状态编号。“返回 Task”只表示本次MoveNext把控制权交回调用者不表示业务操作已完成也不表示有一条线程被保存在状态机中。恢复时的线程与调度位置由 awaiter、上下文与调度器共同决定。三、await 是一种模式不只认识 Task表达式可被 await 的核心是 awaitable pattern有可访问的GetAwaiter()awaiter 提供IsCompleted、GetResult并实现INotifyCompletion或ICriticalNotifyCompletion以登记 continuation。TaskAwaiter 只是常见实现。编译器逻辑可抽象为// 概念伪代码。 var awaiter operation.GetAwaiter(); if (!awaiter.IsCompleted) { state resumeState; savedAwaiter awaiter; builder.AwaitUnsafeOnCompleted(ref savedAwaiter, ref stateMachine); return; } T result awaiter.GetResult();3.1 IsCompleted 快路如果 IsCompleted 为 true状态机不必登记异步 continuation直接调用 GetResult 并继续。例如已完成 Task、缓存命中 ValueTask 或某些同步完成 I/O。这不是“async 方法永远零分配”返回 Task、状态机装箱、委托、闭包和被调用操作仍可能分配运行时可能缓存部分已完成 Task也不能被业务当成所有值的保证。awaiter 必须保证 IsCompleted 与 continuation 注册不存在丢唤醒竞态若操作在检查后、注册前完成continuation 仍必须执行且仅执行一次。自定义 awaiter 是并发协议不只是实现三个同名成员。3.2 builder 的职责AsyncTaskMethodBuilder/AsyncTaskMethodBuilderT管理状态机启动、continuation 接线、异常/结果完成以及返回 Task。异步挂起时Release 状态机通常需要被提升到堆上以跨调用栈存活仅说“生成 struct 所以永不分配”是错误的。同步完成快路可能避免部分提升成本具体要通过分配诊断验证。返回void、Task、Task、ValueTask/ValueTask 或使用自定义 async method builder 的类型会采用不同 builder。编译器遵守 builder 契约运行时再决定 Task 缓存、状态机盒和 continuation 执行细节。四、SynchronizationContext、TaskScheduler 与 ExecutionContext 不是一回事4.1 continuation 在哪里运行普通await task的 Task awaiter默认尝试捕获当前调度环境先看非默认SynchronizationContext再考虑非默认TaskScheduler完成后把 continuation 排回相应环境。UI 上下文用此机制让 await 后代码回到 UI 线程。没有特殊上下文/调度器时continuation 通常在线程池或完成操作的线程上推进具体仍受 awaiter 实现影响。ConfigureAwait(false)告诉 Task/ValueTask 的相应 awaiter不要尝试回到捕获的上下文byte[] payload await client.GetByteArrayAsync(uri, token) .ConfigureAwait(false); Parse(payload); // 不应访问要求原 UI 线程的控件它不保证“一定切到线程池”若操作已完成continuation 可在当前线程直接继续它也不关闭 ExecutionContext 流动。现代 .NET 中还有版本相关 ConfigureAwaitOptions必须按目标 API 核验。“所有库代码必须无条件 ConfigureAwait(false)”过于绝对。与 UI 无关的通用库通常不需要恢复调用上下文使用 false 可减少耦合但框架回调、应用层方法或明确依赖上下文的代码应保留/显式调度。根本原则是每个 await 后面的代码是否要求特定上下文。4.2 ExecutionContext 流动的是逻辑调用数据ExecutionContext 承载 AsyncLocal、文化、部分安全上下文等逻辑环境通常随异步 continuation 流动即使线程改变。SynchronizationContext 决定“往哪里调度”ExecutionContext 决定“带着哪些环境数据执行”。static readonly AsyncLocalstring? CorrelationId new(); CorrelationId.Value request-42; await Task.Delay(1).ConfigureAwait(false); Console.WriteLine(CorrelationId.Value); // 通常仍随 ExecutionContext 流动ExecutionContext 捕获/恢复本身有成本AsyncLocal 变化也可能触发回调。只有理解安全后才能使用ExecutionContext.SuppressFlow并必须恢复这不是普通性能微调。4.3 同步阻塞造成的经典死锁单线程 UI 上下文中调用.Result/.Wait()占住 UI 线程异步方法等待结束后试图把 continuation Post 回 UI双方相互等待。最佳修复通常是“async 一路向上传播”而不是四处包 Task.Run 或只改最深一层 ConfigureAwait。ASP.NET Core 默认没有传统单线程 SynchronizationContext但同步阻塞仍会导致线程池饥饿、吞吐下降。没有 UI 死锁不等于.Result合理。五、Task 与 ValueTask所有权和消费规则比大小更重要Task 是引用类型表示一个可共享完成对象通常可以多次 await、存储、让多个观察者等待。它仍可能来自缓存的完成实例不能笼统写“每次必分配一个新 Task”。ValueTask 是值类型判别包装可以直接携带同步结果、包装 Task或引用IValueTaskSourceT。它减少“常常同步成功且 Task 分配确实成为瓶颈”的某些成本却增加消费规则和结构体复制复杂度。public ValueTaskConfig GetAsync(string key, CancellationToken token) { if (_cache.TryGetValue(key, out Config? value)) return ValueTask.FromResult(value); return new ValueTaskConfig(LoadAsync(key, token)); }不能简单说 ValueTask “只能 await 一次”或“多次 await 一律未定义”若它包装 Task多次消费可能工作若由池化 IValueTaskSource 支持通常只能按协议消费一次并且在完成前不得重复 GetResult、并发等待或错误保留。调用方若需要多次等待可按契约AsTask()但这可能引入分配。默认优先 Task。只有性能分析显示同步完成路径的 Task 分配重要、API 使用者能遵守单次消费约束、异步源适合池化时才选择 ValueTask。async ValueTask 方法自身也可能在真正挂起时分配状态。六、异常、finally 与 await usingasync Task 方法在首次 await 前抛出的普通异常也通常被 builder 捕获并存入返回 Task而不是像普通同步方法那样直接从调用表达式抛出参数验证若必须同步抛出可以使用非 async 外壳调用私有 async 核心。具体 async void 不同后文单列。await 对 faulted Task 调用 awaiter.GetResult按 await 语义重新抛出异常。与.Wait()常见的 AggregateException 包装不同但一个 Task 仍可代表多异常来源异常观察应基于 Task API不要靠 catch 类型猜所有内部故障。try/finally 会跨 await 保存控制状态Stream stream Open(); try { await ProcessAsync(stream); } finally { stream.Dispose(); }无论正常完成、GetResult 抛异常还是控制离开 tryfinally 都应运行。资源从创建起一直持有到 await 后 finally 执行“等待不占线程”不等于“不占连接/文件句柄/内存”。长 await 放在锁、事务或租借资源范围内可能造成资源饥饿。异步释放使用await using和 IAsyncDisposableawait using Stream stream await OpenAsync(token); await ProcessAsync(stream, token); // 编译器在隐藏 finally 中 await DisposeAsync。DisposeAsync 自己也可能失败若主体和释放都异常最终可观察异常取决于控制流。关键资源需要日志和故障策略。using/await using的确切作用域要看花括号不能让返回的延迟查询引用已释放资源。七、取消只是协作协议CancellationToken 不会强制终止线程或任意方法。调用方发出请求被调用操作在安全点观察 token 并返回 canceled Task/抛OperationCanceledException。若 CPU 循环从不检查 token取消不会生效若底层 I/O 不支持取消包装 token 也不能神奇中断。static async Task ProcessAllAsync( IReadOnlyListItem items, CancellationToken token) { foreach (Item item in items) { token.ThrowIfCancellationRequested(); await ProcessOneAsync(item, token).ConfigureAwait(false); } }取消可能发生在部分副作用已经提交之后。API 必须声明是原子取消、尽力停止还是允许部分完成并设计幂等、补偿或事务。token 通常应从公共入口传到所有支持它的下层操作避免无意改用 CancellationToken.None。OperationCanceledException与 Task 的 Canceled 状态关联还涉及 token 和 builder 处理不要把所有 OCE 都吞掉也不要把取消记录成系统错误。catch filter 可区分本次请求 token但仍要保留清理 finally。八、async void只适合必须返回 void 的事件边界async void没有可供调用者 await 的 Task。调用者不知道何时完成不能自然组合、取消或捕获其异步异常。异常通常由捕获的 SynchronizationContext/线程环境处理可能成为未处理异常测试也难等待它。private async void SaveButton_Click(object? sender, EventArgs e) { try { await SaveAsync(); } catch (Exception ex) { ShowError(ex); } }事件签名要求 void 时可以使用但立即把逻辑委托给返回 Task 的方法事件边界负责捕获/报告。普通库、生命周期方法和命令处理优先返回 Task。测试直接测试 Task 方法。async lambda 的目标委托也会决定它是FuncTask还是Action对应的 async void。把 async lambda 传给只接收 Action 的 API 是隐蔽火灾并忘记异常来源应检查重载。九、分配、闭包与状态机生命周期async 成本可能来自返回 Task/ValueTask 后端、挂起后的状态机盒、continuation、CancellationTokenSource、委托/闭包、ExecutionContext、异步 I/O 对象和业务结果。同步完成快路可能省掉其中部分但没有源码/运行时证据不能宣称零分配。跨 await 使用的引用会被状态机字段持有直到字段清理或状态机完成static async Task UseAsync() { byte[] large LoadLargeBuffer(); int header ParseHeader(large); await SlowOperationAsync(); Consume(header); // 源码上 await 后不再用 large编译器/JIT是否缩短其生命周期需验证。 }可把同步预处理提取到单独方法确保大对象不需要成为状态字段或在安全时显式清引用但编译器生命周期和优化仍应以 profiler 确认。lambda 捕获this、局部或状态机变量可能创建闭包并让整个对象图活到异步操作结束。不要在长期任务中无意捕获场景、请求上下文或大缓存。static lambda 能禁止捕获但委托/任务是否分配仍须测量。状态机最初常以 struct 生成以支持同步快路真正挂起时 builder/runtime 会确保它获得稳定身份常涉及堆上的 box。引用字段存在并不会让 Roslyn 因此简单改生成 class原文这种推断不准确。十、ref、unsafe 与 ref structC# 12/13 的版本边界ref struct包括 Span和 ref local 不能被提升到普通堆状态机字段。C# 12 对 async/iterator 的限制更严格不能在这类方法中声明 ref local 或 ref struct localunsafe 代码也受相应限制并不是只要人工保证“不跨 await/yield”就能在 C# 12 中声明。C# 13 才放宽为允许这些值出现在不跨挂起点的安全区域但任何 byref/ref-like 值跨越 await/yield 仍然非法。static async Taskint InvalidAsync(Memoryint memory) { Spanint span memory.Span; await Task.Delay(1); return span[0]; // C# 13 仍非法span 需要跨 awaitC# 12 连局部声明也不允许 }C# 12 可避免声明 ref-like local把 Span 临时值立即交给同步方法并在恢复后重新从可安全存堆的 Memory/数组取得 Spanstatic async Taskint ValidInCSharp12Async(Memoryint memory) { int beforeAwait ReadHeader(memory.Span); await Task.Delay(1); int afterAwait ReadPayload(memory.Span); return beforeAwait afterAwait; }C# 13 可以在 await/yield 之前结束 ref-like local 的生命周期static async Taskint ValidInCSharp13Async(Memoryint memory) { int beforeAwait; { Spanint span memory.Span; beforeAwait ReadHeader(span); } // span 的作用域与生命周期在挂起前结束 await Task.Delay(1); return beforeAwait ReadPayload(memory.Span); }同一版本边界也适用于 iterator 中的 yield。C# 13 对 ref/unsafe/ref struct 的放宽只覆盖不跨挂起点的用法指针或 byref 的使用必须留在不包含 await/yield 的合法区域。必须标明 LangVersion不能用 C# 13 行为回写 C# 12。核心不变的是挂起点不能让指向栈或其他受限存储的引用逃入状态机。同理lock 使用的 Monitor 不能跨 await编译器禁止在 lock 内 await。异步互斥应使用 SemaphoreSlim.WaitAsync 或专用 AsyncLock并在 finally 释放这改变公平性、可重入性和取消语义需单独设计。十一、Unity主线程恢复、PlayerLoop 与 UniTask 不是一套机制Unity 项目的 async 行为取决于编辑器版本、.NET 配置、Mono/IL2CPP 与 UnitySynchronizationContext。许多 Unity API 只能主线程调用。普通 Task await 若捕获 Unity 上下文续体可能被 Post 回主线程队列ConfigureAwait(false)后则不能假定仍在主线程。实际行为必须在目标 Player 记录线程 ID 和帧阶段。Unity 的 PlayerLoop 按 Update、FixedUpdate、LateUpdate 等阶段驱动。await Task.Yield()只表达异步让出不保证业务所需的特定 PlayerLoop 阶段。Unity 新旧版本提供的 Awaitable及第三方 UniTask可能把 continuation 直接集成到 PlayerLoop并提供自己的取消、池化和单次消费约束它们不是 Task/ValueTask 的别名也不能从本文 .NET builder 细节推断实现。// 概念示例具体 API 取决于 Unity/UniTask 版本。 async Task LoadAndApplyAsync(CancellationToken token) { Data data await LoadOffThreadAsync(token).ConfigureAwait(false); await SwitchToUnityMainThreadAsync(token); ApplyToScene(data); // 显式恢复主线程后调用 Unity API }MonoBehaviour 生命周期也不会自动取消异步工作。对象 Destroy 后 continuation 可能仍运行并访问失效组件。为每个生命周期建立 CancellationTokenSource在 OnDestroy/禁用时取消并 Dispose同时处理取消到达前已经排队的 continuation。UniTask 的优势和限制必须按其版本文档验证某些值型任务只允许单次 awaitForget 需要异常处理器PlayerLoopTiming 影响恢复阶段。不要把“更少分配”写成所有平台固定结论在 IL2CPP 目标设备用 Profiler 测。十二、常见失败反例反例一async 方法里直接跑重 CPUUI 仍卡顿。把可并行 CPU 工作有意识调度或分片Unity API 工作仍留主线程。反例二.Result修复成最深层 ConfigureAwait(false)调用链其他 await 仍可能捕获上下文且同步阻塞继续有线程饥饿风险。async 一路上传播。反例三ValueTask 缓存并并发 await若由 IValueTaskSource 支持违反单次消费协议。需要共享时转 Task 或修改 API。反例四传了 token 就声称可取消下层没观察 token副作用也无补偿。建立端到端取消检查和部分完成语义。反例五await 期间持有数据库事务/池对象线程释放了稀缺资源仍被状态机持有。缩小资源作用域或重新设计流水线。反例六async lambda 传给 Action变成 async void异常和完成不可观察。使用 Func API并 await 返回 Task。反例七ConfigureAwait(false) 后更新 UIcontinuation 不保证回 UI。显式 Dispatcher/主线程切换。反例八捕获 this 的无限后台循环组件和场景无法回收。用生命周期 token、弱所有权或托管后台服务。十三、用 IL、日志和基准验证最小项目固定配置Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknet8.0/TargetFramework LangVersion12.0/LangVersion Nullableenable/Nullable Optimizetrue/Optimize /PropertyGroup /Project用 Release 编译并在 SharpLab/ILSpy/ildasm 中检查AsyncStateMachineAttribute 指向何类型哪些局部提升为字段每个 await 使用何种 awaiterIsCompleted 分支AwaitUnsafeOnCompleted/AwaitOnCompletedGetResult、SetResult、SetExceptionfinally 和 DisposeAsync 的状态捕获 lambda 的 display classValueTask builder 的差异。Debug 输出不用于推断 Release 性能。写一个自定义 awaitable 记录GetAwaiter、IsCompleted、OnCompleted 和 GetResult 的线程 ID与顺序分别测试同步完成和手动异步完成。不要在生产造 awaiter只把它当协议实验。用 AsyncLocal 验证 ExecutionContext用自定义单线程 SynchronizationContext 验证捕获与 ConfigureAwait(false)并设置超时避免实验死锁。BenchmarkDotNet 将以下路径分开同步普通方法、async Task 同步完成、async Task 真挂起、ValueTask 同步结果、捕获与非捕获、ExecutionContext/AsyncLocal。异步基准必须由框架正确等待返回任务不能.Result真挂起测试应使用可控完成源而不是把随机网络延迟当微基准。报告 SDK、runtime、Roslyn/LangVersion、OS、CPU、GC、Release、Tiered JIT/PGO、操作完成模式和分配原始结果不写脱离环境的微秒或倍数。Unity 在目标 IL2CPP/Mono Player 单独测帧阶段、GC.Alloc、线程和生命周期取消。正确性测试覆盖同步/异步完成每个 awaiter GetResult 异常finally 必执行DisposeAsync 异常取消前/中/完成后多写者/重入async void 事件异常处理ValueTask 误用检测对象 Destroy 后 continuation 不访问场景。十四、结论async/await 是一套编译器重写与 awaitable/builder 协议。MoveNext 保存并恢复跨挂起状态IsCompleted 提供同步快路awaiter 决定完成通知builder 管理返回对象、状态机身份、结果和异常。暂停时释放的是当前调用线程不是业务资源也不是取消承诺。SynchronizationContext/TaskScheduler 影响 continuation 调度ExecutionContext 携带逻辑调用数据ConfigureAwait(false) 只改变前一类捕获意图。Task 易共享ValueTask 用更严格消费协议换取特定同步路径优化。try/finally、await using、取消和 async void 则决定系统在失败与生命周期边界是否可靠。最后不要用“编译器魔法”掩盖测量结构体状态机仍可能在挂起时提升闭包和资源可能被长期持有C# 12 的 async/iterator 不能声明 ref/ref struct localunsafe 代码也受严格限制C# 13 才允许这些能力用于不跨 await/yield 的安全区域。固定编译器和运行时通过 IL、协议日志、Profiler 和可复现实验确认具体成本Unity 的 SynchronizationContext、PlayerLoop、Awaitable 与 UniTask 需要各自验证。下一篇模式匹配C# 如何改变与数据结构的交互