欢迎光临
我们一直在努力

CancellationToken 完整指南:为什么你的异步任务停不下来?

前言

在 C# 异步编程中,很多开发者已经能熟练使用:

  • async / await
  • Task.Run
  • Invoke
  • ConfigureAwait

但一进入实际项目,很快就会撞上一堵看不见的墙:

任务启动了
↓
用户点了取消
↓
任务……毫无反应

比如这些场景:

  • 文件下载
  • 数据同步
  • 大数据计算
  • 后台轮询
  • HTTP 请求

没有取消机制,任务就像被放出笼子的哈士奇,不跑尽兴根本不会回来。而 CancellationToken,正是 .NET 提供的那根“优雅的牵引绳”。

本文从 WinForms 实战出发,帮你彻底搞懂:

  • CancellationToken 到底是什么玩意儿
  • 为什么你的异步任务停不下来
  • 如何正确取消任务
  • 取消异常怎么处理才不翻车
  • WinForms 里有哪些必须避开的坑

第一章:为什么需要 CancellationToken

先看一个真实到令人心疼的场景。

用户按下“开始”按钮:

private async void btnStart_Click(object sender, EventArgs e)
{
await LongRunningTask();
}

任务可能要跑几十秒。

就在这时,用户突然发现自己选错了参数:

开始
↓
等待中……
↓
哎呀输错了!
↓
疯狂点“取消”按钮……

怎么办?很多新手会本能地想到:

thread.Abort(); // 远古时代的遗产

或者:

task.Dispose(); // 你以为在清理资源?

但这些都不是正确答案。.NET 的设计哲学非常明确:永远不要从外部暴力杀死一个线程,因为那会导致资源泄漏、锁不释放,甚至整个 CLR 原地升天。

正确的思路其实很温和:不要强行掐死任务,而是走过去轻轻拍拍它说:“如果可以的话,请你尽快结束。”

这就是 CancellationToken 的核心思想——协作式取消。任务主动配合检查取消信号,然后体面地退出,而不是被一棍子打晕。


第二章:CancellationToken 到底是什么

先看两行最基础的代码:

var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

很多人第一眼就懵了:Source 是个啥?Token 又是个啥?我该先看哪个?

其实这个设计非常像我们日常生活中的遥控器:

CancellationTokenSource → 遥控器(控制端)
CancellationToken → 电视机上的接收器(只读端)

  • 你想取消,就拿起遥控器按“关机”:cts.Cancel();
  • 任务想知道自己要不要停,就看一眼电视机上的指示灯:token.IsCancellationRequested

所以 Source 是“发出信号”的那一方,Token 是“接收信号”的那一方。一个任务只能拿到 Token,根本没法自己取消自己;而拥有 Source 的那一端(比如 UI 层)则可以随时下达“撤退”命令。职责分明,线程安全,这种设计实现了控制端与观察端的分离。

拥有 CancellationTokenSource 的代码负责发出取消请求,而持有 CancellationToken 的代码负责观察取消状态,两者各司其职,互不干扰。


第三章:最基础的取消实现

假设我们有一个长时间运行的任务:

for (int i = 0; i < 100; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(1000);
}

当用户按下“取消”按钮,代码里只需执行:

cts.Cancel();

整个流程就会变成:

用户点击取消
↓
CancellationTokenSource.Cancel()
↓
Token 内部标记被设置
↓
ThrowIfCancellationRequested() 检测到信号
↓
抛出 OperationCanceledException
↓
任务停止(状态变为 Canceled)

这里有一个非常重要的细节:ThrowIfCancellationRequested() 抛出的 OperationCanceledException 会自动携带当前的 Token。正是因为带上了正确的 Token,运行时才知道“这是一次优雅的取消,不是程序炸了”,从而把任务的状态标为 Canceled,而不是 Faulted。

如果你自己手动构造 OperationCanceledException,却没有使用当前任务关联的 CancellationToken,那么运行时未必能够准确识别这是一场“正常取消”。虽然某些情况下任务仍可能被标记为 Canceled,但这种行为依赖于具体执行环境以及任务与 Token 的关联关系,并不可靠。

相比之下,ThrowIfCancellationRequested() 能保证抛出的异常携带正确的 Token,因此是更推荐的做法。


第四章:IsCancellationRequested 与 ThrowIfCancellationRequested

这两个 API 就像兄弟俩,长得像,性格完全不同。很多文章只告诉你它们分别怎么用,但不解释背后的影响。

方法一:静默退出

if (token.IsCancellationRequested)
{
// 做一些清理工作
return; // 或者 break
}

  • 特点:不抛异常,安安静静地收工。
  • 结果:Task 的状态会变成 RanToCompletion(正常完成)。
  • 适用场景:你的代码本身就能以 return 的方式完美结束,不需要抛出异常去中断上层。

