C#使用Cancellation来取消异步任务

一、什么是 CancellationToken?

1.1 为什么要学习 CancellationToken?

在开发应用程序时,我们经常会遇到一些需要长时间运行的操作,比如:

  • 从网络下载大文件

  • 处理大量数据

  • 在运动控制系统中,电机从当前位置运动到目标位置

  • 执行复杂的计算任务

这些操作可能需要几秒、几分钟甚至更长时间才能完成。但是如果用户想中途取消这个操作,或者发生了紧急情况需要立即停止,我们应该怎么做呢?

在传统的编程方式中,如果我们想停止一个正在运行的线程,可能会使用 Thread.Abort() 这样的方法。但这种方式非常危险,因为它会强制终止线程,可能会导致:

  • 数据不一致(线程正在修改数据时被突然打断)

  • 资源泄漏(文件、数据库连接等没有正确关闭)

  • 程序状态不可预测

为了安全地取消操作,.NET 提供了 CancellationToken 机制。这是一种协作式的取消方式,不是强制终止,而是通知操作"我想取消你,请你自己安全地停止"。

1.2 CancellationToken 的工作原理

想象一下这样的场景:

传统方式(不推荐):

复制代码
老板 → 员工正在工作 → 突然拔掉电源(暴力终止)
结果:员工可能正在处理重要文件,直接断电导致数据损坏

CancellationToken 方式(推荐):

复制代码
老板 → 员工正在工作 → 老板说"请停止工作"
员工 → 检查到停止信号 → 保存当前工作 → 清理现场 → 安全停止
结果:工作有序停止,没有数据损坏

在 CancellationToken 机制中:

  • CancellationTokenSource(令牌源):相当于"老板",负责发起取消请求

  • CancellationToken(令牌):相当于"通知",传递给执行任务的代码,告诉它是否应该停止

  • 执行任务的代码:相当于"员工",定期检查令牌状态,如果收到取消信号就安全地停止

1.3 核心概念详解

CancellationTokenSource(令牌源)
  • 作用:这是取消操作的源头,只有通过它才能发起取消请求

  • 特点 :可以主动调用 Cancel() 方法来取消操作

  • 生命周期:使用后应该及时释放资源(实现了 IDisposable 接口)

  • 比喻:就像一个广播站,只有它能发送"取消"的广播信号

CancellationToken(令牌)
  • 作用:被传递给异步方法,用于检查是否收到取消信号

  • 特点:本身不能主动取消,只能被动接收取消状态

  • 不可变性:一旦创建就不能修改,这是线程安全的

  • 比喻:就像一个收音机,只能接收信号,不能发送信号

CancellationTokenRegistration(取消注册)
  • 作用:用于注册取消时的回调函数

  • 特点:当取消发生时,会自动执行注册的回调

  • 比喻:就像设置了闹钟,当"取消"信号到来时自动执行某些操作

1.4 CancellationToken 的使用场景

在运动控制卡编程中,CancellationToken 尤其重要:

  1. 紧急停止:操作员按下急停按钮,需要立即停止所有电机运动

  2. 用户中断:用户点击"取消"按钮,停止当前的运动任务

  3. 超时保护:运动超过预设时间仍未到达目标位置,自动停止

  4. 多轴同步停止:多轴联动时,一个轴异常需要停止所有轴

  5. 资源清理:取消运动时,需要关闭相关硬件设备,释放资源

没有 CancellationToken,我们很难实现这些功能,或者实现的代码会很复杂且不安全。

二、基本使用

2.1 创建和使用 CancellationToken

让我们通过一个完整的例子来理解如何使用 CancellationToken。这个例子模拟了一个需要 10 秒才能完成的工作,但我们在 5 秒后就想取消它。

cs 复制代码
CancellationTokenSource cts = new CancellationTokenSource();

CancellationToken token = cts.Token;

async Task DoWorkAsync(CancellationToken ct)
{
    for(int i=0;i<10;i++)
    {
        ct.ThrowIfCancellationRequested();//检查请求是否取消
        Console.WriteLine($"working...{i+1}/10");
        await Task.Delay(1000,ct);
    }
    Console.WriteLine("work end!");
}

