本节摘要:async 方法会被编译器重写成一个状态机类,await 是状态机的挂起点与恢复点。理解这个重写,就理解了"为什么 await 之后线程可能换人、为什么 async 方法返回 Task、为什么同步等待 async 会死锁"。本节拆开状态机的真实结构。
public async Task<string> FetchTitleAsync(HttpClient client, string url) { var html = await client.GetStringAsync(url); // 挂起点一 var doc = Parse(html); await Task.Delay(100); // 挂起点二 return doc.Title; }
这段代码读起来像同步,但编译器生成的东西面目全非:一个嵌套的状态机结构体,原方法体被切成三段(两个 await 之间各一段),每段对应状态机的一个状态号;所有局部变量(html、doc)被提升为状态机的字段——因为段与段之间"局部变量"必须活过方法返回。调用 FetchTitleAsync 实际执行的是:创建状态机、执行第一段、在 await 处决定去留、立即返回一个 Task 给调用者。方法在第一个未完成的 await 处就"返回"了,后续段注册在任务的延续回调里。
await 处的决策逻辑精简后是这样:问一句"任务完成了吗"——完成了,同步继续往下跑(常见于缓存命中、已完成的同步路径,零开销);没完成,把"当前状态号 + 后续段"打包成回调挂到任务上,方法返回。任务完成时回调被调度执行,状态机恢复到记录的状态号,取出字段里的局部变量继续跑。全程没有线程在傻等:等待期间这条线程被释放去干别的活。

图中右下的红框值得停留:状态机(含被提升的局部变量)要在堆上活过方法"返回",所以每个未同步完成的 async 调用至少分配一个状态机对象加一个 Task——await 的代价不在 CPU 在分配。C# 的持续优化(ValueTask、池化状态机)都在削减这笔开销。
恢复回调在哪条线程执行,取决于"完成"由谁宣布。IO 完成由 IO 完成端口线程报告,回调随后通常在线程池线程上跑;UI 程序里 await 默认捕获同步上下文,恢复被投递回 UI 线程——这是 UI 不卡死的关键。推论要记牢:await 前后不能假设同一线程,线程静态字段、线程亲和的句柄在 await 跨越后不可直接续用。ConfigureAwait(false) 告诉运行时"恢复不挑线程",库代码应默认加上它——既省上下文投递开销,也避免下一小节的死锁。
同步等待死锁。UI 线程或经典 ASP.NET 上下文里写 FetchTitleAsync(url).Result:调用线程阻塞等任务,而任务的恢复被排队投递回这个线程——互相等,死锁。这条线程此刻正被 Result 占着,永远轮不到恢复回调。解法只有一路到底的 async,同步代码里不得 Result/Wait 一个会回投递上下文的任务。
async void。返回 void 的 async 方法:调用方拿不到 Task,无法等待、无法捕获异常——异常会直接打崩进程或进不了正常的异常处理管道。唯一合法场景是事件处理器(签名由委托规定)。其余一律 Task 或 Task<T>。
忘 await。调了异步方法不 await,得到"发射后不管"的火:异常被吞、顺序失控。编译器对此只有警告,养成看到 CS4014 警告就处理(await 它或显式变量注明意图)的习惯。
伪异步。方法标了 async,体内全是 CPU 计算,没有真正的 await——它照样占用一条线程池线程跑完,只是换了身异步外衣。异步解决"等"的问题(IO、网络、延迟),并行解决"算"的问题,下一节展开这对概念的分界。
多任务编排三个组合器要形成条件反射:Task.WhenAll 等全部完成(并发发起一批请求的正解,总耗时约等于最慢的那个),Task.WhenAny 等任意一个(超时竞速:让任务与 Task.Delay 赛跑),Task.WhenEach 流式收完成。取消模型是协作式的:传 CancellationToken 进异步调用,取消令牌触发时任务在下一个挂起点抛 OperationCanceledException——调用方和被调方都要配合检查,令牌才有意义。
下一节把"算"的问题接过来:任务并行、数据并行与同步原语。