
你点了界面上的“取消”按钮日志里那行进度却还在刷你明明调用了CancellationTokenSource.Cancel()后台任务照常跑完才反应——这类问题我见过太多次了。C# 的 Task 取消机制看似简单无非是CancellationTokenSource配CancellationToken但真正用对的人不多。尤其是接触 C# 多线程、上位机开发、TCP 通信这类场景时取消逻辑写得对不对直接影响系统能不能正常退出、UI 会不会卡死、外部进程会不会变成僵尸。这篇文章就把 CancellationToken 的使用模式从头拆到尾从底层原理讲到高频陷阱再说几个我实际踩过的坑希望能让读者少走一段弯路。1. 协作式取消到底在取消什么1.1 线程不能被杀只能被说服很多第一次接触 .NET 异步模型的开发者会有一个直觉取消就是“让那个 Task 停下来”。这个概念一开始就是错的。在 C# 里你几乎没有办法安全地从外部强行终止一个正在运行的用户态线程。Thread.Abort在 .NET Core 时代基本是废掉的硬调会在运行时引发ThreadAbortException整个进程的状态可能变得不可预测——锁没释放、对象处于半初始化、finally 里还做了一半清理。所以现代 .NET 设计了另一种思路协作式取消。协作式取消的含义很直白不是“你命令任务停止”而是“你发出停止请求任务配合停止”。CancellationTokenSource.Cancel()做的事情只是把内部状态置为“已取消”然后通知注册过回调的对象。任务本身如果不检查这个状态、不响应回调取消请求对它就毫无效果。代码不会平白多出一个异常也不会强制中断你正在执行的计算。Thread.IsBackground或者Thread.Interrupt也不是替代方案它们和取消机制根本就不是一类东西。Thread.Interrupt只能打断阻塞中的线程计算密集型的代码它管不了IsBackground只决定进程退出时线程要不要被强杀。真正要取消一个异步任务走的唯一主线就是 CancellationToken。1.2 一个 struct 和一个不锁链表的回调链表理解 CancellationToken 之前先记住一个引用类型CancellationTokenSource下称 CTS和一个结构体CancellationToken下称 Token。CTS 才是“总开关”Token 只是发出去给调用方看的“只读状态快照”。Token 的设计目标之一是不给异步调用增加无谓的内存开销所以它是 struct。你每次把 Token 传进一个方法本质上是复制了一份结构体。这个结构体内部保存了对 CTS 的引用以及一个int类型的_state字段。当 CTS 触发Cancel()它会把这个 state 置为取消状态并通知所有注册的回调。Register的底层实现我拆开看过它不是用一个简单的ListAction加锁而是一个单向链表、每条注册记录用Interlocked.CompareExchange做无锁头插。这意味着注册回调这个动作非常快高并发场景下不会因为取锁而堵塞。但它也带来了一个隐含问题如果你频繁注册、取消注册回调链表的节点回收不及时内存压力会比想象中大。好在 .NET 运行时内部已经处理了大部分场景多数开发者不需要关心这个细节但知道它存在对排查内存缓慢增长的问题是有用的。Token 里还有一个非常关键的细节CancellationToken在“未关联任何 CTS”时CanBeCanceled返回 falseIsCancellationRequested永远返回 false。这在后面讲 API 设计时非常影响判断。2. 三种标准使用模式从传参到注册回调2.1 最小模式把 Token 传下去入门级写法长这样using var cts new CancellationTokenSource(); CancellationToken token cts.Token; var task Task.Run(() DoWork(token), token); // 用户点了取消按钮 cts.Cancel();Task.Run的第二个参数把 Token 关联到了任务本身。这样做有一个隐藏作用如果任务还没开始执行也就是仍在任务队列里排队Cancel 之后这个任务根本不会启动状态直接变成Canceled。如果任务已经开始执行Token 就会被传进DoWork由你决定在哪些检查点响应取消。不要小看“任务还没开始就被取消”这个行为。在实际开发中任务队列里可能排着上百个任务用户在界面上连续点击几次触发按钮正确传 Token 能帮你挡掉大量已入队但没必要执行的工作这比用布尔开关判断要干净得多。DoWork内部的写法就体现了协作式取消的形态void DoWork(CancellationToken token) { for (int i 0; i 100000; i) { token.ThrowIfCancellationRequested(); // 做一点实际工作 } }这种模式叫“轮询”适合 CPU 密集型循环。ThrowIfCancellationRequested()内部实际上就是if (IsCancellationRequested) throw new OperationCanceledException(token);抛出的是携带 Token 的取消异常。提示Task.Run传入 Token 和让DoWork内部再检查 Token 并不冲突。前者负责“任务还没跑就取消掉”后者负责“任务跑起来之后在循环里响应取消”。两个都写上才是完整的。2.2 响应模式让取消触发回调轮询适合处理循环但很多代码不是简单循环而是等待某个 I/O 事件。比如串口收数据、TCP 等待数据到达、用户输入、网络下载包到达。这时候每行代码去检查IsCancellationRequested不现实更好的做法是用Register注册回调。using var cts new CancellationTokenSource(); cts.Token.Register(() { // 取消时执行的清理动作 Console.WriteLine(任务被取消进入清理流程); socket.Close(); // 用关闭资源的方式打断阻塞 }); await socket.ReceiveAsync(...);Register返回一个CancellationTokenRegistration这是 struct实现了IDisposable。如果某段逻辑之前注册过回调但走到某个分支后确定不会再取消应该Dispose掉这个 registration避免回调在组件已被销毁后仍被触发。有个很容易被忽略的细节Register的回调默认是在调用Cancel()的线程上同步执行的。假设你在 UI 线程里点了取消按钮回调里又访问了 UI 控件可能会因为访问顺序和预期不一致而出问题。严谨的做法是把回调里的工作丢到专属线程或者线程池回调本身只做“发出信号”的事。token.Register(() { ThreadPool.QueueUserWorkItem(_ { // 把清理工作放到线程池执行 }); });2.3 异常响应模式用 OperationCanceledException 统一出口.NET 的异步框架在取消发生时有一套自己的异常约定使用OperationCanceledExceptionOCE代表取消。这看起来很简单但牵扯到 Task 状态机时它起着决定性作用。如果一个 Task 执行过程中抛出了携带该 Task Token 的 OCE这个 Task 的最终状态是Canceled而不是Faulted。await这个 Task 时会抛出TaskCanceledException继承自 OCE而不是AggregateException。如果你的代码块里写的是catch (Exception)那么“取消”和“出错”就被混为一谈了。更典型的用法是上层区分try { await someTask; } catch (OperationCanceledException) when (token.IsCancellationRequested) { // 是取消不是真错误 return; } catch (Exception ex) { // 是真正的异常 }注意这里的when (token.IsCancellationRequested)过滤器。它确保你 catch 的确实是“这次取消”引发的 OCE而不是别处顺手抛出的 OCE。一个别人代码里故意throw new OperationCanceledException()的 OCE也会被捕获但匹配条件会把它过滤掉。ThrowIfCancellationRequested这一行代码本身是“检查抛异常”二合一它抛出的异常天然带上了当前 Token所以catch时可以拿token做匹配。这也是为什么我建议在循环检测点只调用它而不是手动写if (token.IsCancellationRequested) throw new OperationCanceledException();——后者很容易写漏 Token 参数一旦漏了取消原因就无法和启动任务时的 Token 对上。3. 取消令牌的高压雷区记录我踩过的几个真实坑3.1 误把 ThrowIfCancellationRequested 当成万能的有一段让我印象很深的代码是接手某个遗留上位机项目时看到的。项目里大量的异步方法长这样public async Taskstring ReadDataAsync(CancellationToken token) { token.ThrowIfCancellationRequested(); var data await ReadFromDeviceAsync(); token.ThrowIfCancellationRequested(); return data; }从表面看这写法一点毛病没有检查点在方法入口和 I/O 返回后各放了一个。但实际运行中有一个场景会出事设备端 I/O 超时特别久用户点了取消之后程序并没有退出而是卡在读设备那个 await 上直到设备异常超时之后第二个检查点才会触发。原因很简单ThrowIfCancellationRequested只响应“运行中”的检查没法打断“正在等待”的 I/O。要真正打断阻塞只能从底层下手——关闭 Socket、挂断串口、发取消指令这些需要注册到Register回调里做而不是靠抛异常。所以我的判断标准是如果你的方法里存在一个会阻塞超过几百毫秒的调用光有ThrowIfCancellationRequested是不够的必须想办法在取消时把那个阻塞调用打断。比如 TcpClient 场景取消回调里 Close 掉连接阻塞的读取会立刻抛异常返回这个异常再转换成取消路径整体流程才算完整。3.2 catch 里吞掉取消异常导致上层死等还有一个高频错误是 catch 写法的问题。很多人习惯写try { await DoWorkAsync(token); } catch (Exception ex) { _logger.LogError(ex, 任务失败); }这段代码看起来只是记录日志不会出大问题。但你仔细想如果DoWorkAsync因为取消原因抛了 OCE这个 catch 也会拦下来打一条“任务失败”的日志然后“假装”任务正常返回了。上层调用者可能还要做“成功之后的下一步”整个流程却已经停在了取消状态里——没抛出没人知道取消发生了。更严重的是某些框架代码里会这样做catch (Exception) { // 什么都不做 }一旦取消异常被吞掉await任务的结果就永远不会变成Canceled状态上层用WhenAll或WaitAll等任务结束的逻辑就会一直等待。尤其是与大量异步任务并发执行的系统一个任务吞掉取消异常可能导致整个取消流程悬停。处理这个问题不复杂如果你确实要捕获所有异常做日志记得把 OCE 重新抛出去或者至少判断token.IsCancellationRequestedcatch (Exception ex) { if (ex is OperationCanceledException token.IsCancellationRequested) throw; _logger.LogError(ex, 任务失败); }这样既不会漏日志也不会破坏取消语义。检查清单里永远不要写“我这边不会有人取消”这种话任何异步方法都可能被上层用 CancelAfter 取消这个设计从根上就得保住。3.3 WaitAsync 取消的不是 Task是“等待”这件事.NET 6开始Task有了WaitAsync(TimeSpan, CancellationToken)这个扩展方法。很多文章喜欢用它做“超时取消”但它的作用范围很容易被误解。await someTask.WaitAsync(TimeSpan.FromSeconds(10), token);这行代码的含义是如果我等someTask超过 10 秒或者 token 被取消我就放弃等待抛TimeoutException或 OCE然后继续往下走。注意它不会取消someTask本身那个任务还在后台跑着只是你不再等它了。这个区别在资源敏感的系统里相当致命。如果你用WaitAsync做超过 10 秒就放弃的“假取消”底层那个任务如果还在占着串口、占着 Socket、占着数据库连接你后续又立刻发起一个新的同类操作端口/句柄竞争就来了。真正的做法是让底层任务也接收同一个 Token或者超时的时候主动触发 CTS 的 Cancel。using var cts new CancellationTokenSource(TimeSpan.FromSeconds(10)); try { await DoWorkAsync(cts.Token); } catch (OperationCanceledException) { // 超时或外部取消任务本身也会感知到 }用CancellationTokenSource(TimeSpan)或CancelAfter做超时是把取消请求发给了被等待的任务而不是单方面地“不等了”。这个区别是实打实的多花几行代码能避免一堆后遗症。我遇到过这样一个案例老同事用Task.WhenAllWaitAsync管理一堆同时上行的网络请求觉得“超时了我不等就是了”。结果每次超时都会有一两个 TCP 连接悬挂在底层久而久之端口耗尽应用完全卡死。换成统一 CTS 超时后所有请求都收到了取消信号Socket 被注册的回调关闭问题彻底解决。每次想起这个案例我都感慨取消机制设计的核心在于“让所有层都能感知”而不是“让某一层满足”。3.4 CancelAfter 的计时器被遗忘内存悄悄涨CancellationTokenSource.CancelAfter(TimeSpan)是一个很便捷的超时实现它内部会注册一个 System.Threading.Timer到时间后触发 Cancel。有些人用完了会忘记 Dispose CTS以为 GC 会兜底。问题出现在循环场景比如一个循环里每轮new CancellationTokenSource(TimeSpan.FromSeconds(5))忘记 Dispose。每轮都会新建一个 Timer虽然到时间后 Timer 会触发取消但 Timer 对象本身被 GC 回收前如果你还持有着 CTS 引用它一直不会释放。一旦这个循环跑得足够频繁内存占用会呈阶梯状上涨。我的习惯是这个凡是创建了 CTS要么立即using要么在 finally 里Dispose。尤其CancelAfter这种带计时器的 CTS不 Dispose 意味着计时器根上一直有引用内存只能靠 GC 的最终处理慢慢回收效率不可控。using var cts new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { await DoWorkAsync(cts.Token); } catch (OperationCanceledException) { }3.5 UI 线程等待取消时差点把界面卡死这个坑在 WinForms/WPF 里特别常见。假设你写了cts.Cancel(); task.Wait(token);task.Wait(token)是同步阻塞等待任务完成但它有个很微妙的行为如果 token 在等待期间被取消它不会“中止等待”而是抛 OCE如果任务还在执行耗时代码没有及时响应取消Wait仍然会一直阻塞到任务完成。也就是说Token 能取消“等待”但取消不了“任务本身在执行中的耗时”。这就引出一个更关键的教训在 UI 线程上永远不要做同步等待。线程池线程也得谨慎。用await配合ConfigureAwait(false)才是正道。UI 线程的场景下点击取消按钮后应该让 UI 线程立刻返回消息循环而不是在按钮事件里死等任务结束。否则界面就“假死”了用户会感觉程序崩溃了。如果你在 UI 线程里必须做类似等待我的做法是无论如何都await task而不是task.Wait()哪怕只是为了更新取消后的状态也走 async 路线。这样 UI 线程永远保持响应取消过程是否顺利用户能直接看到界面反馈。4. 进阶组装超时、父子令牌与并行取消4.1 用一个 CTS 同时管住超时和用户取消实际业务中“用户点了取消”和“系统判断超时”往往是两回事。如果只用一个 CTS那么超时了也就相当于用户取消了取消原因无法区分。更合理的方式是用CreateLinkedTokenSource把多个取消源组合成一个“组合令牌”。using var timeoutCts new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var userCts new CancellationTokenSource(); using var linkedCts CancellationTokenSource.CreateLinkedTokenSource( timeoutCts.Token, userCts.Token); await DoWorkAsync(linkedCts.Token);linkedCts.Token会在任意一个源令牌被取消时触发取消。这样无论是用户点取消还是 30 秒超时任务都会收到取消信号。这个模式在 UI 应用里尤为重要。如果用户启动了某个长任务系统设了一个超时上限同时 UI 上还有“取消”按钮。这三者必须用 linked token 合并而不是各自为战。否则你会遇到任务因超时已经被系统取消防止了UI 却不知道还显示“取消中”或者 UI 取消导致任务终止但超时计时器依然在后台运行白占用资源。链接令牌的 Dispose 顺序有个讲究最好先 Dispose linkedCts再 Dispose 源头 CTS。虽然多数情况下先 dispose 源头也不会有异常但为了保险始终遵循“从组合到源”的释放顺序比较稳妥。4.2 如何在不支持取消的 API 上做取消现实世界的库并不都支持 CancellationToken。有些老库的异步方法就是Task Run()不接受任何取消参数。这时你怎么实现“取消”答案是要么在库外层做超时要么通过回调打断阻塞操作。以HttpClient为例新版HttpClient支持 Token 参数老代码可能需要你自己发取消请求。另外一个常见例子是Process的异步输出读取、ThreadPool待执行任务等。对于不支持取消的异步方法我的常规做法是如果方法底层是 I/O 阻塞想办法拿到底层资源句柄在取消回调里关闭它。如果底层是纯计算无法打断只能设定超时后放弃等待但必须认识到任务本身还在继续。如果底层支持“取消回调”机制比如某些 SDK 有CancellationToken.Register的入口就注册回调做转换。比较典型的例子是Task.Run一个不接受 Token 的内部方法——比如第三方 CPU 密集方法Compute()。它本身没法取消但如果你硬要套一层 Token可以这样async Task ComputeWithCancellationAsync(CancellationToken token) { var computeTask Task.Run(() Compute()); await Task.WhenAny(computeTask, Task.Delay(Timeout.Infinite, token)); token.ThrowIfCancellationRequested(); await computeTask; }这里Task.WhenAny和Task.Delay(Timeout.Infinite, token)组合实现“等待任务完成或令牌取消”。但注意Compute()本身仍然在后台运行不会因为你取消就被终止。这只是“上层不再等待”的语法糖真正要释放 CPU 还是得靠计算内部配合。所以厂商文档里那句“不能保证取消一定会停止任务”就是这种含义。4.3 Parallel、ForEachAsync 和 WhenAll 的取消.NET 的并行 API 都有一个接收ParallelOptions或CancellationToken的重载。如果你写Parallel.For传 Token 进去需要注意它的行为它不会在你取消的瞬间立即中断所有正在跑的迭代而是在每个迭代开始前检查 Token。这符合协作式取消的设定。var options new ParallelOptions { CancellationToken token }; try { Parallel.For(0, 100, options, i Compute(i)); } catch (OperationCanceledException) { }对于异步流式处理.NET 6 的Parallel.ForEachAsync也支持 Tokenawait Parallel.ForEachAsync( items, new ParallelOptions { CancellationToken token }, async (item, ct) { await ProcessItemAsync(item, ct); });这里的ct是循环内每个迭代拿到的 Token从options上继承。写ProcessItemAsync时没必要再额外传 option 里的 Token 了直接用ct就行。Task.WhenAll本身不接收 Token这一点让很多人困惑。你想“如果 token 在等待期间被取消WhenAll 能不能提前退出”答案是不能。WhenAll会一直等待全部任务结束。取消逻辑的正确做法是给每个子任务传同一个 Token让它们自己提前结束或者用Task.WhenAny组合等待。var allTasks items.Select(item ProcessItemAsync(item, token)); try { await Task.WhenAll(allTasks); } catch (OperationCanceledException) { }如果某个子任务及时响应了取消抛出的 OCE 会从WhenAll抛出来整体流程就可以走入取消路径。但如果子任务里有人吞了取消异常或者压根不检查 tokenWhenAll会永远等待这个锅只能由编写子任务的人来背。所以要约定异步方法内部收到取消信号后应当尽快抛出 OCE而不是静默返回。5. 实战案例上位机通信和外部进程的取消改造5.1 上位机串口/TCP 读取循环的正确取消方式做 C# 上位机开发的人一定熟悉这类场景开一个后台线程循环读取串口或 TCP 数据界面通知“停止采样”时这个循环必须立刻退出并释放串口。很多人第一版代码是这样写的private async Task ReadLoopAsync(CancellationToken token) { while (true) { byte[] buffer new byte[1024]; int len await _serialPort.BaseStream.ReadAsync(buffer, 0, buffer.Length, token); if (len 0) break; ProcessData(buffer, len); } }看着好像已经用了 Token但SerialPort.BaseStream.ReadAsync的取消行为并不总是理想。在某些驱动实现里取消请求虽然会让ReadAsync抛 OCE但依赖驱动对CancelIoEx的支持如果驱动不支持真正取消还是得靠关闭端口。所以上位机项目的标准做法是“双保险”一方面把 Token 传给所有支持取消的 API另一方面在token.Register里调用Close()/Dispose()释放底层资源。这样不管驱动支不支持取消 API资源层都会被打断阻塞读取立刻返回。try { using var cts new CancellationTokenSource(); // 注册取消回调强制关闭串口/Socket cts.Token.Register(() { try { _serialPort.Close(); } catch { } }); await ReadLoopAsync(cts.Token); } catch (OperationCanceledException) { // 取消流程结束 }这招在 TCP 通信里也适用。Socket.ReceiveAsync用 Token 取消有时候不会立刻生效但在回调里直接socket.Shutdown(SocketShutdown.Both)或者直接Dispose保证读取立刻被打断。实际测试下来取消响应时间能降到毫秒级而不是傻等 I/O 超时。5.2 用 Token 管理外部进程超时DISM 等命令行工具上位机和运维工具里还经常碰到一个场景调用外部命令行程序比如dism、netsh、ping、自定义的 C 工具然后用异步流读取它的输出。如果外部程序卡死怎么取消Process类本身没有接收 CancellationToken 的 API但你可以组合WaitForExitAsync配合WaitAsync.NET 5实现超时取消或者在取消回调里杀掉进程。using var cts new CancellationTokenSource(TimeSpan.FromSeconds(60)); using var proc new Process(); proc.StartInfo.FileName dism.exe; proc.StartInfo.Arguments ...; proc.Start(); try { await proc.WaitForExitAsync(cts.Token); } catch (OperationCanceledException) { // 超时取消直接杀掉 try { proc.Kill(true); } catch { } }WaitForExitAsync从 .NET 5 开始就有了接收 CancellationToken 的重载它会等待进程退出如果 token 取消就会抛 OCE。这时唯一能强制外部进程停下来的手段就是Kill。不过要注意Kill(true)会连子进程一起杀某些场景下可能副作用太大谨慎使用。这个模式的关键经验有两个第一不要把Kill写在取消回调里而是写在 catch 里因为回调在 Cancel 线程上执行如果这时候进程恰好已经退出回调里再调 Kill 就可能误杀其他 PID。第二如果外部进程有自己的取消参数比如 CtrlC 信号优先传递信号而不是强杀毕竟强制结束进程可能导致资源没清理干净。5.3 我自己维护的一个通用取消辅助类工作中我沉淀了一个小的辅助类用来处理“取消时必须清理资源”这件事。核心逻辑是统一封装“注册清理动作”和“取消时执行清理”并避免重复执行。public sealed class CancellationHelper : IDisposable { private readonly CancellationTokenSource _cts new(); private int _cleanupStarted; public CancellationToken Token _cts.Token; public IDisposable Register(Action cleanup) { return _cts.Token.Register(cleanup); } public void CancelAndCleanup() { if (Interlocked.Exchange(ref _cleanupStarted, 1) 0) { _cts.Cancel(); _cts.Dispose(); } } public void Dispose() CancelAndCleanup(); }Interlocked.Exchange保证 Cancel 和资源清理只发生一次。这在重复点击“停止”按钮、多线程同时触发取消的场景下特别稳。类似的辅助类在 WinForms 和后台服务里都能直接用。这个小类看似简单但它解决了一个实际问题取消动作的“幂等性”。.NET的 CTS 本身是幂等的Cancel()多次调用只会触发一次通知但资源清理动作如果写在回调里要留意重复注册和重复执行的问题。用一个这种包装类统一管理至少不会在千万次点击中偶发地重复关闭某个端口。写在最后取消是接口契约的一部分给方法签名加一个CancellationToken参数不需要花多少功夫但能不能把取消协议的每一层都打通才是真正拉开代码质量差距的地方。这个协议不是“你取消了我就抛个异常”而是“无论哪一层发起了取消任务、资源、UI 都必须在合理时间内做出正确响应”。在我参与过的上位机项目、后台调度服务和高并发通信库里凡是取消设计得好的系统自我诊断和线上维护都轻松很多。如果你的项目里还有一堆不支持取消的 Task、一堆 catch 块吞着 OCE真的建议花一两天时间把这些缺口补上——这个回报率相当可观。