模块二 · 请求与异步
async/await 到底在做什么
async 不是"开线程",await 也不是"阻塞等待"。这一节讲清状态机、同步上下文、Task 组合、取消与 async void 的风险。
读完这一节,你能做到
- 说清 async/await 编译后的状态机与执行流程。
- 解释 ConfigureAwait(false) 的作用与适用场景。
- 用 WhenAll/WhenAny 做并发组合,用 CancellationToken 取消。
- 判断 async void 为什么危险。
一、async 不是开线程
async 只是让方法可以 await,编译器会把它改写成状态机;await 一个未完成的 Task 时,方法会把控制权交还给调用方,而不是阻塞线程。
public async Task<string> GetAsync(HttpClient client)
{
var response = await client.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}
执行过程:
- 调用方法,运行到第一个未完成的 await。
- 注册回调,返回一个未完成的 Task 给调用方。
- IO 完成后,回调恢复状态机,继续执行剩余代码。
- 全部完成,Task 变为完成状态。
所以它适合 IO 密集;CPU 密集应交给 Task.Run 或并行库。
二、同步上下文与 ConfigureAwait
await 默认会捕获当前的 SynchronizationContext,并在恢复时回到原上下文。在 UI 或旧 ASP.NET 上,这会导致死锁风险;在 ASP.NET Core 里没有同步上下文,影响较小。
类库代码推荐 ConfigureAwait(false):
- 不要求回到原上下文,减少调度开销。
- 避免上层同步阻塞导致的死锁。
应用层(Controller、UI 事件)通常不需要,因为你要回到原上下文更新界面或使用 HttpContext。
三、Task 组合
| API | 行为 |
|---|---|
Task.WhenAll | 等全部完成,任一失败则结果 Task 失败 |
Task.WhenAny | 等第一个完成 |
Task.Delay | 延时,可配合取消 |
ValueTask | 可能同步完成时减少分配 |
并发调用三个接口:
var userTask = GetUserAsync(id);
var orderTask = GetOrdersAsync(id);
await Task.WhenAll(userTask, orderTask);
var user = await userTask;
注意:先启动任务再 await,才是并发;连续 await 是串行。
四、取消与超时
CancellationToken 是协作式取消:调用方传 token,被调用方在合适的位置检查并抛 OperationCanceledException。
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var data = await client.GetStringAsync(url, cts.Token);
超时可以用 CancellationTokenSource 或 Task.WhenAny(task, Task.Delay(timeout));前者能真正取消,后者只是不再等待。
五、async void 的风险
async void 无法被 await,异常会直接抛到同步上下文,可能导致进程崩溃;调用方也无法知道何时完成。
只有 UI 事件处理器可以用 async void,并且要在内部 try/catch。其余场景一律用 async Task。
六、并发与并行
- 并发:多个任务交替推进,适合 IO。
- 并行:多个任务同时执行,适合 CPU。
Parallel.For/ForEach适合 CPU 密集的批量计算。Channel<T>适合生产者-消费者,替代阻塞队列。
不要用 Task.Run 包装 IO:它只是把阻塞操作挪到线程池,浪费线程。
七、常见坑
- 用
.Result/.Wait()同步阻塞异步方法,可能死锁。 - 连续 await 本可并行的任务,白等。
async void吞异常。- 忘记传 CancellationToken,请求无法取消。
- 在循环里创建 Task 但不等待,异常丢失。
八、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 用 WhenAll 并发调用两个延时接口,对比串行耗时。
- 用 CancellationTokenSource 实现 2 秒超时,观察异常类型。
- 写一个 async void 抛异常的例子,再用 async Task 修好。
② 让 AI 写同一题
把第 2 题(异步接口改造)交给 AI,补一句 禁止 async void,必须传 CancellationToken。
③ 对比要盯的点
- 它有没有写
async void?异常会直接打到线程池,没人接得住。 CancellationToken它一路传下去了吗?还是只在签名上挂了一个。- 它用了
.Result或.Wait()吗?那是同步阻塞异步,高并发下会耗尽线程池。
④ 一句话取舍
客户端断开时,它那版的后台任务会停吗?
九、自测
- async/await 编译后是什么?
- ConfigureAwait(false) 解决什么问题?
- WhenAll 与 WhenAny 的区别?
- 取消为什么是协作式的?
- async void 为什么危险?
参考答案要点:状态机;避免回到原上下文;等全部/等第一个;需要被调用方检查;异常无法捕获。
附录:高频追问
问:await 会阻塞线程吗? 不会。未完成时让出线程,完成后回调恢复;阻塞的是 .Result/.Wait()。
问:Task 和 Thread 的区别? Task 是更高层的抽象,可能在线程池执行,也可能只是异步 IO 的完成通知。
问:ValueTask 什么时候用? 高频调用且经常同步完成时,减少 Task 分配;不要重复 await 同一个 ValueTask。
问:异步方法一定跑在线程池吗? 不一定。IO 异步由操作系统通知,CPU 密集的 Task.Run 才在线程池。
问:如何避免死锁? 全程 async,不用 .Result;类库用 ConfigureAwait(false)。
问:Task.WhenAll 一个失败会怎样? 返回的 Task 失败,异常是第一个失败的;要收集全部异常用 await Task.WhenAll 后遍历。
问:async 方法能返回 void 吗? 只有事件处理器可以,其他场景用 Task。
问:CancellationToken 怎么传? 作为最后一个参数,逐层传递,不要新建无关的 token。
问:如何限制并发数? SemaphoreSlim 或 Parallel.ForEachAsync 的 MaxDegreeOfParallelism。
问:异步流是什么? IAsyncEnumerable<T> + await foreach,适合边读边处理大数据。
附:async 的常见性能陷阱
| 陷阱 | 后果 | 修法 |
|---|---|---|
.Result/.Wait() | 线程阻塞、可能死锁 | 全程 async |
Task.Run 包 IO | 浪费线程池线程 | 直接用异步 API |
| 循环里逐个 await | 串行变慢 | 先启动再 WhenAll |
| 忘记 CancellationToken | 请求无法取消 | 逐层传递 |
| async void | 异常无法捕获 | 用 async Task |
另一个容易忽略的点:异步不等于更快。它提升的是吞吐(线程不被占住),单次请求的延迟可能更高,因为多了状态机与调度开销。
异步与事务、锁
await 不会释放数据库事务;事务期间让出线程但连接仍然占用。不要把慢 IO 放进事务里,也不要在持有锁时 await 不确定的时长。
需要“事务 + 异步”时,先完成所有异步准备,再开短事务提交。
补充:Task 调度与线程池
Task.Run 会把工作排到线程池;线程池增长有延迟,突发大量阻塞任务会排队。IO 异步不占线程,但回调仍要在某个线程上执行。
判断标准:这段工作是“等”还是“算”。等用异步 IO,算用 Task.Run/并行,不要在异步方法里做长时间同步计算。
补充:异步调试
- 用
dotnet-counters看线程池队列与线程数,判断是否饥饿。 - 用 Activity/OpenTelemetry 串起异步调用链。
- 出现“请求卡住”先搜
.Result/.Wait()。 - 大量
Task.Run可能是把 IO 当 CPU 处理了。
异步的验收标准
- 压测时线程池队列不持续增长。
- 取消令牌能让请求及时结束。
- P99 稳定,不因并发上升而飙升。
- 没有
async void与同步阻塞。
三个不要
不要用 .Result 同步等待;不要用 async void;不要用 Task.Run 包装 IO。
一个判断
如果 await 之后不需要回到原上下文(不用 HttpContext、不更新 UI),库代码就加 ConfigureAwait(false);应用层通常不需要。
验收
压测时线程池队列稳定、取消能生效、没有 .Result 与 async void,才算把异步用对了。异步是吞吐,不是延迟;先修阻塞,再谈异步。取消要逐层传,别用 Task.Run 包 IO,库加 ConfigureAwait。先修阻塞,再谈异步;取消逐层传。先修阻塞,再谈异步。先修阻塞。
补遗:线程同步与并发集合
lock/Monitor(互斥、可重入);Interlocked(原子自增/交换);SemaphoreSlim(异步友好的并发限制);ReaderWriterLockSlim(读多写少);Mutex(跨进程)。
并发集合:ConcurrentDictionary、ConcurrentQueue、ConcurrentBag、BlockingCollection。Channel<T> 是异步生产者-消费者首选:有界/无界、可等待、支持取消。
补遗:并行与线程池
Parallel.For/ForEach 适合 CPU 密集;PLINQ 要谨慎,默认并行度受核心数限制。线程池饥饿的根因通常是同步阻塞,不是线程不够;先修阻塞,再调 minThreads。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解