在 C# 中给方法加上 async,并不会自动让其中的工作变得更快。理解异步的关键,是弄清楚程序在等待什么,以及等待期间是否还占着调用线程。
下面以 .NET 8 及以上的 HTTP API 为例,代码展示核心调用方式。调用端应持有并复用 HttpClient,例如通过依赖注入管理,而不是每请求一次就创建一个实例。
await 释放的是等待,不是计算成本
网络请求通常需要等待远端响应。await 一个尚未完成的异步操作,可以让调用方把控制权交还,而不是同步阻塞当前线程。Microsoft 的异步编程说明
如果代码执行的是大规模图像计算或光线追迹,CPU 工作仍然需要真实运行。桌面应用可以按需求把 CPU 工作交给后台任务以保持界面响应,但这不意味着计算量消失;服务器端也不应把所有工作无差别地套进 Task.Run。
给取消建立一条完整链路
CancellationToken 表示取消请求,它不是强制终止线程的开关。任务是否能及时停止,取决于下游操作和自己的循环是否配合检查。.NET 协作式取消模型
下面的方法把用户取消和超时合并到一个请求令牌中:
1 | using System; |
为了让这个示例中的超时来源更清晰,调用方可为专用 HttpClient 设置 Timeout = Timeout.InfiniteTimeSpan,由上述令牌控制期限。如果同时保留 HttpClient 自身的超时,就需要一并分析它产生的取消,不能把所有 OperationCanceledException 都简单归为用户取消。
筛选条件只能反映异常处理时观察到的状态。在用户取消和期限几乎同时发生时,分类策略需要业务自行约定,不能据此精确还原所有竞态的先后顺序。
调用端决定如何呈现结果
1 | try |
这里的 client 和 userCancellationToken 由调用方提供。实际界面程序应将结果映射成界面状态,不要直接在通用下载方法里显示弹窗。
常见的三个混淆
第一,调用异步方法后用 Result 或 Wait 同步等待。在存在特定同步上下文的界面应用中,这可能造成死锁;在其他环境中也会阻塞线程。尽量让调用链持续使用 await。
第二,只给外层等待设置期限,却没有把取消请求传给实际操作。停止等待并不一定代表底层工作已经停止,例如 WaitAsync 的等待期限本身不会自动替你取消原始任务。
第三,用 async void 编写普通业务方法。除事件处理器等必要场景外,返回 Task 更方便调用方等待并处理异常。
怎样验证
至少覆盖正常完成、用户主动取消、超过期限和网络失败四种情况。对于包含重试的操作,还要确认取消后不会继续启动下一次重试。
把这些情况分开处理后,界面提示、日志和重试策略才有明确依据。
期限应该覆盖哪段工作
先把一次操作画成时间线:排队等待、连接建立、发送请求、等待响应头、读取响应体、解析结果、提交业务状态。给某一个 await 设置十秒,不代表整个时间线都受十秒约束。
例如使用 ResponseHeadersRead 时,SendAsync 可以在响应头可用后返回,正文仍未下载完成。此时还必须把同一个总期限传给后续读取,否则服务器每隔一段时间发送少量正文,程序可能持续等待很久。HttpCompletionOption 的完成边界
“总期限”和“空闲超时”也有区别:总期限限制整个操作耗时,空闲超时限制两次进度之间最长等待。一个大文件可以始终有进度,却超过总期限;另一个小文件可能总时长尚短,但已经长时间没有任何进度。产品到底关心哪一种,应先说清楚,不能把两个参数都叫 timeout。
若允许三次重试,每次都重新分配十秒,最坏总耗时还要加退避时间。更合理的设计是先建立一个总预算,再让每次尝试从剩余预算中领取期限。这里的预算是业务决定,不是靠不断加取消源自动推导出来的。
正文读取还需要字节上限
下面的方法适用于 .NET 8,演示如何把同一个令牌贯穿流读取和目标写入。它只处理正文复制,不包含 HTTP 状态检查,也不拥有输入和输出流。调用方应在前述 SendAsync 的响应对象有效期间调用,并用 using 保持响应、输入流和取消源的生命周期。
1 | using System; |
Content-Length 可以用于提前拒绝过大的响应,但它可能缺失,也不能替代实际读取上限。若响应经过自动解压,还要明确上限针对的是网络传输字节还是解压后字节。磁盘写入失败与网络失败也是不同错误,不能全都进入“再发一次 HTTP 请求”的重试分支。
写文件时先写独立临时文件,校验完成后再提交为正式文件。取消后留下半个文件并不意味着下载已完成;清理失败时记录临时路径即可,不要覆盖已有正常结果。流的所有权也要写进接口说明:复制方法不会主动关闭调用方传入的目标流。
停止等待与停止底层操作必须分别验证
考虑一段不支持取消的第三方计算:先得到 workTask,再调用 workTask.WaitAsync(timeout)。外层等待可以超时返回,但原任务仍可能继续占用 CPU、写文件,最后再抛异常。Task.WaitAsync 方法
这会引出三项责任。第一,原任务最终由谁观察异常;第二,在它停止前是否允许启动同一资源上的新任务;第三,它产生的过期结果能否提交。仅仅 catch TimeoutException 并把界面改成“失败”,并没有回答这些问题。
对于允许协作取消的代码,应把令牌传到底层并等待其收尾;对于不可取消的库,可能需要限制并发、隔离进程或放弃结果提交,但要按库能力设计,不能假装 CancellationToken 能强行杀死任意计算。Dispose 取消源也不等于发出取消请求,两者承担不同责任。
用户取消和超时同时发生,分类是一项业务策略
前文异常筛选在处理异常时读取两个令牌的状态。如果用户在超时发生后、catch 执行前点击取消,两个令牌都可能为真,程序无法只靠这些布尔值精确还原时间顺序。应明确展示策略,例如优先把显式用户取消呈现为取消,同时在日志保留“期限也已触发”的事实。
如果必须记录谁先请求停止,可以在各取消来源触发时竞争设置一个一次性原因标识,并记录单调时钟时间;但那仍是“本地观察到停止请求”的顺序,不是远端服务器停止工作的顺序。不要从日志中的毫秒时间戳推导出不存在的分布式因果关系。
日志宜包括操作 ID、阶段、已读字节、总耗时、调用方取消状态、期限状态和异常类型。URL 中若含查询令牌,应先脱敏。一次连接中断和一次业务取消可以有相似堆栈,但恢复动作往往不同。
取消后还有结果返回,怎样避免旧状态覆盖新状态
搜索框连续请求 A、B 是一个典型例子。A 的令牌被取消,不代表它绝不返回结果:它可能在取消请求前已经完成,也可能下游没有及时响应。若 A 比 B 更晚写入界面,就会出现输入新关键字却显示旧内容的问题。
可以同时使用取消与代次检查:每次请求生成递增 generation,只有当前 generation 的结果能够提交。取消用于节省无用工作,代次用于保护业务状态,两者不是替代关系。界面提交仍需遵守 UI 线程要求,ConfigureAwait(false) 不能让界面控件跨线程访问变得合法。
一套可重复的验证矩阵
除了原来的正常、取消、超时和网络失败,还要加入慢响应头、正常响应头后卡住正文、正文逐字节缓慢到达、下载超过字节上限、目标流写入失败、取消与期限同时触发、被取消的旧任务晚返回。
这些情况可以通过自定义 HttpMessageHandler 和 Stream 构造,不必依赖一个偶尔变慢的公网服务。对于忽略取消令牌的假实现,要给测试本身设置明确的收尾机制,避免测试进程被永久挂住。断言也应检查业务状态没有被旧任务覆盖,而不是只确认某处抛出了取消异常。
本次深化保留原文标题和链接。BodyCopier 已在 .NET SDK 8.0.421 独立临时项目编译,13 项内存流与可控故障断言通过,覆盖精确上限、跨缓冲区、超限、空输入、取消、阻塞读写期限和目标写失败。该验证不包含真实 HTTP,也没有完成桌面或服务器应用集成;原文下载器与调用端片段仍需在实际项目中验证。