☰
深入 .NET CancellationToken:生产环境的 5 个取消陷阱与解决模板
2026/10/10 21:51:26 网站建设 项目流程

最近接手一个订单处理服务的时候,我发现一个很奇怪的现象:接口偶尔会返回 200,但是数据库里的订单状态却一直停在“处理中”,而且日志里根本看不到任何异常。排查了两天,最后定位到一个很不起眼的地方——某个异步任务里有人写了await Task.Delay(TimeSpan.FromSeconds(30)),没传CancellationToken。结果就是请求超时被网关断开后,后端服务还在傻等,等完之后继续往下写状态。你说这算不算生产环境事故?严格来说不算宕机,但用户那边看到的就是“订单卡住了”或者“按钮点了没反应”。

这个项目让我下决心把 .NET 取消令牌机制彻底梳理一遍。很多人对CancellationToken的理解停留在“能取消 Task 就行”的层面,可真上了生产环境,坑比想象中多得多。这篇文章不讲教科书,只讲我从实际故障里总结出来的 5 个大坑,以及每个坑背后的原理、排查思路和最终解法。如果你是写 ASP.NET Core 服务、后台任务、网关或中间件的人,这篇文章值得你花十分钟读完,并且直接抄走最后的公共模板。

1. 取消令牌的工作方式:它不只是一个“抛异常开关”

先花点时间把底层机制讲清楚。CancellationTokenSource(下面简称 CTS)是“总开关”,它通过Cancel()触发取消信号;CancellationToken是分发给各个任务和方法的“信使”。这个设计把“取消的发起者”和“取消的响应者”彻底解耦——调用方不需要知道谁在处理任务,被调用的方法也不需要知道是谁发起的取消。

1.1 从 CTS 到 Token 的传播链

一个典型的取消链路是这样的:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); // 分发 token 给下游任务 var result = await ProcessOrderAsync(orderId, cts.Token);

在ProcessOrderAsync内部,又会把这个 token 继续传给更底层的数据库操作、HTTP 调用等:

