1. 把“await 就是等一下”这句话忘掉
很多 C# 开发者入门异步编程时,脑子里装的是这样一幅画面:async开了一个新线程,await在那儿等这个线程跑完,然后接着往下走。这个理解只要错一步,后面写出来的代码就会在各种地方翻车——UI 假死、接口吞吐上不去、明明写了异步却比同步还慢、.Result一加就锁死。
我见过一个挺典型的现场:某个内部服务上线后,QPS 一直卡在几十,CPU 使用率却低得可怜,监控里全是线程池排队告警。翻代码,满屏的async,看起来无懈可击。最后定位到一行var data = await GetDataAsync().Result;——前半句的async纯粹是装饰,真正决定行为的是后面那个.Result。
所以这篇不讲入门语法,讲的是几件把async/await用明白必须搞清楚的事:编译器到底把你的方法改成了什么东西、上下文捕获是在捕获什么、死锁是怎么在一行.Result里长出来的、Task/ValueTask/async void各自该出现在什么位置。适合已经能写出能跑的异步代码、但说不清它为什么能跑、更说不清它为什么会卡住的人。
2. 编译器的改写:你写的不是方法,是一个状态机
2.1 从一段最小代码看改写结果
先看一段平平无奇的代码:
public async Task<int> GetValueAsync() { Console.WriteLine("A"); var result = await FetchAsync(); Console.WriteLine("B"); return result * 2; }反编译之后,你会看到一个编译器生成的GetValueAsync外壳方法和一个<>c__DisplayClass或者GetValueAsyncStateMachine结构体。外壳方法只做三件事:创建状态机、把this和参数塞进去、调用builder.Start(ref stateMachine)。真正干活的是状态机里的MoveNext()。
MoveNext()大致长这样(省略了异常处理和调试用的钩子):
private void MoveNext() { int result; try { TaskAwaiter<int> awaiter; if (state != 0) { Console.WriteLine("A"); awaiter = FetchAsync().GetAwaiter(); if (!awaiter.IsCompleted) { state = 0; this.awaiter = awaiter; builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; // <-- 注意这里,方法返回了 } } else { awaiter = this.awaiter; this.awaiter = default; state = -1; } result = awaiter.GetResult(); Console.WriteLine("B"); result *= 2; } catch (Exception ex) { state = -2; builder.SetException(ex); return; } state = -2; builder.SetResult(result); }把这段读三遍,await的真相就出来了:它不是“等待”,它是一次return。当被等待的任务还没完成,MoveNext会保存当前状态、把 awaiter 存进字段、把自己的回调挂到这个任务上,然后直接return出去。调用方拿到的是一个尚未完成的Task,线程被放走了。等任务完成时,负责触发回调的那条线程(可能是 IO 完成线程、线程池线程,也可能是当初那个 UI 线程自己)再调一次MoveNext,从state == 0的分支进去,接着跑。
2.2 局部变量为什么“活过了”方法返回
传统方法一返回,栈帧销毁,局部变量全没了。但在异步方法里,await之后的代码还要用到result、循环变量、using里的对象,这些必须活到下一次MoveNext。答案是它们被提升成了状态机的字段。
编译器会分析每个局部变量的作用域,只把跨越await边界还在用的那些提升为字段。这也是为什么异步方法里申请一堆只在await前用的小对象,不会额外增加状态机体积——它们留在MoveNext的栈上就好。
理解这一点能解释很多现象。比如你在for循环里await,循环变量会被提升,闭包捕获和状态机字段叠加在一起,稍微改错一行就会出现“所有回调都用了最后一次的值”这类经典 bug。再比如lock语句块里不能await,本质原因是lock依赖Monitor.Enter/Exit在同一线程上配对,而await之后续体完全可能跑在另一条线程上——编译器直接把这条路封死,是在帮你。
2.3MoveNext会被调用几次,这决定了性能开销
一个典型的“一次挂起”的异步方法,MoveNext至少被执行两次:第一次跑到挂起点,第二次从挂起点恢复。如果中间await了三个没完成的任务,就是四次。
这里有个容易被忽略的成本:状态机在发布配置下通常是struct,但因为要按引用传给 builder、要在堆上存活到任务完成,最终还是要装箱到堆上。所以每一次异步调用,哪怕await的任务早就完成了,也至少有一次状态机分配。这就是 .NET 后来引入ValueTask和PoolingAsyncValueTaskMethodBuilder这类优化的动机——在“绝大多数情况同步完成”的高频路径上,把这次分配省掉。
实测下来,一个纯Task返回、同步完成的方法,比等价同步方法的开销大概在几十纳秒量级,单次调用看不出来,放到每秒百万次的循环里就很可观了。判断标准很简单:这段代码是不是在热点路径上、每秒调用量是不是以万为单位。不是的话,别为了省这点开销牺牲可读性。
3. 上下文捕获:ConfigureAwait(false)关掉的是什么
3.1SynchronizationContext和TaskScheduler的分工
await恢复时,续体默认要回到“原来的地方”去执行。这个“原来的地方”由两层机制共同决定:当前线程的SynchronizationContext,以及当前Task关联的TaskScheduler。
在 UI 线程上,SynchronizationContext.Current是一个把回调Post到消息队列的实现;在线程池线程或控制台主线程上,它一般是null,此时调度器退化为默认线程池调度器。await在挂起前会同时抓取这两样东西存进状态机,恢复时优先用捕获到的SynchronizationContext.Post,其次用捕获的TaskScheduler,都没有就直接在线程池上继续。
这套机制的初衷是让 UI 代码写起来安全:在按钮点击事件里await一个网络请求,回来后直接操作控件不会抛跨线程异常,因为续体被Post回 UI 线程了。代价是每次恢复都要经过一次消息队列投递,而且从“任务完成”到“续体真正执行”之间多了一段延迟。
3.2 库代码为什么必须加ConfigureAwait(false)
写类库的时候,你没有权利假设调用方在什么环境里跑。如果调用方是 UI 线程,你的每一次await都把续体扔回 UI 线程,等于把本来能在线程池上并行的工作强行串行化到唯一一条线程上,吞吐直接被锁死。更糟的是,如果调用方是个同步阻塞在.Result上的 UI 线程(后面会讲),你的续体永远排不上队。
ConfigureAwait(false)的作用就是明确告诉运行时:恢复时不要管捕获的上下文,随便给我一条线程池线程就行。
public async Task<string> ReadConfigAsync(string path) { using var stream = File.OpenRead(path); using var reader = new StreamReader(stream); string? line = await reader.ReadLineAsync().ConfigureAwait(false); return line ?? string.Empty; }分界线其实很清楚:凡是可能被别人引用的类库、通用组件、基础设施层代码,一律加ConfigureAwait(false);应用程序最外层的入口(界面事件处理、控制器动作、控制台主流程)不加,因为你就是需要回到那个上下文。中间层如果既可能被 UI 调用、又可能被服务端调用,按类库处理,加。
3.3 三个常见的过度反应
第一个坑是“无脑全加”。有人在 UI 事件处理里也加了ConfigureAwait(false),紧接着下一行就更新控件,然后收获一个跨线程异常。这不是ConfigureAwait的错,是你的代码边界没想清楚。
第二个坑是以为加了ConfigureAwait(false)就能“开新线程”。它什么线程都不开,它只是放弃回到原来的上下文。遇到同步完成的await(对象已经在缓存里、返回值已经就绪),它连线程切换都不会发生,续体就在当前线程上继续跑。
第三个坑是把它当成解决死锁的常规手段。这事儿下面单独说。
4. 死锁的完整链路:一行.Result是怎么锁死整个线程的
4.1 把过程一步步走一遍
先看这段代码,在带同步上下文的线程上执行(桌面 UI 的事件处理、控制台里人为设置的上下文,都是一样的机理):
// 会死锁 public string GetData() { return GetDataAsync().Result; } private async Task<string> GetDataAsync() { await Task.Delay(1000); return "done"; }逐帧推演:
- 调用方线程(记作 T1)进入
GetData,调用GetDataAsync(),得到状态机外壳返回的Task。 GetDataAsync内部同步执行到await Task.Delay(1000)。此时T1上存在一个同步上下文,await把它捕获进状态机。Task.Delay还没完成,MoveNext把状态置为 0、挂上续体回调、return。控制权回到T1。T1拿到未完成的Task,调用.Result,进入阻塞等待。T1 就此停住,不再处理任何消息队列。- 1 秒后,计时器线程完成了
Task.Delay,触发续体。续体发现捕获了同步上下文,于是调用SynchronizationContext.Post,把“继续执行MoveNext”这个工作投递到T1的消息队列里。 T1正阻塞在.Result上,它的消息队列没人处理。续体永远排不上。Task永远不会完成。.Result永远不会返回。
死锁成立。注意关键点:不是Task没完成,是“完成Task所需的工作被排在了正在等Task的那条线程上”。
4.2 为什么加ConfigureAwait(false)有时能“修好”,但这是坏药
在上面那个GetDataAsync的await后面加上.ConfigureAwait(false),续体就不会再Post回T1,而是直接在线程池线程上跑,Task正常完成,.Result顺利返回,死锁消失。
看起来问题解决了,实际上你只是把同步阻塞掩盖掉了。原因有二。
第一,这只对你自己写的、加了ConfigureAwait(false)的那一层有效。你调用的第三方库、框架内部代码如果没有加,链条上任何一环捕获了上下文,死锁照样复现。你没法控制别人怎么写。
第二,同步阻塞本身就在浪费一条线程,而且在线程池环境里会引发更麻烦的问题。
正确的做法永远是:一路异步到底。ASP.NET Core 以前的 Web 框架、桌面应用、控制台,都可以把入口改成async Task(控制台用async Task Main,事件处理用async void但要自己兜异常),让整条调用链没有一处同步等待。
4.3 线程池饥饿:比死锁更隐蔽的卡死
服务端场景里更常见的是另一种形态,它不是严格意义上的死锁,但表现一样:请求全部超时,CPU 很低,线程数很高但都在等。
机理是这样的。线程池有个注入策略:当你提交的任务排队时间超过阈值,它会缓慢地往池子里加线程,速率大概是每秒一到两个。假设你有一个高并发场景,每条请求进来都同步阻塞等一个 IO 结果(.Result、.Wait()、Task.WaitAll),瞬间占满了默认的线程数。此时 IO 完成了,对应的续体需要线程池线程才能执行,但所有线程都在阻塞。线程池开始按每秒一两个的速度注入新线程,于是系统进入一种奇怪的半瘫痪状态:吞吐极低、延迟极高、线程数缓慢爬升到几百。
我处理过的一次线上问题就是这样。表现是接口 P99 从 200ms 涨到 15s,但数据库、下游服务全都正常。把阻塞调用全部改成await,线程数从 400 多降到 30 出头,吞吐翻了七八倍。这个对比很能说明问题:异步的价值不在“更快”,在“同样一条线程能扛更多并发”。
5. 异常去哪了:Task是异常的第一个收容所
5.1await抛的是原始异常,.Result抛的是包装
同样一个内部抛出InvalidOperationException的异步方法,两种调用方式拿到的异常完全不同:
Task<int> task = FailAsync(); try { await task; } catch (Exception ex) { /* ex 是 InvalidOperationException */ } try { _ = task.Result; } catch (Exception ex) { /* ex 是 AggregateException,InnerException 才是真凶 */ }await走的是awaiter.GetResult(),它会把Task里存的异常用ExceptionDispatchInfo重新抛出,并且保留原始调用栈。.Result和.Wait()抛的是AggregateException,异常被包了一层。这个差异直接影响你的catch分支写法和日志里的堆栈可读性。
顺带一提,AggregateException里的InnerExceptions可能有多个,但在await单个Task时你只会看到第一个。想让多个并发任务的异常都暴露出来,Task.WhenAll会等到全部完成才抛,而Task.WhenAny完全不会抛——它返回的是那个先完成的任务,异常还留在里面,需要你自己去await结果。
5.2async void是唯一会把异常扔到进程外面的写法
async void方法没有任何Task承载异常。当它内部抛出未被捕获的异常时,异常会被抛到当前的SynchronizationContext上;如果没有上下文,就直接抛到线程池线程,结局通常是进程崩溃。这个行为在桌面应用里表现为“程序莫名退出”,在服务端表现为“整个进程挂掉”,日志里往往只有一行看不出前因后果的堆栈。
async void唯一合理的用途是事件处理器签名,因为事件委托要求返回void。即便如此,方法体内部也应该自己包一层:
private async void OnButtonClick(object sender, EventArgs e) { try { await DoWorkAsync(); } catch (Exception ex) { LogError(ex); ShowMessage("操作失败,请稍后重试"); } }除此之外的所有场景——包括“我懒得改返回类型”——都应该用async Task。测试框架能await它,调用方能捕获异常,调试器能正确跟踪。
5.3finally里别做异步清理
await可以出现在try、catch、finally里,这一点编译器是支持的。但在finally里await一个可能长时间不返回的操作,会让异常的传播时机变得难以推理,尤其在多层嵌套的时候。清理逻辑我一般尽量写成同步的,或者用IAsyncDisposable配合await using,把清理语义交给语言机制管理,比手写finally里的await清楚得多。
6.Task、ValueTask、Task.Run、async void:各自的正确座位
6.1 先问一句:这个等待是不是真的在等 IO
async/await本身不创造并发,它只是让你在等待期间不使用线程。那么判断标准就很明确了:这段工作是在等外部资源(网络、磁盘、数据库、另一个服务),还是在烧 CPU(计算、序列化、加密)?
等 IO,直接await,别套Task.Run。套一层只是把“发起 IO 再等待”这个动作扔到另一条线程上,多了一次线程切换和一次任务调度,纯属浪费。
烧 CPU,且调用方是 UI 线程,用Task.Run把计算挪到线程池,让 UI 保持响应。调用方本身就是线程池线程(比如服务端请求处理),别套Task.Run,那是在线程池里再排队等线程池。
// 对:IO 密集,不需要 Task.Run public Task<byte[]> DownloadAsync(string url) => _httpClient.GetByteArrayAsync(url); // 对:CPU 密集,从 UI 移走 public Task<byte[]> CompressAsync(byte[] data) => Task.Run(() => Compress(data)); // 错:既 IO 又包 Task.Run,白多一次切换 public Task<byte[]> DownloadWrappedAsync(string url) => Task.Run(() => _httpClient.GetByteArrayAsync(url));6.2ValueTask省的那几十字节,得用对地方
ValueTask的设计目标是“高频调用且大概率同步返回”的场景,典型代表是从缓存里读数据:命中缓存就直接返回结果,不用分配Task对象。
它的代价是一堆使用限制,踩了不会报编译错误,只会在运行时给你惊喜。规则记住三条:
| 限制 | 说明 |
|---|---|
只能await一次 | 底层可能是复用的对象,二次等待行为未定义 |
不能并发await | 多个消费方同时等待同一个实例会出错 |
不要在未完成时调.Result | 大部分实现不支持同步阻塞取值 |
如果确实需要把ValueTask转成可多次消费的对象,用.AsTask()。如果需要在多个消费者之间共享,一开始就该返回Task。我自己的判断线是:先写Task,性能剖析明确指出这里是分配热点、且同步完成率超过 90%,才考虑换ValueTask。反过来先上ValueTask再到处查 bug,得不偿失。
6.3 取消和超时:别让异步变成“无限期等待”
异步方法应该接受CancellationToken,并且一路往下传,别在中途丢掉。
public async Task<string> QueryAsync(string key, CancellationToken ct) { using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); linked.CancelAfter(TimeSpan.FromSeconds(5)); var response = await _client.GetAsync(key, linked.Token); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(linked.Token); }这里用CreateLinkedTokenSource而不是直接ct,是为了让“调用方主动取消”和“超时取消”两条路径都生效,同时不影响调用方自己那个令牌的生命周期。如果直接给ct加超时,会污染上游。判断取消请求时用ThrowIfCancellationRequested(),它会抛OperationCanceledException,在链路顶端能和其他异常区分开处理——用户主动取消不该记成错误日志。
7. 一次实际改造:把串行的批量处理掰成并发
7.1 改造前的样子和瓶颈测量
场景挺常见:一批待处理的任务,从多个数据源拉取,然后汇总。最初的代码是这样:
foreach (var id in ids) { var item = await _repo.GetAsync(id); var detail = await _remote.FetchAsync(item.RefId); results.Add(Merge(item, detail)); }一百个 ID,每次两个网络往返,每个往返 50ms 左右,串行下来十秒开外。你说它是同步代码吧,它全是await;说它是异步吧,它一次只干一件事。
异步的并发能力来自“多个未完成的任务同时存在”,这段代码里任何时刻最多只有一个任务在飞。
7.2 分两步:先并发,再限流
第一步把所有任务先发出去,用Task.WhenAll统一等:
var tasks = ids.Select(async id => { var item = await _repo.GetAsync(id); var detail = await _remote.FetchAsync(item.RefId); return Merge(item, detail); }); var results = await Task.WhenAll(tasks);注意Select里是asynclambda,它在枚举时立刻启动,所以确实是一起在飞。这一步把时间从十秒压到一个往返量级,但引入了新问题:一百个并发请求同时打到下游,对方大概率会限流或者直接把你拉黑。
第二步加上限流,用SemaphoreSlim控制同时进行的数量:
private static readonly SemaphoreSlim Gate = new(8); public static async Task<T[]> RunLimitedAsync<T>( IEnumerable<Func<Task<T>>> work, CancellationToken ct) { var tasks = work.Select(async f => { await Gate.WaitAsync(ct).ConfigureAwait(false); try { return await f().ConfigureAwait(false); } finally { Gate.Release(); } }); return await Task.WhenAll(tasks).ConfigureAwait(false); }这个模式里有两个细节容易写错。WaitAsync和Release必须严格配对,所以Release放finally;Gate如果是实例字段而不是每个批次一个新的,要确认它不会被多个批次互相拖累——共用一个全局信号量在某些场景下是故意的(保护下游),在另一些场景下是 bug(批次之间互相卡),得看你的意图。
7.3 压测结果和回归检查点
同一批一百条数据,串行版本大约 10.5 秒,全并发版本 0.4 秒但下游错误率升到 3%,限流 8 并发版本 1.3 秒、错误率 0。这个取舍很典型:并发度不是越高越好,找到一个既压满下游容量又不触发限流的数字,靠的是压测不是猜。
回归检查我盯这几个点:结果顺序是否需要保持(WhenAll保留输入顺序,Select里的async也会,但如果自己用ConcurrentBag收集就乱了);异常发生时是否应该继续处理其余批次(WhenAll会等全部完成再抛,中途失败不会中断其他任务,这点和Parallel.ForEach的默认行为不同);内存峰值(一百个并发任务同时持有响应体,和串行时的峰值差一个数量级)。
8. 排错手记:调用栈看着没问题,程序就是不往下走
8.1 断点进了,调用栈却是空的
异步方法里打断点,调试器显示的调用栈常常只有一层MoveNext,上面的业务调用全消失了。原因很简单:从挂起到恢复,线程换了,栈帧早就没了,现在是“回调”把你叫醒的,自然看不到调用者。
遇到这种情况,我一般先看Tasks窗口(或者并行堆栈窗口),能看到所有活跃任务的当前状态和等待关系。另一个土办法是在挂起点前后各打一行带Thread.CurrentThread.ManagedThreadId的日志,对比一下就知道续体跑到哪条线程上去了。这个信息在排查“莫名跨线程访问”时特别管用。
8.2 忘了await,程序继续跑但结果不对
public async Task ProcessAsync() { SaveAsync(); // 少了 await,编译器只给个警告 Console.WriteLine("完成"); }这行的编译器警告是 CS4014,很多人把它当噪音关掉了。后果是SaveAsync变成了“发射后不管”,异常无人接收,ProcessAsync返回时保存可能还没开始。更隐蔽的情况是它在using块里,Dispose先把资源释放了,异步操作还在用,抛一个ObjectDisposedException出来。
处理办法:确实需要“发射后不管”的,把返回值显式赋给_并写清楚原因;其余一律补await。项目里可以在规则配置中把 CS4014 提升为错误,这个改动成本极低,收益很高。
8.3 循环里的await和Task.WhenAll用混了
一个常见误判是觉得“await在循环里就是并行的”。恰恰相反,foreach里的await就是严格的串行,每一次迭代都要等上一次结束。反过来,把所有任务塞进List<Task>再WhenAll,确实是并行,但如果列表是几万个元素,你会同时打开几万个连接。
还有一个坑是Task.WhenAll传空集合。它返回一个已完成的Task,不会抛异常,但如果你写了tasks.Min()之类的后续处理,就会在空集合上炸。空集合判断要不要加,看你的数据来源可不可靠,我一般会加,成本一行。
最后提一个调试体验上的小技巧:给耗时的异步操作起个名字。用Task的调试名称或者干脆在日志里带上操作标识,出问题的时候Tasks窗口里一堆MoveNext是没法分辨谁是谁的。这点投入在复杂系统里回报很高。
9. 我个人的几条硬规矩
写异步代码这些年,攒下几条不太会破例的习惯,随手分享一下。
返回类型只用Task和Task<T>,async void只出现在事件处理器签名上。这条没有例外,包括“这个方法肯定不会抛异常”——肯定会。
类库代码一律ConfigureAwait(false),应用入口一律不加。中间层按类库处理。这条规则简单到可以写进代码审查清单,减少大量争论。
不在异步链路上出现.Result、.Wait()、.GetAwaiter().GetResult()。唯一合理的同步等待位置是程序的Main方法最外层,而且现在Main本身就能声明成async Task,连这个理由都没了。
每个可能长时间运行的异步方法都带CancellationToken参数,并一路往下传。中途丢令牌是异步代码里最常见的疏漏,表现为“用户点了取消,后台还在跑”。
并发度用手动限流控制,不用Task.Run无限堆积。SemaphoreSlim配合Task.WhenAll这个组合,我在至少十几个项目里用过,够用且行为可预测。
真要说最容易出问题的地方,还是那个最朴素的认知:await不是“等”,是“让出”。把这句话刻进肌肉记忆,很多看起来玄乎的问题,推演一遍就清楚了。