方法二:抛出取消异常

token.ThrowIfCancellationRequested();

  • 特点:自动抛出 OperationCanceledException,并携带 token。
  • 结果:Task 状态变成 Canceled。
  • 适用场景:你需要让调用方明确知道“这次操作是因为被取消而结束的”,比如要更新 UI 状态、记录日志,或区分取消和真正的成功。

关键的区别就在 Task 的最终状态上:

return → RanToCompletion
Throw → Canceled

另外有一个前置知识必须在这里点破:并不是只要抛出 OperationCanceledException 就万事大吉。

运行时需要根据异常中携带的信息判断这是不是一次正常取消。如果异常中携带的 Token 与当前任务关联的 Token 不一致,运行时可能无法将其识别为一次正常取消,而会被当作普通异常处理。

因此推荐使用 ThrowIfCancellationRequested(),避免 Token 不一致的问题。


第五章:哪些 API 天生支持 CancellationToken

很多开发者写了好几年 C#,都不知道自己最喜欢用的那些异步方法其实早就内置了取消支持。比如:

  • Task.Delay()
  • HttpClient.GetAsync()
  • Stream.ReadAsync()
  • Stream.WriteAsync()
  • SemaphoreSlim.WaitAsync()

用起来非常简单,直接把 token 传进去就行:

await Task.Delay(TimeSpan.FromSeconds(30), token);

一旦 token 被取消,Task.Delay 会立刻抛出一个 OperationCanceledException 并停止等待,而不是继续傻等 30 秒。这对于那些“外部 API 迟迟不响应”的场景简直是救命稻草。

大概就像:不带 token 的 Task.Delay 就像一个没有闹钟的午睡,除非自然醒,否则谁说都不好使。


第六章:WinForms 中最常见的取消场景

在 WinForms 里,取消几乎无处不在,但很多人只会依葫芦画瓢,不知道背后的原理。

场景一:取消长时间计算

开始按钮:

private CancellationTokenSource? _cts;

private async void btnStart_Click(object sender, EventArgs e)
{
_cts = new CancellationTokenSource();
var token = _cts.Token;

try
{
await Task.Run(() => Calculate(token), token);
}
catch (OperationCanceledException)
{
// 用户取消了,正常退出
}
}

private void Calculate(CancellationToken token)
{
for (int i = 0; i < 100000; i++)
{
token.ThrowIfCancellationRequested();
// 模拟耗时计算
}
}

取消按钮:

private void btnCancel_Click(object sender, EventArgs e)
{
_cts?.Cancel();
}

重点解释:Task.Run 第二个参数(那个 token)的作用非常有限——它只能在任务还没被线程池调度执行时提前取消掉,避免任务启动。一旦 Calculate 开始运行,能救你命的就只有委托内部传入的那个 token。

很多开发者产生过一个美丽的误解:以为只要把 token 传给 Task.Run,然后调用 cts.Cancel(),任务就会自动停掉。真相是,取消标记从来不会主动中断任何代码,你必须在循环里自己检查。不理解这一点,你的取消就永远是“狼来了”。

场景二:取消文件导出

private async void btnExport_Click(object sender, EventArgs e)
{
_cts = new CancellationTokenSource();
try
{
await ExportExcelAsync(_cts.Token);
}
catch (OperationCanceledException)
{
MessageBox.Show("导出已取消");
}
}

当用户关闭窗口时,可以在 FormClosing 事件中调用 _cts.Cancel(),这样就能避免窗口已经关了但后台还在傻傻写文件。

场景三:取消后台轮询

WinForms 里最常见的轮询模式:

while (!token.IsCancellationRequested)
{
await Task.Delay(1000, token);
// 检查某项状态,比如服务器是否还活着
await CheckStatusAsync();
}

因为 Task.Delay 也接受了 token,所以取消时会立即从 await 中醒来并抛出异常,轮询循环随之终止,非常干净。


第七章:CancelAfter 的妙用

CancelAfter 是一个你用了就回不去的功能。它可以在指定时间后自动触发取消,就像给任务上了一个倒计时闹钟。

cts.CancelAfter(TimeSpan.FromSeconds(10));

等价于:10 秒之后自动执行 Cancel()。

典型场景:

  • 网络请求超时:不想无限等下去,超过 10 秒直接取消。
  • 后台任务自动清理:超过预期时间后发送取消请求,并记录日志。
  • 第三方接口调用:对面服务可能无限挂起,你得自己兜底。

你可以把它和普通的 Cancel() 混合使用,比如用户手动取消就立即生效,否则 30 秒后自动超时,非常灵活。


第八章:LinkedTokenSource —— 多路取消的聚合器

当一个任务需要同时响应多个取消源时(比如“用户手动取消”和“超时自动取消”,任一发生都要停止),就需要请出 LinkedTokenSource。

