你点了界面上的“取消”按钮,日志里那行进度却还在刷;你明明调用了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的底层实现我拆开看过,它不是用一个简单的List<Action>加锁,而是一个单向链表、每条注册记录用Interlocked.CompareExchange做无锁头插。这意味着注册回调这个动作非常快,高并发场景下不会因为取锁而堵塞。但它也带来了一个隐含问题:如果你频繁注册、取消注册回调,链表的节点回收不及时,内存压力会比想象中大。好在 .NET 运行时内部已经处理了大部分场景,多数开发者不需要关心这个细节,但知道它存在,对排查内存缓慢增长的问题是有用的。
Token 里还有一个非常关键的细节:CancellationToken在“未关联任何 CTS”时,CanBeCanceled返回 false,IsCancellationRequested永远返回 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 的异步框架在取消发生时有一套自己的异常约定:使用OperationCanceledException(OCE)代表取消。这看起来很简单,但牵扯到 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 Task<string> 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.IsCancellationRequested:
catch (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.WhenAll+WaitAsync管理一堆同时上行的网络请求,觉得“超时了我不等就是了”。结果每次超时都会有一两个 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也支持 Token:
await 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抛出来,整体流程就可以走入取消路径。但如果子任务里有人吞了取消异常或者压根不检查 token,WhenAll会永远等待,这个锅只能由编写子任务的人来背。所以要约定:异步方法内部收到取消信号后,应当尽快抛出 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。第二,如果外部进程有自己的取消参数(比如 Ctrl+C 信号),优先传递信号而不是强杀,毕竟强制结束进程可能导致资源没清理干净。
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,真的建议花一两天时间把这些缺口补上——这个回报率相当可观。