var task = DoWorkAsync(token);

await Task.Delay(5000);
cts.Cancel();

try
{
    await task;
}
catch(OperationCanceledException)
{
    Console.WriteLine("wrok stop");
}

关键要点:

  • ThrowIfCancellationRequested() 是主动检查,如果不调用它,任务可能不会及时响应取消

  • 传递 token 给 Task.Delay() 可以在等待期间响应取消

  • 取消通过抛出异常来实现,这是 .NET 的标准做法

  • OperationCanceledException 是预期的异常,不是程序错误

2.2 CancellationTokenSource 的常见方法

CancellationTokenSource 提供了多个有用的方法,让我们来看看它们的作用和使用场景。

cs 复制代码
// 创建一个 CancellationTokenSource
CancellationTokenSource cts = new CancellationTokenSource();

// 方法1:立即取消
// 这是最常用的方法,立即发出取消信号
// 通常在用户点击"取消"按钮或发生错误时使用
cts.Cancel();
Console.WriteLine("已发出取消信号");

// 方法2:延迟后自动取消
// 方式A:使用 TimeSpan,更易读
cts.CancelAfter(TimeSpan.FromSeconds(5));
// 5秒后会自动发出取消信号,无需手动调用 Cancel()

// 方式B:使用毫秒数,更精确
cts.CancelAfter(5000);
// 5000毫秒(5秒)后自动取消

// 应用场景:防止任务长时间卡死
// 例如:网络请求最多等待10秒,超时自动取消
// 例如:电机运动最多等待30秒到达目标位置,超时自动停止

// 方法3:创建关联多个 token 的新 token
// 这是高级用法,允许同时响应多个取消源
var cts1 = new CancellationTokenSource();
var cts2 = new CancellationTokenSource();

// 创建一个关联的 token
// 只要 cts1 或 cts2 中任一被取消,linkedCts.Token 都会收到取消信号
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
    cts1.Token, cts2.Token);

// 应用场景:
// 1. 全局取消 + 用户取消
//    cts1.Token:用户点击取消按钮
//    cts2.Token:系统关闭时自动取消
//    linkedCts.Token:同时响应这两种取消

// 2. 运动控制场景
//    cts1.Token:用户点击"停止"按钮
//    cts2.Token:限位开关触发(硬件信号)
//    linkedCts.Token:任一情况发生都停止运动

// 方法4:释放资源
// CancellationTokenSource 实现了 IDisposable 接口
// 使用完毕后应该调用 Dispose() 释放资源
cts.Dispose();

// 推荐做法:使用 using 语句自动释放
using var ctsAuto = new CancellationTokenSource();
// 使用 ctsAuto
// 离开作用域时自动调用 Dispose()

详细说明 CancelAfter 的使用:

cs 复制代码
CancellationTokenSource cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(3));
var token = cts.Token;

try
{
    await Task.Delay(5000,token);
    Console.WriteLine("Work end");
}
catch(OperationCanceledException)
{
    if(token.IsCancellationRequested)
        Console.WriteLine("work stop");
    else
        Console.WriteLine("work cancel");
}

详细说明关联 Token 的使用:

cs 复制代码
// 场景:运动控制系统,需要同时响应多个停止信号
class MotionSystem
{
    private CancellationTokenSource _userCancelSource; // 用户取消
    private CancellationTokenSource _emergencyStopSource; // 急停信号
    private CancellationTokenSource _linkedSource; // 关联的源

    public void Start()
    {
        _userCancelSource = new CancellationTokenSource();
        _emergencyStopSource = new CancellationTokenSource();

        // 创建关联的 token,任一信号都触发取消
        _linkedSource = CancellationTokenSource.CreateLinkedTokenSource(
            _userCancelSource.Token,
            _emergencyStopSource.Token
        );

        // 使用关联的 token 启动运动
        StartMotionAsync(_linkedSource.Token);
    }

    // 用户点击"停止"按钮
    public void UserStop()
    {
        _userCancelSource?.Cancel();
        Console.WriteLine("用户请求停止");
    }