var userCts = new CancellationTokenSource();
var timeoutCts = new CancellationTokenSource();
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));

// 创建一个组合的 Token
using (var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
userCts.Token, timeoutCts.Token))
{
await DoWorkAsync(linkedCts.Token);
}

这样一来:

用户点击取消 ──→ userCts 取消
↘
OR → linkedCts 也被取消 → 任务停止
↗
超时到达 ──→ timeoutCts 取消

任何一个源取消,linkedCts 都会跟着取消。这个特性在复杂交互的 WinForms 应用里非常实用,比如“用户关闭窗口,或者超过 60 秒没反应,全部视为终止”。


第九章:WinForms 中的几个常见坑

好了,下面是血泪经验集合,建议反复阅读。
坑一:创建了 Token 却从未检查

var token = cts.Token;
while (true)
{
Work(); // 永远不看 token
}

这相当于你给员工发了通知:“紧急任务取消”,然后你把通知私密了,员工看不到这份通知,当然会继续吭哧吭哧干活。

坑二:重复使用已取消的 CTS

cts.Cancel();
await StartAgain(); // 由于 Token 已经永久处于取消状态,新启动的任务通常会立即结束,或者在第一次检查 Token 时抛出 OperationCanceledException。

一旦 CancellationTokenSource 被取消,它返回的 Token 就永久处于取消状态。想要重新开始,必须创建新的实例:

cts = new CancellationTokenSource();

坑三:忘记 Dispose

CancellationTokenSource 内部分配了定时器资源(尤其是用了 CancelAfter 的情况下),用完必须 Dispose(),否则可能造成句柄泄漏。推荐使用 using 或在窗体关闭时统一清理。

坑四:一把 catch(Exception) 吞掉取消异常

catch (Exception)
{
// 假装什么都没发生
}

这样会导致你永远无法区分“任务是出错了”还是“任务被用户取消了”。正确的做法是将取消异常单独处理:

catch (OperationCanceledException)
{
// 正常的取消行为,不需要大惊小怪
}
catch (Exception ex)
{
// 真正的错误,该记录就记录
}

坑五:async void 事件里不处理取消异常

WinForms 中的按钮点击、窗体加载等事件都是 async void。如果在里面抛出的 OperationCanceledException 一路冒泡到顶层,在默认 WinForms 配置下,这可能导致程序弹出异常对话框,甚至直接终止进程。async void 无法像 Task 一样被调用方捕获。用户点一下取消按钮,程序直接闪退——这绝对是要挨打的节奏,因此务必在事件处理器内部自行处理异常。

所以请务必在 async void 的最外层套上 try-catch:

private async void btnStart_Click(object sender, EventArgs e)
{
try
{
await DoWorkAsync(token);
}
catch (OperationCanceledException)
{
// 取消是正常操作,无需额外处理
}
catch (Exception ex)
{
// 记录或提示用户
}
}

坑六:把 CancellationToken 当成线程终止器

这是最大的误解。很多人以为:

cts.Cancel();

可以立刻杀死正在执行任务的线程
但真正的流程是:
设置一个“请求取消”的标记
任务代码在合适的时机检查这个标记
如果发现已取消,就自己乖乖退出
例如下面这段代码,即使调用了 Cancel(),也永远不会停止:

while (true)
{
DoWork();
}
// cts.Cancel() 后依然欢快地跑着

修复方法就是在循环体内显式检查:

while (!token.IsCancellationRequested)
{
DoWork();
}

或者使用 ThrowIfCancellationRequested()。

这就是 .NET 一直强调的协作式取消(Cooperative Cancellation)——取消不是命令,是商量。任务的生死大权,终究掌握在任务自己手里。


第十章:一张总结表

把所有的场景和推荐做法浓缩在一起,方便你随时查阅:

场景推荐方案
用户主动取消 Cancel()
自动超时 CancelAfter()
多个取消来源 LinkedTokenSource
循环任务中检查 IsCancellationRequested
需要任务状态变为取消 ThrowIfCancellationRequested
HTTP 请求 直接传入 token
Task.Delay 直接传入 token
窗体关闭时取消 在 FormClosing 中调用 Cancel

核心结论

CancellationToken 的本质从来不是:强制终止任务
而是:向任务发送一个“如果可以,请停止”的请求

任务是否真的停下来,完全取决于你的代码有没有主动、频繁地去检查这个标记。换句话说:取消机制是写给任务的,而不是写给调度器的。

如果你学完本文只记住一句话,那就记住这句:

启动任务容易,让任务优雅地停下来,才是真正的异步编程。

下次你的用户点取消再也没反应时,你就知道该去哪儿找 bug 了。

赞(0)
未经允许不得转载:171主机测评 » CancellationToken 完整指南:为什么你的异步任务停不下来?
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址