异步代码不加取消机制?犹如开车没有刹车

作者:微信公众号:【架构师老卢】
9-20 9:52
9

没有取消机制的异步代码,就像开车没有刹车。表面看似没问题,直到你发现应用仍在疯狂运行、消耗内存、执着地完成早已无人需要的工作。这不是健壮性,而是披着高效外衣的资源浪费。

如果你仍以“没有取消令牌也能运行”为由跳过它们,那你写的就不是健壮的异步代码——你写的是为所欲为的代码。现在可能没问题,但迟早会出事。

如果你曾点过毫无反应的取消按钮,你就会明白:这个话题值得掌握。

(第二部分现已发布)


什么是 CancellationToken?
本质上,CancellationToken 只是一个信号——一个轻量级对象,用于告知异步代码:“该停止了。”但令牌本身并不执行取消操作,它只负责接收信号。

发送信号的是 CancellationTokenSource。你创建源对象,从中获取令牌,并将令牌传递给任何需要支持取消的方法。

基本结构

using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
// 可以在其他地方取消令牌:
cts.Cancel();

可以这样理解:

  • CancellationTokenSource = 控制器
  • CancellationToken = 广播信号
  • 异步方法 = 监听器(收到信号后自愿停止)

如何在异步方法中传递和使用令牌
几乎所有规范的 .NET API 都接受可选 CancellationToken 参数,这意味着你的方法也应该如此。

定义带令牌参数的方法

public async Task DoWorkAsync(CancellationToken cancellationToken)
{
    for (int i = 0; i < 10; i++)
    {
        cancellationToken.ThrowIfCancellationRequested();
        Console.WriteLine($"Working... {i}");
        await Task.Delay(500, cancellationToken);
    }
}

在调用链中传递令牌

using var cts = new CancellationTokenSource();
Task task = DoWorkAsync(cts.Token);
// 2秒后取消
cts.CancelAfter(TimeSpan.FromSeconds(2));

注意:

  • ThrowIfCancellationRequested() 用于响应取消信号
  • Task.Delay(...) 也支持令牌,因此取消操作可以中断等待
  • 必须将令牌传递给每个可取消操作

以下是一个支持取消的简单异步方法:

public async Task<string> DownloadAsync(string url, CancellationToken cancellationToken)
{
    using var httpClient = new HttpClient();
    var response = await httpClient.GetAsync(url, cancellationToken);
    return await response.Content.ReadAsStringAsync();
}

传递令牌让被调用方有权选择退出。调用方式如下:

var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

try
{
    var content = await DownloadAsync("https://example.com", cts.Token);
    Console.WriteLine("Download succeeded!");
}
catch (OperationCanceledException)
{
    Console.WriteLine("Download cancelled.");
}

必须单独处理 OperationCanceledException,以区分操作失败和取消。


忽略取消机制带来的隐藏缺陷
问题在这里变得严重。忽略取消令牌不仅是错过优化,更是滋生难以复现缺陷的温床:

  1. 僵尸任务
    用户取消了操作,但任务仍在后台运行:写入内存、更新状态,甚至可能随后抛出错误。

  2. 应用关闭延迟
    不可取消的任务会阻塞优雅关闭,迫使你依赖 Environment.Exit(1) 而非干净的退出钩子。

  3. 内存泄漏
    不释放 CancellationTokenSource 或在循环中忽略取消,可能导致内存持有时间远超预期。

  4. 测试无限挂起
    依赖异步操作但无取消机制的测试没有退出策略,CI 流水线可能因小错误卡住 30 分钟。

忽略取消不仅是懒惰,更是危险。


为何应该使用取消令牌
因为你的代码不是孤立运行的。

异步操作发生在某些生命周期内:

  • 用户离开页面时
  • 后台服务关闭时
  • UI 发出停止加载数据的信号时

如果代码忽略取消:

  • 浪费计算资源
  • 迫使调用方使用 Hack 手段(超时、强制终止)
  • 导致测试脆弱性

如果你正在构建可复用 API,标准更高。使用者期望能传递取消令牌。如果方法签名不支持,会降低代码在实际系统中的可用性。


示例:协作式控制台应用

public static async Task Main()
{
    using var cts = new CancellationTokenSource();
    Console.CancelKeyPress += (s, e) =>
    {
        e.Cancel = true;
        cts.Cancel();
    };
    try
    {
        await DoWorkAsync(cts.Token);
        Console.WriteLine("Work completed successfully.");
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("Work was cancelled.");
    }
}

static async Task DoWorkAsync(CancellationToken token)
{
    for (int i = 0; i < 10; i++)
    {
        token.ThrowIfCancellationRequested();
        Console.WriteLine($"Working... {i}");
        await Task.Delay(1000, token);
    }
}

按 Ctrl+C 即可取消。这是真实场景的行为——应用按指令退出,而不是随心所欲。


忽略取消的代价
许多开发者将 CancellationToken 视为可选参数。他们将其塞入方法签名以满足分析器或库规范,却从不实际使用。这种心态会导致应用无响应、关闭缓慢、CPU 周期浪费,甚至内存泄漏。

理解取消机制不仅是编写防御性代码,更是编写专业代码:尊重用户时间、节约系统资源、在压力下保持可预测行为。

本文奠定了基础:令牌是什么、如何传递、为何重要,以及忘记使用会如何导致微妙而痛苦的缺陷。

相关留言评论
昵称:
邮箱:
阅读排行