    // 硬件急停信号触发
    public void EmergencyStop()
    {
        _emergencyStopSource?.Cancel();
        Console.WriteLine("急停信号触发!");
    }
}

三、在异步方法中使用 CancellationToken

3.1 异步方法接收 CancellationToken

在 .NET 的异步编程最佳实践中,所有可能长时间运行的异步方法都应该接受 CancellationToken 参数。这是一个约定,也是编写健壮代码的关键。

cs 复制代码
// 好的做法:所有异步方法都应接受 CancellationToken
// 参数命名约定:通常使用 ct 或 cancellationToken
// 参数位置约定:通常是最后一个参数
// 默认值:使用 default(CancellationToken),允许调用方选择不传递
async Task DownloadFileAsync(string url, CancellationToken ct = default)
{
    try
    {
        // 在耗时操作中传递 token
        // 这样可以让操作本身支持取消
        using var httpClient = new HttpClient();

        // GetAsync 方法支持 CancellationToken
        // 如果在等待响应时收到取消信号,会立即取消请求
        var response = await httpClient.GetAsync(url, ct);

        // ReadAsStringAsync 也支持 CancellationToken
        // 如果在读取内容时收到取消信号,会立即停止读取
        var content = await response.Content.ReadAsStringAsync(ct);

        Console.WriteLine($"下载完成,内容长度: {content.Length}");
    }
    catch (OperationCanceledException)
    {
        // 捕获取消异常,这是正常的取消流程
        // 不应该记录为错误
        Console.WriteLine("下载已取消");
    }
    catch (HttpRequestException ex)
    {
        // 捕获网络错误,这是真正的错误
        Console.WriteLine($"网络错误: {ex.Message}");
    }
}

为什么要在参数中传递 CancellationToken?

  1. 允许调用方控制取消:调用方可以决定是否允许取消,以及何时取消

  2. 遵循.NET惯例:这是.NET异步编程的标准做法

  3. 支持取消传播:允许取消信号从上层方法传递到下层方法

  4. 提高响应性:让整个调用链都能快速响应取消请求

3.2 检查取消状态的两种方式

在异步方法中,有两种方式来检查是否应该取消操作。理解它们的区别很重要,因为它们适用于不同的场景。

方式1:ThrowIfCancellationRequested() - 抛出异常
cs 复制代码
async Task ProcessDataAsync(CancellationToken ct)
{
    for (int i = 0; i < 1000; i++)
    {
        // 方式1:检查并抛出异常
        // 如果已请求取消,抛出 OperationCanceledException
        // 优点:代码简洁,自动停止执行
        // 缺点:需要使用 try-catch 处理异常
        ct.ThrowIfCancellationRequested();

        // 执行一些工作
        await DoSomeWorkAsync(i, ct);
    }
}

工作原理:

  1. 调用 ThrowIfCancellationRequested() 检查 token 的状态

  2. 如果 IsCancellationRequestedtrue,抛出 OperationCanceledException

  3. 抛出异常后,后续代码不会执行,方法立即退出

  4. 异常会向上传播,直到被某个 try-catch 捕获

适用场景:

  • 想要立即停止操作,不需要做任何清理工作

  • 希望取消信号以异常的形式向上传播

  • 方法调用链中已经有人处理 OperationCanceledException

方式2:IsCancellationRequested - 自行处理
cs 复制代码
async Task ProcessDataAsync(CancellationToken ct)
{
    List<int> processedData = new List<int>();

    for (int i = 0; i < 1000; i++)
    {
        // 方式2:检查后自行处理
        // 只检查状态,不抛出异常
        // 优点:可以执行清理逻辑,控制退出方式
        // 缺点:需要自己编写退出逻辑
        if (ct.IsCancellationRequested)
        {
            Console.WriteLine("检测到取消请求,开始清理资源...");

            // 可以在这里执行清理工作
            // 例如:保存已处理的数据、关闭连接、释放资源等
            await SavePartialResultsAsync(processedData);

            // 优雅地退出方法
            return; // 正常返回,不抛出异常
        }

        // 执行工作
        int result = await DoSomeWorkAsync(i, ct);
        processedData.Add(result);
    }
}

