CancellationToken 协作式取消的正确姿势

很多人对「取消」有一个错误的直觉,以为调用 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 与注册的回调都要及时释放。
上一篇 依赖注入的生命周期陷阱:Scoped 在后台任务里为什么会崩