知

模块二 · 请求与异步

第 4 / 11 节20 分钟阅读型

async/await 到底在做什么

async 不是"开线程",await 也不是"阻塞等待"。这一节讲清状态机、同步上下文、Task 组合、取消与 async void 的风险。

读完这一节,你能做到

  1. 说清 async/await 编译后的状态机与执行流程。
  2. 解释 ConfigureAwait(false) 的作用与适用场景。
  3. 用 WhenAll/WhenAny 做并发组合,用 CancellationToken 取消。
  4. 判断 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();
}

执行过程:

  1. 调用方法,运行到第一个未完成的 await。
  2. 注册回调,返回一个未完成的 Task 给调用方。
  3. IO 完成后,回调恢复状态机,继续执行剩余代码。
  4. 全部完成,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:它只是把阻塞操作挪到线程池,浪费线程。

七、常见坑

  1. 用 .Result/.Wait() 同步阻塞异步方法,可能死锁。
  2. 连续 await 本可并行的任务,白等。
  3. async void 吞异常。
  4. 忘记传 CancellationToken,请求无法取消。
  5. 在循环里创建 Task 但不等待,异常丢失。

八、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 用 WhenAll 并发调用两个延时接口,对比串行耗时。
  2. 用 CancellationTokenSource 实现 2 秒超时,观察异常类型。
  3. 写一个 async void 抛异常的例子,再用 async Task 修好。

② 让 AI 写同一题

把第 2 题(异步接口改造)交给 AI,补一句 禁止 async void,必须传 CancellationToken。

③ 对比要盯的点

  • 它有没有写 async void?异常会直接打到线程池,没人接得住。
  • CancellationToken 它一路传下去了吗?还是只在签名上挂了一个。
  • 它用了 .Result 或 .Wait() 吗?那是同步阻塞异步,高并发下会耗尽线程池。

④ 一句话取舍

客户端断开时,它那版的后台任务会停吗?

九、自测

  1. async/await 编译后是什么?
  2. ConfigureAwait(false) 解决什么问题?
  3. WhenAll 与 WhenAny 的区别?
  4. 取消为什么是协作式的?
  5. 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。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解