很多人对「取消」有一个错误的直觉,以为调用 Cancel() 会立刻干掉正在执行的任务。
实际上它做的事情非常克制:把一枚共享的标志位翻成 true,然后叫醒所有关心它的人。
至于要不要停、什么时候停,完全由被取消的代码自己决定。理解这一点,后面的所有写法就都顺了。
一、取消的本质是一枚共享标志
CancellationTokenSource 是标志的持有者,CancellationToken 是它的只读视图。
你用 source 取消,把 token 传给别人观察,仅此而已:
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token; // 结构体,可自由复制传递
cts.Cancel(); // 标志变为已取消
Console.WriteLine(token.IsCancellationRequested); // True
这也解释了为什么取消对「同步阻塞」无效:如果方法体里是 Thread.Sleep 或者一段同步 I/O,
它压根不会去看那枚标志,也就无从响应。所以第一条实践原则是:
需要支持取消的路径,必须使用能接受 token 的异步 API。
二、让它一路传下去
取消是链式的:任何一环没有把 token 往下传,链路就在那里断掉了。参数约定很固定——
放在最后一个参数,并给出 default 默认值:
public async Task<IReadOnlyList<Order>> LoadOrdersAsync(
int tenantId,
CancellationToken cancellationToken = default)
{
var orders = await _db.Orders
.Where(o => o.TenantId == tenantId)
.ToListAsync(cancellationToken); // 往下传
return orders;
}
在 ASP.NET Core 里,只要方法签名的最后一个参数是 CancellationToken,
框架会自动绑定到 HttpContext.RequestAborted。
也就是说,用户关掉页面,你的查询就会自然停止——前提是你真的把它传到了最底层。
三、ThrowIfCancellationRequested 放在哪里
它应该出现在「可以安全停下来的位置」,而不是随便找一行插进去。经验位置有三个:
- 循环体开头——保证长循环不会跑完几十万次才响应。
- 进入耗时操作之前——避免在已经取消的情况下还发起网络请求或写库。
- 一批工作完成后、下一批开始前——让状态落在一致的位置上。
public async Task ProcessAsync(IEnumerable<Item> items, CancellationToken ct)
{
foreach (var item in items)
{
ct.ThrowIfCancellationRequested(); // 断点放在批次边界
await HandleAsync(item, ct);
await _db.SaveChangesAsync(ct);
}
}
抛出的 OperationCanceledException 不要吞掉。
catch (Exception) 会把取消也当成失败记进错误日志,导致真正的告警被噪声淹没:
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// 正常停机路径,安静退出
return;
}
catch (Exception ex)
{
_logger.LogError(ex, "处理失败");
}
四、超时和取消不是一回事
超时可以理解成「由计时器触发的取消」。用 CancelAfter 或者链接令牌组合出来,
但必须把两者区分开——用户主动取消不需要告警,超时通常需要:
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
try
{
await _client.GetAsync(url, linked.Token);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
// 是超时,不是用户取消
throw new TimeoutException($"请求超时(5s):{url}");
}
CreateLinkedTokenSource 的意义在于「任一来源触发即取消」:
外部要求停或者本地超时到点,都能让这个操作退出,而 catch 里的 when 条件负责判断到底是哪一种。
注意判断顺序——先判断更具体的 timeoutCts,再处理外部取消。
CancellationTokenSource实现了IDisposable。凡是用new出来的 source (尤其是带计时器的),都应当using掉,否则它的计时器和回调注册会一直挂着, 在高频路径上就是稳定的内存泄漏。
五、两个容易漏掉的细节
注册的回调要注销
给别人的 token 挂回调时,如果不注销,回调会活到 token 被取消或 source 被释放为止。 长期存活的 token 上反复注册,是隐藏很深的一类泄漏:
using var registration = token.Register(() => connection.Abort());
await connection.RunAsync(); // 提前结束时 registration 随 using 释放
取消之后要不要清理
取消点很可能落在「一半已提交、一半未提交」的中间状态。所以取消检查最好放在事务边界或批次边界,
配合幂等设计,让重跑一次是安全的。CancellationToken 不会替你保证一致性,它只负责让你有机会停下。
小结
- 取消是共享标志 + 协作响应,不会强行终止任何执行。
- token 作为最后一个参数一路传到底,断在任何一层都等于没做取消。
- 检查点放在循环开头、耗时操作前、批次边界,抛出的异常不要被泛化 catch 吞掉。
- 超时用链接令牌实现,并用
when (timeoutCts.IsCancellationRequested)区分语义。 - 创建的 source 与注册的回调都要及时释放。