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 Task<int> 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 Task<int> 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 AsyncTaskMethodBuilder<int> builder; public CancellationToken token; private int a; private TaskAwaiter<int> awaiter1; private ConfiguredTaskAwaitable<int>.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/AsyncTaskMethodBuilder<T>管理状态机启动、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 AsyncLocal<string?> 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,或引用IValueTaskSource<T>。它减少“常常同步成功且 Task 分配确实成为瓶颈”的某些成本,却增加消费规则和结构体复制复杂度。
public ValueTask<Config> GetAsync(string key, CancellationToken token) { if (_cache.TryGetValue(key, out Config? value)) return ValueTask.FromResult(value); return new ValueTask<Config>(LoadAsync(key, token)); }不能简单说 ValueTask “只能 await 一次”或“多次 await 一律未定义”:若它包装 Task,多次消费可能工作;若由池化 IValueTaskSource 支持,通常只能按协议消费一次,并且在完成前不得重复 GetResult、并发等待或错误保留。调用方若需要多次等待,可按契约AsTask(),但这可能引入分配。
默认优先 Task。只有性能分析显示同步完成路径的 Task 分配重要、API 使用者能遵守单次消费约束、异步源适合池化时,才选择 ValueTask。async ValueTask 方法自身也可能在真正挂起时分配状态。
六、异常、finally 与 await using
async 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 抛异常还是控制离开 try,finally 都应运行。资源从创建起一直持有到 await 后 finally 执行;“等待不占线程”不等于“不占连接/文件句柄/内存”。长 await 放在锁、事务或租借资源范围内可能造成资源饥饿。
异步释放使用await using和 IAsyncDisposable:
await 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( IReadOnlyList<Item> 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 的目标委托也会决定它是Func<Task>还是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 struct:C# 12/13 的版本边界
ref struct(包括 Span)和 ref local 不能被提升到普通堆状态机字段。C# 12 对 async/iterator 的限制更严格:不能在这类方法中声明 ref local 或 ref struct local,unsafe 代码也受相应限制;并不是只要人工保证“不跨 await/yield”就能在 C# 12 中声明。C# 13 才放宽为允许这些值出现在不跨挂起点的安全区域,但任何 byref/ref-like 值跨越 await/yield 仍然非法。
static async Task<int> InvalidAsync(Memory<int> memory) { Span<int> span = memory.Span; await Task.Delay(1); return span[0]; // C# 13 仍非法:span 需要跨 await;C# 12 连局部声明也不允许 }C# 12 可避免声明 ref-like local,把 Span 临时值立即交给同步方法,并在恢复后重新从可安全存堆的 Memory/数组取得 Span:
static async Task<int> ValidInCSharp12Async(Memory<int> 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 Task<int> ValidInCSharp13Async(Memory<int> memory) { int beforeAwait; { Span<int> 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 的优势和限制必须按其版本文档验证:某些值型任务只允许单次 await,Forget 需要异常处理器,PlayerLoopTiming 影响恢复阶段。不要把“更少分配”写成所有平台固定结论;在 IL2CPP 目标设备用 Profiler 测。
十二、常见失败反例
反例一:async 方法里直接跑重 CPU
UI 仍卡顿。把可并行 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) 后更新 UI
continuation 不保证回 UI。显式 Dispatcher/主线程切换。
反例八:捕获 this 的无限后台循环
组件和场景无法回收。用生命周期 token、弱所有权或托管后台服务。
十三、用 IL、日志和基准验证
最小项目固定配置:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net8.0</TargetFramework> <LangVersion>12.0</LangVersion> <Nullable>enable</Nullable> <Optimize>true</Optimize> </PropertyGroup> </Project>用 Release 编译并在 SharpLab/ILSpy/ildasm 中检查:AsyncStateMachineAttribute 指向何类型;哪些局部提升为字段;每个 await 使用何种 awaiter;IsCompleted 分支;AwaitUnsafeOnCompleted/AwaitOnCompleted;GetResult、SetResult、SetException;finally 和 DisposeAsync 的状态;捕获 lambda 的 display class;ValueTask 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 local,unsafe 代码也受严格限制,C# 13 才允许这些能力用于不跨 await/yield 的安全区域。固定编译器和运行时,通过 IL、协议日志、Profiler 和可复现实验确认具体成本;Unity 的 SynchronizationContext、PlayerLoop、Awaitable 与 UniTask 需要各自验证。
下一篇:模式匹配:C# 如何改变与数据结构的交互