工作原理:

  1. 检查 IsCancellationRequested 属性

  2. 如果为 true,执行自定义的清理逻辑

  3. 使用 return 正常退出方法,不抛出异常

  4. 调用方不知道方法是被取消的,只认为方法正常完成

适用场景:

  • 需要在取消时执行特定的清理工作

  • 希望部分完成的结果能够被保存

  • 不希望以异常的形式通知调用方

  • 需要记录取消事件

两种方式的对比
特性 ThrowIfCancellationRequested() IsCancellationRequested
是否抛出异常
代码简洁度 中等
能否执行清理逻辑 只能在 finally 块中 可以在检测后立即执行
调用方是否知道取消 是(通过异常) 否(正常返回)
推荐使用场景 大多数情况 需要特殊清理逻辑时
实际应用示例

让我们通过一个实际的运动控制例子来理解两种方式的区别:

cs 复制代码
// 场景1:简单的数据处理,不需要清理
async Task SimpleProcessAsync(CancellationToken ct)
{
    for (int i = 0; i < 1000; i++)
    {
        ct.ThrowIfCancellationRequested(); // ✅ 使用 ThrowIfCancellationRequested
        await ProcessItemAsync(i, ct);
    }
}

// 场景2:需要保存中间结果
async Task ProcessWithSavingAsync(CancellationToken ct)
{
    var results = new List<int>();

    for (int i = 0; i < 1000; i++)
    {
        if (ct.IsCancellationRequested) // ✅ 使用 IsCancellationRequested
        {
            await SavePartialResultsAsync(results); // 保存已完成的部分
            return;
        }

        int result = await ProcessItemAsync(i, ct);
        results.Add(result);
    }
}

// 场景3:需要通知调用方取消状态
async Task ProcessWithNotificationAsync(CancellationToken ct)
{
    try
    {
        for (int i = 0; i < 1000; i++)
        {
            ct.ThrowIfCancellationRequested(); // ✅ 使用 ThrowIfCancellationRequested
            await ProcessItemAsync(i, ct);
        }
    }
    catch (OperationCanceledException)
    {
        // 重新抛出,让调用方知道操作被取消
        throw; // ✅ 抛出异常通知调用方
    }
}

// 场景4:需要同时处理取消和异常
async Task ProcessWithBothAsync(CancellationToken ct)
{
    var results = new List<int>();

    try
    {
        for (int i = 0; i < 1000; i++)
        {
            if (ct.IsCancellationRequested) // ✅ 使用 IsCancellationRequested
            {
                await CleanupAsync(results);
                throw new OperationCanceledException(ct); // 手动抛出异常
            }

            int result = await ProcessItemAsync(i, ct);
            results.Add(result);
        }
    }
    catch (Exception ex) when (ex is not OperationCanceledException)
    {
        // 处理其他异常
        await HandleErrorAsync(ex);
        throw;
    }
}

3.3 在 WinForms 中使用

在 Windows 窗体应用程序中使用 CancellationToken 是非常常见的场景。这通常涉及用户界面(UI)和后台任务的交互。让我们通过一个完整的例子来学习如何正确实现。

cs 复制代码
public partial class MainForm : Form
{
    // 字段:存储 CancellationTokenSource 和当前任务
    private CancellationTokenSource _cts;
    private Task _currentTask;

    public MainForm()
    {
        InitializeComponent();
        // 初始化时,取消按钮应该禁用
        btnCancel.Enabled = false;
    }