public async Task<OrderResult> ProcessOrderAsync(int orderId, CancellationToken cancellationToken) { // 先检查任务是否已经被取消 cancellationToken.ThrowIfCancellationRequested(); // 传给数据库 var order = await _db.GetOrderAsync(orderId, cancellationToken); // 传给下游 HTTP 服务 var stock = await _stockClient.CheckStockAsync(orderId, cancellationToken); return BuildResult(order, stock); }

这里的关键点是:取消信号必须沿着调用链一层一层传下去。任何一个环节丢失 token,整条取消链路就会在这里断掉。你可能在 Controller 层接到 token 后传给了第一个 Service,但那个 Service 内部调私有方法时忘了传,后续所有耗时操作就都失去取消能力了。

1.2 回调注册与主动检查的取舍

CancellationToken内部有两种机制让你“感知取消”:一种是通过Register注册回调,另一种是在代码里主动调用ThrowIfCancellationRequested或者检查IsCancellationRequested。

Register更像“订阅通知”——取消发生时,回调会被执行。这个回调可能被注册到任意线程上,由 CTS 内部通过线程池调度执行。适合做清理工作,比如关闭文件流、释放连接、写日志。

主动检查则更适合“每执行一段操作就停下来看看”的场景。比如你要循环处理几万条数据,每处理 100 条就检查一次 token,避免一个超长循环把人家的取消请求晾在一边。

这两者不是互斥的,实际代码里应该组合使用。但很多人只用了其中一个,或者把回调注册当摆设,后面我会展开讲。

1.3 链条的断裂点才是事故高发地

生产环境里最常见的问题不是 CTS 用不对,而是“信号发了,但没人听到”。举个例子:

// 错误示范 public async Task<byte[]> ReadFileAsync(string path) { var data = await File.ReadAllBytesAsync(path); // 没有 token return data; }

这个方法如果被一个耗时的文件读取卡住,调用方就算取消了请求,这个文件读取也会继续执行,直到读完为止。如果同时有大量这种请求进来,线程池很快就会被这些“僵尸任务”占满。

所以排查取消问题的时候,我第一步会顺着调用链把所有方法签名翻一遍,看看 token 是在哪一层开始“消失”的。这一步做完,大概率能找出 70% 的问题。

2. 陷阱一:Token 只在入口处检查,底层 IO 根本听不到取消指令

这是所有坑里最隐蔽、也最影响系统稳定性的一种。

2.1 生产故障的典型现场

某个内部系统在高峰期偶尔会有接口卡顿。表象是明明客户端已经断开连接了,但服务端的任务日志显示这个方法一直没结束。更诡异的是,进程里的线程数持续上涨,任务积压越来越多。用工具抓了 dump 之后发现,大量线程卡在Task.Delay上,还有一部分卡在 HTTP 调用的等待响应上。

我当时一眼就看明白了:入口方法签名里有CancellationToken,但调用链往下走三层之后,token 就再也没出现过了。

public async Task<string> FirstLayerAsync(CancellationToken token) { var item = await SecondLayerAsync(); // 忘了传 token! return item; } private async Task<string> SecondLayerAsync() { // 这里有一个远程调用,可能持续几十秒 return await _client.GetStringAsync(url); // 同样没 token }

2.2 把 Token 传到位才是第一优先级

方法签名里加一个CancellationToken参数,看起来是小改动,但对整个系统的影响是决定性的。我给自己定了两条规矩,现在每次写代码都会遵守:

  • 所有可能耗时超过 100ms 的方法,都必须接收CancellationToken。
  • 调用下游方法时,只要对方提供了带 token 的重载,就一定要传。

这两条听起来像废话,但很多代码老手都会犯“嫌麻烦”的毛病。尤其是项目里如果积累了老代码,很多方法从一开始就没设计 token,后来人接 try 的时候想传也没参数可传,最后就只能停留在“入口处检查一下”的程度。

2.3 数据库和 HTTP 调用怎么正确传递

以 EF Core 为例,支持 token 的写法很多人在用,但用不完整:

var orders = await _db.Order .Where(o => o.UserId == userId) .ToListAsync(cancellationToken); // 查询时带上

其实SaveChangesAsync、FirstOrDefaultAsync、SingleOrDefaultAsync这些都有带 token 的重载。如果是原生 ADO.NET 的话,SqlCommand也支持CancellationToken:

var command = new SqlCommand(sql, connection); // ... await command.ExecuteReaderAsync(cancellationToken);

HTTP 调用方面,HttpClient的所有异步方法也都支持传 token。有一个非常容易被忽略的死角是:GetAsync如果不传 token,内部用的是HttpClient.Timeout控制的超时,但超时和取消是两个概念。超时是指“我再也不等你了”,取消是“调用方主动不要了”。如果你只靠HttpClient.Timeout兜底,客户端断开后请求依然会在服务端跑完,只是等到超时那一刻你才知道。

2.4 遇到不支持的 SDK 怎么办

总有一些老第三方 SDK 的方法没有 token 参数。这时候有几种常见的兜底方案:

  • 把调用包在Task.WhenAny里,和“等待取消”的任务赛跑:
private static async Task<T> WithCancellationAsync<T>(Task<T> task, CancellationToken token) { var tcs = new TaskCompletionSource<bool>(); using (token.Register(() => tcs.TrySetResult(true))) { var completedTask = await Task.WhenAny(task, tcs.Task); if (completedTask != task) { throw new OperationCanceledException(token); } return await task; } }
  • 用Task.WaitAsync(.NET 6+)给一个固定的超时和取消组合:
var result = await task.WaitAsync(TimeSpan.FromSeconds(10), token);

不过说实话,上面这些方案都不如“改 SDK”来得干净。第三方库如果不支持取消,优先给作者提 issue 或者换一个维护更活跃的库,包一层WhenAny终究是权宜之计,因为它会把取消信号“吞掉”,底层任务可能仍然在跑,只是调用方不再等它了。如果你非用不可,得确保这个底层任务不会持有关键共享资源,否则照样泄漏。

3. 陷阱二:把取消异常当成普通错误处理,导致超时统计全部失真

第二个坑集中在异常处理上,但它影响的不只是稳定性,还有监控指标的正确性。

3.1 OperationCanceledException 和 TaskCanceledException 的关系

先厘清一个基础概念:TaskCanceledException继承自OperationCanceledException。前者通常表示一个任务因为取消而中止,后者更宽泛,包含所有“操作被取消”的情况。

生产环境里的常见误操作是,一个 catch 块把所有OperationCanceledException都当成了“业务失败”,记录错误日志、增加失败计数、甚至返回 500 状态码。结果就是调用方明明是因为超时/主动取消而断开,服务端却把它记成一次“系统异常”,告警不断,监控图全花。

3.2 “取消”和“失败”混淆的后果

举个例子。一个下订单接口设置了 5 秒超时:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { var result = await _orderService.CreateAsync(orderId, cts.Token); return Ok(result); } catch (Exception ex) { _logger.Error(ex, "创建订单失败"); // 取消也被记成了失败 return StatusCode(500); }

如果下游数据库压力大,5 秒内没响应,cts到时间自动取消,CreateAsync就会抛OperationCanceledException。上面的代码把这个异常当成了创建失败,接口返回 500。客户端收到 500 后可能触发重试,重试又打来一波请求,进一步加剧数据库压力——一个纯粹的取消场景,硬生生变成了雪崩的导火索。

3.3 一套可落地的异常区分模板

正确做法是在 catch 时区分“真正的失败”和“取消”。我通常这样处理:

try { var result = await _orderService.CreateAsync(orderId, cts.Token); return Ok(result); } catch (OperationCanceledException) when (cts.IsCancellationRequested) { // 主动取消或超时取消,不记错误日志,按 499 或直接返回 _logger.LogInformation("订单创建已被取消,订单号 {OrderId}", orderId); return StatusCode(499); } catch (Exception ex) { _logger.LogError(ex, "创建订单失败,订单号 {OrderId}", orderId); return StatusCode(500); }

注意when (cts.IsCancellationRequested)这个过滤条件。有些情况下OperationCanceledException是下游某个操作自己抛的,不代表当前这个请求被取消。通过判断当前 CTS 的状态,可以精确区分“外部取消”和“内部异常”。

还有一个细节:如果你在自己的业务代码里主动ThrowIfCancellationRequested(),异常堆栈上会有明确的调用点,但如果你依赖框架层自动抛出的取消异常,通常会在异步状态机层面,比较难看。为了便于排查,我建议在业务关键路径上主动调一次ThrowIfCancellationRequested,这样一旦出问题,堆栈能直接指到业务代码位置。

至于日志和指标,取消不应该记入错误率。我们会单独记一个canceled指标,用来观察是哪些接口高频被取消,这往往是上游超时设置不合理或者下游响应过慢的预警信号。把取消和错误混在一起统计的监控大屏,很容易让你在复盘时做出完全错误的判断。

4. 陷阱三:注册回调的线程安全隐患与任务假死

这个坑主要出在Register的滥用和异步等待的误用上。很多人一旦知道“取消时可以注册回调”,就会兴奋地在回调里塞一堆逻辑,结果反而制造了新的问题。

4.1 Register 回调里不要执行耗时操作

Register回调默认运行在调用了Cancel()的那个线程上,也就是说谁触发取消,谁就要顺带把这些回调执行完。如果你的回调里有阻塞操作——比如等锁、调数据库、刷日志——那么发起取消的那个线程会被阻塞住。

更隐蔽的是,如果回调里抛出了异常,这个异常会传播到调用Cancel()的线程上,很可能直接导致某个后台任务崩溃。清理回调的正确姿势是:只做轻量级的标记、信号量释放或者TaskCompletionSource的TrySetResult,把真正的耗时清理放到等待任务的本体里。

// 这是推荐的写法 token.Register(() => cleanupSignal.TrySetResult(true)); // 而不是 token.Register(async () => { await _db.SaveChangesAsync(); });

异步 lambda 更不能直接注册。Register的回调类型是Action,异步 lambda 编译后会变成async void,异常直接抛到线程池,根本抓不到。如果你真的需要在取消时做异步清理,请在外面先await一个等待取消的任务,再执行清理。

4.2 同步上下文死锁是最膈应人的事故

ASP.NET Core 默认没有同步上下文,所以现代 Web 应用一般不踩这个坑。但在 WPF、WinForms、Xamarin 或者一些需要依赖同步上下文的项目中,死锁依旧存在。

典型的场景:UI 线程里直接.Result或.Wait()等待一个异步任务,而这个任务内部又在取消回调里尝试回到 UI 线程执行,于是两边互相等,任务假死。这其实是“取消”放大了原本就存在的异步阻塞问题。你在生产环境里尤其要小心那些“伪异步”代码——比如在BackgroundService里同步等待、在事件处理器里.Wait()。

4.3 Task.WaitAsync 不是万能钥匙

.NET 6引入了Task.WaitAsync,允许你给一个任务同时指定超时和取消令牌。很多人拿它解决“SDK 不支持取消”的问题,但它有个副作用:WaitAsync取消之后,底层任务并不会被取消,它还在跑。也就是说,WaitAsync只是“我不等你了”,不是“让任务停下来”。

后来我养成了一个习惯:只用WaitAsync作为最后防线,同时一定跟踪底层任务的状态。如果是用WhenAny+Register的自定义 wrapper,我会在 wrapper 里保证最终把底层任务的异常或者结果消费掉,避免 UnobservedTaskException 的产生。

这里还要补一句:打断阻塞任务 ≠ 取消任务。Thread.Interrupt、Thread.Abort这些就别再用了,CancellationToken是协作式取消,不是强制杀死线程。指导思想是“让任务自己意识到该停了”,而不是“把任务连根拔起”。

5. 陷阱四:Cts 不释放导致的定时器堆积与内存压力

如果前面的坑属于“功能不对”,这个坑属于“性能耗损”。它很慢,但会在高并发下悄悄压垮你的进程。

5.1 CancelAfter 背后的 TimerQueue 机制

CancellationTokenSource有一个构造函数带TimeSpan参数,也可以调用CancelAfter实现“到时自动取消”。这背后的实现不是简单的Thread.Sleep,而是基于 .NET 内置的定时器队列TimerQueue。每个 CTS 被创建并设置CancelAfter后,它内部就会在TimerQueue上注册一个定时器节点。

大量短期 CTS 如果频繁创建但不释放,定时器节点就一直在队列里挂着。每次设置CancelAfter都会触发定时器链表的插入、排序、扫描,当并发量上来之后,这些操作会积累成肉眼可见的 CPU 和内存开销。

5.2 LinkedCts 与回调订阅泄漏

CancellationTokenSource.CreateLinkedTokenSource是一个很常用的 API,用来把多个 token 合并成一个。它内部会在每个被连接的源上注册回调。如果你创建了很多 LinkedCts,但用完之后没有Dispose,这些回调引用会一直存在,导致外层 CTS 无法被 GC 回收,形成内存泄漏。

这个泄漏特别隐蔽,因为你看不到一个单独的“大对象”,而是无数个小对象互相引用,用 dotMemory 或者 dump 分析才能揪出来。定位到之后你会发现,某个长期运行的服务内存只增不减,就是因为一个CreateLinkedTokenSource没有释放。

5.3 高吞吐场景下的统一释放策略

我现在的做法是:CTS 一律用using包裹,并且Dispose和Cancel的顺序要谨慎。共享一个刚改过的模板:

public async Task<Response> HandleAsync(Request request, CancellationToken requestToken) { // 先把外部 token 和本地超时合并 using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(requestToken); timeoutCts.CancelAfter(TimeSpan.FromSeconds(10)); // 业务处理 try { return await Process(request, timeoutCts.Token); } catch (OperationCanceledException) when (!requestToken.IsCancellationRequested) { // 本地超时导致的取消,可以单独处理 return Response.Timeout(); } }

这里有两条经验:

  • 外部传入的 token 不要自行Dispose。它由创建者管理,你只负责使用。
  • 自己创建的 CTS 一定要Dispose。using是最省心的方式。如果有些地方实在不能用using,就在finally里手动Dispose。

释放这个动作还有一个隐藏的好处:它会触发内部清理逻辑,断开注册的回调引用,让 GC 能更早回收相关对象。在高并发场景下,这一步做与不做,内存曲线的差别非常大。

6. 陷阱五:取消成功之后的“半完成状态”没有兜底

最后一个坑,严格来说是“取消后的善后逻辑”没有设计好。很多人以为取消令牌只影响任务的“提前终止”,但生产环境里更常见的问题是:任务被取消了,但是数据被写进了一个半完成的状态,或者后续逻辑因为这次取消产生了错误数据。

6.1 Finally 里的清理逻辑是否幂等

一份比较典型的错误代码长这样:

var order = await _db.GetOrderAsync(orderId, token); try { order.Status = OrderStatus.Processing; await _db.SaveChangesAsync(token); // 远程扣库存,这个调用比较慢 await _inventoryClient.DeductAsync(orderId, token); order.Status = OrderStatus.Completed; await _db.SaveChangesAsync(token); } finally { // 这里如果被取消,order.Status 还是 Processing,而且可能压根没保存 // 如果你在 finally 里做了补偿,又可能因为网络重试导致重复扣库存 }

假设DeductAsync在执行到一半时被取消,catch没有兜底,finally也没做任何状态修正,整个订单就停留在Processing。如果下游有一个扫描“处理中超过 5 分钟”的定时任务,它会把这条订单捞出来重新处理,重新去扣库存——这就是经典的重复扣款问题。

解决思路不是让finally越复杂越好,而是把整个操作设计成“阶段化 + 幂等”的:

  • 每个阶段开始前检查 token,确保不进入一个注定无法完成的分支。
  • 取消发生后,把当前状态写清楚(比如PendingRecovery),并单独记录一条“补偿日志”。
  • 补偿操作必须有全局唯一的业务键,数据库加唯一索引,保证任何重试都不会重复扣款。

6.2 取消后更新状态记录的竞态

还有一个小型竞态:取消回调里你更新了内存标记,而业务代码同时在写数据库。如果取消发生在SaveChangesAsync的提交期间,EF Core 会怎么处理?实际上它会在内部检测到取消并回滚当前事务,可能抛出的并不是OperationCanceledException,而是DbUpdateException,具体取决于运行时内部流程。这个问题在低版本运行时里面出现过,所以不要假设“取消了就一定能收到取消异常”。

6.3 干净的三态设计:完成、失败、取消

到我手上的项目,我会建议业务接口在设计阶段就把“取消”定义为一种显式的终态,而不是“未知”。比如订单状态枚举里必须有Canceled这个值,并且取消之后如果还有未完成的子任务(发短信、推送、发送 webhook),需要走专门的“延迟重试队列”,而不是当作失败立刻重试。

“取消”本身是可以作为一次合法业务流程结束的——业务方主动放弃、超时未完成,都应该有对应的状态收敛。切忌把取消当成异常、把异常当成失败、把失败当成重试信号。一个只含Success/Failed两态的系统,碰上取消必出幺蛾子。

7. 生产环境可复用的取消策略:一个公共模板

到了收尾的部分,我把自己常用的一套取消策略框架写在这里。它不是银弹,但至少能帮你把前面 5 个坑都堵上大部分。

7.1 公共 TokenHandler 的设计

我习惯封装一个CancellationContext类,把 CTS、外部 token、超时策略和清理回调统一管理起来。大致骨架如下:

public sealed class CancellationContext : IDisposable { private readonly CancellationTokenSource _linkedCts; public CancellationToken Token => _linkedCts.Token; public CancellationContext(CancellationToken externalToken, TimeSpan timeout) { _linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalToken); _linkedCts.CancelAfter(timeout); } public void Cancel() => _linkedCts.Cancel(); public void Dispose() => _linkedCts.Dispose(); }

使用的时候:

using var ctx = new CancellationContext(requestToken, TimeSpan.FromSeconds(10)); var result = await DoWorkAsync(ctx.Token);

这个封装带来的统一好处是:超时、外部取消、主动调用Cancel()全都会触发同一个 token。你不需要在每个业务方法里都去构建 CTS。

7.2 压测与诊断手段

再分享一个诊断经验。如果怀疑取消没生效,先别急着加代码。我会先做一轮压测,在压测过程中手动触发取消(模拟客户端断开),然后看两件事:

  • 线程池活动线程数是否回落。如果不回落,说明有不少任务仍然阻塞着,取消信号没起效。
  • 进程内存是否持续增长。如果增长,重点检查所有创建了 CTS 的地方有没有释放。

一些诊断命令对这类问题很有用,比如查看线程池状态、抓 dump 分析任务堆栈等等。如果任务堆栈全都停在等待库函数调用的地方,那基本就是 token 没传到底层;如果停在Task.Delay或同步等待上,那就是内部有阻塞调用没走异步。

7.3 分享几条个人经验

文章写到这,我最后想说的经验可能有点反常识:取消令牌设计的重点,不在于“怎么取消”,而在于“取消之后怎么收场”。

一开始我也爱研究Register、ThrowIfCancellationRequested、WaitAsync这些 API 的底层实现,但真正把生产环境搞崩的,往往不是 API 不会用,而是整个链路少了某几个 token,或者在 catch 里把取消当成异常。你在代码评审的时候,可以专门多问一句:“如果这个操作被取消了,数据库里会留下什么状态?”很多问题当场就能暴露。

另外,尽量把超时和取消分开理解。超时是可以预测的放弃,取消是不可预测的中断。两者都需要收敛到明确终态。把超时也设计成一种“内部原因的取消”是常见做法,但必须能区分它和外部取消,否则监控数据和用户反馈会互相矛盾。

这个领域还有很多细节可以聊,比如自定义 Awaitable 的取消响应、分布式任务里如何把取消信号传遍集群等等。先把手头的 5 个坑填平,比摸更多花哨 API 更实在。希望这篇文章能让你在下一次代码评审里,少看到一行Task.Delay(3000)后面没跟 token。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询