    // 开始按钮点击事件
    private async void btnStart_Click(object sender, EventArgs e)
    {
        // 步骤1:创建新的 CancellationTokenSource
        // 每次开始新任务时,都应该创建一个新的实例
        _cts = new CancellationTokenSource();

        // 步骤2:更新 UI 状态
        // 禁用开始按钮,启用取消按钮,防止重复点击
        btnStart.Enabled = false;
        btnCancel.Enabled = true;
        progressBar1.Value = 0;
        lblStatus.Text = "准备开始...";

        try
        {
            // 步骤3:启动长时间运行的操作
            // 将 token 传递给方法,使其支持取消
            _currentTask = LongRunningOperation(_cts.Token);

            // 步骤4:等待操作完成
            // 使用 await 关键字,不会阻塞 UI 线程
            await _currentTask;

            // 步骤5:操作正常完成
            lblStatus.Text = "操作完成!";
            MessageBox.Show("操作完成!", "成功", MessageBoxButtons.OK, MessageBoxIcon.Information);
        }
        catch (OperationCanceledException)
        {
            // 步骤6:操作被取消
            // 这是正常情况,不是错误
            lblStatus.Text = "操作已取消";
            MessageBox.Show("操作已取消", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information);
        }
        catch (Exception ex)
        {
            // 步骤7:处理其他异常
            lblStatus.Text = "发生错误";
            MessageBox.Show($"发生错误:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
        }
        finally
        {
            // 步骤8:清理资源,恢复 UI 状态
            // finally 块中的代码总是会执行,无论是否发生异常
            btnStart.Enabled = true;
            btnCancel.Enabled = false;

            // 释放 CancellationTokenSource
            _cts?.Dispose();
            _cts = null;
            _currentTask = null;
        }
    }

    // 取消按钮点击事件
    private void btnCancel_Click(object sender, EventArgs e)
    {
        // 步骤1:发出取消信号
        // 调用 Cancel() 会通知所有持有该 token 的任务
        _cts?.Cancel();

        // 步骤2:更新 UI
        // 禁用取消按钮,防止重复点击
        btnCancel.Enabled = false;
        lblStatus.Text = "正在取消...";
    }

    // 长时间运行的操作
    // 这个方法会在后台线程执行,不会阻塞 UI
    private async Task LongRunningOperation(CancellationToken ct)
    {
        // 模拟处理 100 个项目
        for (int i = 0; i < 100; i++)
        {
            // 步骤1:检查是否请求取消
            // 这是在后台线程执行的,不会阻塞 UI
            ct.ThrowIfCancellationRequested();

            // 步骤2:执行一些工作
            // 这里用 Task.Delay 模拟耗时操作
            await Task.Delay(100, ct);

            // 步骤3:更新 UI(重要!)
            // 由于这个方法在后台线程运行,不能直接访问 UI 控件
            // 必须使用 Invoke 或 BeginInvoke 切换到 UI 线程
            this.Invoke((MethodInvoker)delegate
            {
                // 更新进度条
                progressBar1.Value = i + 1;

                // 更新状态标签
                lblStatus.Text = $"处理中... {i + 1}/100";

                // 可以添加其他 UI 更新
                lblProgress.Text = $"{i + 1}%";
            });
        }
    }
}

重要概念解释:

1. 为什么需要字段变量?
cs 复制代码
// 使用字段而不是局部变量的原因:
private CancellationTokenSource _cts; // ✅ 字段,可以在多个方法中访问

// ❌ 如果使用局部变量,其他方法无法访问
private async void btnStart_Click(object sender, EventArgs e)
{
    var cts = new CancellationTokenSource(); // 局部变量
    await LongRunningOperation(cts.Token);
}

// ❌ btnCancel_Click 无法访问上面的 cts
private void btnCancel_Click(object sender, EventArgs e)
{
    // cts 在这里不可见!
    // cts?.Cancel(); // 编译错误
}
2. 为什么每次都要创建新的 CancellationTokenSource?
cs 复制代码
// ❌ 错误:重复使用同一个实例
private CancellationTokenSource _cts = new CancellationTokenSource();

private async void btnStart_Click(object sender, EventArgs e)
{
    // 如果用户点击两次"开始",会出现问题
    // 第一次点击已经开始,第二次点击又创建任务
    // 当第二次点击后点击"取消",两次任务都会被取消
    _cts.Cancel(); // 这会影响所有使用过这个 token 的任务
}

// ✅ 正确:每次开始新任务时创建新实例
private async void btnStart_Click(object sender, EventArgs e)
{
    // 每次点击"开始",都创建一个新的 CancellationTokenSource
    // 这样不同的任务之间互不影响
    _cts = new CancellationTokenSource();
}
3. 为什么要使用 Invoke 更新 UI?

在 WinForms 中,有一条重要规则:只有创建控件的线程(UI 线程)才能访问该控件

cs 复制代码
private async Task LongRunningOperation(CancellationToken ct)
{
    for (int i = 0; i < 100; i++)
    {
        ct.ThrowIfCancellationRequested();
        await Task.Delay(100, ct);

        // ❌ 错误:直接访问 UI 控件
        // 这个方法在后台线程运行,不能直接访问 UI 控件
        progressBar1.Value = i + 1; // 会抛出 InvalidOperationException

        // ✅ 正确:使用 Invoke 切换到 UI 线程
        this.Invoke((MethodInvoker)delegate
        {
            // 这段代码会在 UI 线程执行
            progressBar1.Value = i + 1;
            lblStatus.Text = $"处理中... {i + 1}/100";
        });
    }
}

Invoke 的工作原理:

  • Invoke 会将代码排队到 UI 线程的消息队列中

  • UI 线程在处理消息时执行这段代码

  • Invoke 是同步的,会等待 UI 线程执行完成

  • 还可以使用 BeginInvoke,它是异步的,不会等待

四、高级用法

4.1 注册取消回调

有时候我们需要在取消发生时执行一些特定的操作,比如清理资源、关闭设备连接、记录日志等。CancellationToken 提供了 Register 方法,允许我们注册回调函数,当取消发生时会自动执行这些回调。

基本用法
cs 复制代码
// 创建 CancellationTokenSource
CancellationTokenSource cts = new CancellationTokenSource();

// 注册取消时的回调
// Register 方法返回一个 CancellationTokenRegistration 对象
// 这个对象可以用于注销回调
CancellationTokenRegistration registration = cts.Token.Register(() =>
{
    Console.WriteLine("执行清理工作...");

    // 在这里执行清理操作
    // 例如:关闭文件、释放资源、关闭设备连接等
    CleanupResources();

    // 例如:记录日志
    LogCancellation();
});

// 执行一些工作
await DoSomeWorkAsync(cts.Token);

// 触发取消
cts.Cancel();

// 手动注销回调(如果需要)
// 如果回调已经执行,注销没有影响
registration.Dispose();

详细说明:

1. Register 的工作原理
cs 复制代码
// Register 方法的签名
// public CancellationTokenRegistration Register(Action callback)

// 回调函数会在以下情况被执行:
// 1. 调用 CancellationTokenSource.Cancel()
// 2. 调用 CancellationTokenSource.CancelAfter() 超时后自动取消
// 3. 关联的令牌源被取消(使用 CreateLinkedTokenSource 创建的)

// 回调函数的执行时机:
// - 在 Cancel() 调用的线程中同步执行
// - 如果注册了多个回调,会按照注册顺序依次执行
// - 回调函数执行期间,其他线程可能继续运行
2. 注册多个回调
cs 复制代码
CancellationTokenSource cts = new CancellationTokenSource();

// 可以注册多个回调
var registration1 = cts.Token.Register(() =>
{
    Console.WriteLine("回调1: 关闭数据库连接");
    CloseDatabaseConnection();
});

var registration2 = cts.Token.Register(() =>
{
    Console.WriteLine("回调2: 清理临时文件");
    CleanTempFiles();
});

var registration3 = cts.Token.Register(() =>
{
    Console.WriteLine("回调3: 记录取消日志");
    LogCancellation();
});

// 当调用 Cancel() 时,所有回调都会执行
cts.Cancel();

// 输出:
// 回调1: 关闭数据库连接
// 回调2: 清理临时文件
// 回调3: 记录取消日志

// 清理注册对象
registration1.Dispose();
registration2.Dispose();
registration3.Dispose();
3. 回调的执行顺序
cs 复制代码
CancellationTokenSource cts = new CancellationTokenSource();

// 注册回调的顺序就是执行顺序
cts.Token.Register(() => Console.WriteLine("第1个回调"));
cts.Token.Register(() => Console.WriteLine("第2个回调"));
cts.Token.Register(() => Console.WriteLine("第3个回调"));

cts.Cancel();

// 输出:
// 第1个回调
// 第2个回调
// 第3个回调
4. 回调中抛出异常
cs 复制代码
CancellationTokenSource cts = new CancellationTokenSource();

// 如果回调中抛出异常,会阻止后续回调执行
cts.Token.Register(() =>
{
    Console.WriteLine("回调1开始");
    throw new Exception("回调1出错");  // 抛出异常
});

cts.Token.Register(() =>
{
    Console.WriteLine("回调2");  // 不会执行!
});

try
{
    cts.Cancel();
}
catch (AggregateException ex)
{
    Console.WriteLine($"捕获到异常: {ex.InnerExceptions[0].Message}");
}

// 输出:
// 回调1开始
// 捕获到异常: 回调1出错

可以在回调中直接catch到异常,这样即使出现了异常,也不会阻止后面的回调

Register 方法的关键要点:

  1. 回调是同步执行的 :回调会在调用 Cancel() 的线程中同步执行

  2. 可以注册多个回调:按照注册顺序依次执行

  3. 应该捕获异常:回调中应该捕获异常,避免影响其他回调

  4. 可以注销回调 :通过返回的 CancellationTokenRegistration 对象可以注销回调

  5. 适合用于资源清理:在取消时自动清理资源,避免资源泄漏

5.2 传递 CancellationToken 到多个异步操作

在实际应用中,我们经常需要并行执行多个任务,并且希望能够一次性取消所有任务。通过将同一个 CancellationToken 传递给所有任务,可以轻松实现这个需求。

cs 复制代码
async Task ComplexOperationAsync(CancellationToken ct)
{
    var tasks = new List<Task>();

    // 并行执行多个任务,共享同一个取消令牌
    // 这样,当令牌被取消时,所有任务都会收到通知
    tasks.Add(Task.Run(() => Task1(ct), ct));
    tasks.Add(Task.Run(() => Task2(ct), ct));
    tasks.Add(Task.Run(() => Task3(ct), ct));

    try
    {
        // 等待所有任务完成
        await Task.WhenAll(tasks);
        Console.WriteLine("所有任务完成");
    }
    catch (OperationCanceledException)
    {
        Console.WriteLine("某个或所有任务被取消");
        throw;
    }
    catch (AggregateException ex)
    {
        // 捕获多个任务的异常
        Console.WriteLine($"捕获到 {ex.InnerExceptions.Count} 个异常");
        foreach (var innerEx in ex.InnerExceptions)
        {
            Console.WriteLine($"  - {innerEx.Message}");
        }
        throw;
    }
}

// 各个任务的实现
async Task Task1(CancellationToken ct)
{
    for (int i = 0; i < 10; i++)
    {
        ct.ThrowIfCancellationRequested();
        Console.WriteLine($"Task1 运行中... {i + 1}/10");
        await Task.Delay(200, ct);
    }
    Console.WriteLine("Task1 完成");
}

async Task Task2(CancellationToken ct)
{
    for (int i = 0; i < 15; i++)
    {
        ct.ThrowIfCancellationRequested();
        Console.WriteLine($"Task2 运行中... {i + 1}/15");
        await Task.Delay(150, ct);
    }
    Console.WriteLine("Task2 完成");
}

async Task Task3(CancellationToken ct)
{
    for (int i = 0; i < 20; i++)
    {
        ct.ThrowIfCancellationRequested();
        Console.WriteLine($"Task3 运行中... {i + 1}/20");
        await Task.Delay(100, ct);
    }
    Console.WriteLine("Task3 完成");
}

使用示例:

cs 复制代码
var cts = new CancellationTokenSource();

// 启动复杂操作
var operationTask = ComplexOperationAsync(cts.Token);

// 等待3秒后取消
await Task.Delay(3000);
Console.WriteLine("取消所有任务...");
cts.Cancel();

try
{
    await operationTask;
}
catch (OperationCanceledException)
{
    Console.WriteLine("操作已取消");
}

输出示例:

cs 复制代码
Task1 运行中... 1/10
Task2 运行中... 1/15
Task3 运行中... 1/20
Task1 运行中... 2/10
Task2 运行中... 2/15
Task3 运行中... 2/20
Task1 运行中... 3/10
Task2 运行中... 3/15
Task3 运行中... 3/20
取消所有任务...
某个或所有任务被取消
操作已取消

5.3 超时控制

超时控制是 CancellationToken 的一个重要应用场景。在运动控制系统中,我们经常需要设置超时,防止某个操作长时间卡死。

方式1:使用 CancellationTokenSource 的构造函数
cs 复制代码
// 方式1:使用带超时的构造函数
// 创建一个会在指定时间后自动取消的 CancellationTokenSource
async Task<T> WithTimeoutAsync<T>(
    Task<T> task,
    TimeSpan timeout)
{
    // 创建一个会在 timeout 时间后自动取消的 CancellationTokenSource
    using var cts = new CancellationTokenSource(timeout);

    try
    {
        // 等待任务完成
        return await task;
    }
    catch (OperationCanceledException) when (!cts.Token.IsCancellationRequested)
    {
        // 任务自己取消了,不是超时
        // 这种情况下,异常是由任务本身抛出的,而不是超时
        throw;
    }
    catch (OperationCanceledException)
    {
        // cts.Token.IsCancellationRequested 为 true
        // 说明是超时导致的取消
        throw new TimeoutException($"操作超时({timeout.TotalSeconds}秒)");
    }
}
方式2:使用 Task.WhenAny 实现超时
cs 复制代码
// 方式2:使用 Task.WhenAny 比较原任务和超时任务
async Task<T> WithTimeoutAsync<T>(
    Task<T> task,
    TimeSpan timeout)
{
    // 创建超时任务
    var timeoutTask = Task.Delay(timeout);

    // 等待任一任务完成
    var completedTask = await Task.WhenAny(task, timeoutTask);

    // 如果超时任务先完成,抛出超时异常
    if (completedTask == timeoutTask)
    {
        throw new TimeoutException($"操作超时({timeout.TotalSeconds}秒)");
    }

    // 否则返回原任务的结果
    return await task;
}
方式3:结合 CancellationToken 和 Task.WhenAny
cs 复制代码
// 方式3:结合使用 CancellationToken 和 Task.WhenAny
// 这样可以真正取消原任务,而不只是超时
async Task<T> WithTimeoutAsync<T>(
    Task<T> task,
    TimeSpan timeout)
{
    // 创建超时的 CancellationTokenSource
    using var cts = new CancellationTokenSource(timeout);

    try
    {
        // 使用 Task.WhenAny 等待任一任务完成
        var completedTask = await Task.WhenAny(task, Task.Delay(timeout, cts.Token));

        // 如果原任务先完成,返回结果
        if (completedTask == task)
        {
            return await task;
        }

        // 如果超时,抛出异常
        throw new TimeoutException($"操作超时({timeout.TotalSeconds}秒)");
    }
    catch (OperationCanceledException) when (cts.Token.IsCancellationRequested)
    {
        // 确认是超时导致的取消
        throw new TimeoutException($"操作超时({timeout.TotalSeconds}秒)");
    }
}
相关推荐
m0_748229992 小时前
C与C#:编程语言的核心差异解析
c语言·开发语言·c#
java1234_小锋2 小时前
Java中读写锁的应用场景是什么?
java·开发语言
yong99902 小时前
MATLAB的智能扫地机器人工作过程仿真
开发语言·matlab·机器人
2601_949847752 小时前
Flutter for OpenHarmony 剧本杀组队App实战:邀请好友功能实现
开发语言·javascript·flutter
浮尘笔记2 小时前
Go语言并发安全字典:sync.Map的使用与实现
开发语言·后端·golang
2301_811232982 小时前
C++中的契约编程
开发语言·c++·算法
2401_829004022 小时前
C++中的访问者模式
开发语言·c++·算法
黎雁·泠崖2 小时前
Java内部类与匿名内部类:定义+类型+实战应用
java·开发语言