第十章:C#取消:CancellationToken

第十章:取消


.NET 4.0 引入了一套强大且优雅的取消功能,它使开发者可以在异步或并发代码中方便地请求和响应取消操作。本章将深入探讨 取消源(CancellationTokenSource)取消接收器(CancellationToken) 的使用方法,探讨其背后的机制,并给出与非标准取消模式互操作的最佳实践。

10.1 发起取消请求:CancellationTokenSource 的用法

在支持取消的代码中,CancellationTokenSource 扮演着取消请求的发起者角色。它通过 Token 属性生成一个与之关联的 CancellationToken,并通过调用 Cancel 方法发送取消信号。本节将讲解如何创建和使用 CancellationTokenSource,以及应对取消可能导致的多种结果。

核心概念

  • CancellationTokenSource:负责发起取消请求。它的 Cancel 方法会发出取消信号。
  • CancellationToken:从 CancellationTokenSource 获取的取消令牌,传递给任务或方法,用于检测取消请求并作出响应。

取消机制的核心思想是协作,取消请求只是一种信号,代码需显式支持并响应此信号。CancellationTokenSource 是取消请求的源头,负责发起取消操作,而关联的 CancellationToken 则用于向任务或操作传递取消信号。

基本用法

以下是使用 CancellationTokenSource 发起取消请求的基本步骤:

  1. 创建 CancellationTokenSource 并获取 CancellationToken
  2. CancellationToken 传递给可取消的任务
  3. 发出取消请求(调用 Cancel 方法)。
  4. 处理取消:任务会检查 CancellationToken,并在取消时抛出 OperationCanceledException

示例代码

下面的代码展示了如何使用 CancellationTokenSourceCancellationToken 发起和处理取消请求:

void IssueCancelRequest()
{
    using var cts = new CancellationTokenSource();
    var task = CancelableMethodAsync(cts.Token);  // 启动可取消任务

    // 发起取消请求
    cts.Cancel();
}

在这个示例中,CancelableMethodAsync 是一个接受 CancellationToken 的异步方法。任务启动后,cts.Cancel() 发出取消信号。任务在执行过程中会定期检查 CancellationToken,并在检测到取消时抛出 OperationCanceledException

等待任务并处理结果

通常,你需要等待任务完成,并根据不同的取消状态处理结果。任务完成时可能有三种情况:

  1. 任务成功完成。
  2. 任务响应取消请求,抛出 OperationCanceledException
  3. 任务因其他错误抛出非取消相关的异常。

下面的代码展示了如何处理这些情况:

async Task IssueCancelRequestAsync()
{
    using var cts = new CancellationTokenSource();
    var task = CancelableOperationAsync(cts.Token);  // 启动可取消任务

    // 发起取消请求
    cts.Cancel();

    try
    {
        await task;  // 等待任务完成
        // 任务成功完成
    }
    catch (OperationCanceledException)
    {
        // 任务因取消而终止
    }
    catch (Exception)
    {
        // 任务因其他错误终止
        throw;
    }
}

async Task CancelableOperationAsync(CancellationToken token)
{
    for (int i = 0; i < 10; i++)
    {
        token.ThrowIfCancellationRequested();
        await Task.Delay(500); // 模拟工作
    }
    Console.WriteLine("操作完成。");
}

在这个示例中,await task 等待任务完成。依照不同的结果,分别处理任务成功、取消和异常情况。

现实场景中的使用

在实际应用中,CancellationTokenSource 通常会被放在不同的代码逻辑中。特别是在 GUI 应用程序中,用户可以通过点击按钮来启动或取消任务。下面是一个基于 GUI 的示例,通过按钮触发异步操作,并通过另一个按钮发起取消请求:

private CancellationTokenSource _cts;

private async void StartButton_Click(object sender, RoutedEventArgs e)
{
    StartButton.IsEnabled = false;
    CancelButton.IsEnabled = true;

    try
    {
        _cts = new CancellationTokenSource();
        CancellationToken token = _cts.Token;

        // 启动一个可以被取消的异步操作
        await Task.Delay(TimeSpan.FromSeconds(5), token);

        MessageBox.Show("操作成功完成。");
    }
    catch (OperationCanceledException)
    {
        MessageBox.Show("操作被取消。");
    }
    catch (Exception)
    {
        MessageBox.Show("操作中出现错误。");
        throw;
    }
    finally
    {
        StartButton.IsEnabled = true;
        CancelButton.IsEnabled = false;
    }
}

private void CancelButton_Click(object sender, RoutedEventArgs e)
{
    _cts.Cancel();  // 发起取消请求
    CancelButton.IsEnabled = false;
}

这个示例展示了如何在 GUI 应用中使用 CancellationTokenSource。当用户点击“启动”按钮时,异步操作开始;点击“取消”按钮时,发出取消请求。通过禁用按钮,确保用户不能同时启动和取消任务。

竞争条件与取消结果

在发起取消请求时,任务的状态可能存在竞争条件。当取消请求发出时,任务可能已经执行完毕,可能正在执行,或者正好即将检查取消令牌。这会导致以下三种可能的结果:

  1. 任务响应了取消,抛出 OperationCanceledException
  2. 任务成功完成,即便取消请求已发出,但任务没有及时检查到取消信号。
  3. 任务遇到其他错误,抛出与取消无关的异常。

因此,处理取消时需要考虑这些不同的结果,并在代码中适当地处理它们。

最佳实践

  • 独立性:
    每个 CancellationTokenSource 是独立的,不能重复使用。一旦调用了 Cancel 方法,该实例就处于“取消”状态,后续不能重置。
  • 始终检查取消状态:
    使用 ThrowIfCancellationRequested 或显式检查 IsCancellationRequested ,确保任务及时响应取消信号。
  • 避免资源泄漏:
    使用 using 块或显式调用 Dispose,确保 CancellationTokenSource 被正确释放。
  • 设计友好的取消 API:
    确保公共方法支持 CancellationToken 参数,并为无需取消的场景提供重载或默认值。

10.2 通过轮询响应取消请求

在某些情况下,代码中有长时间运行的循环,并且需要支持取消操作。由于循环本身并没有内置的取消机制,无法直接使用 CancellationToken,因此我们可以通过轮询的方式来检查并响应取消请求。

核心概念

当代码执行循环时,通常需要定期检查 CancellationToken 是否已经被取消,并在检测到取消请求时及时退出。最常见的做法是使用 CancellationToken.ThrowIfCancellationRequested() 方法,它会在取消请求发出时抛出 OperationCanceledException,从而终止当前操作。

基本用法

如果你在代码中执行一个较长的循环任务,可以在循环体中间歇性地检查取消令牌。以下是一个简单的示例:

public int CancelableMethod(CancellationToken cancellationToken)
{
    for (int i = 0; i < 100; i++)
    {
        // 模拟某些计算操作
        Thread.Sleep(1000);

        // 检查是否有取消请求
        cancellationToken.ThrowIfCancellationRequested();
    }
    return 42;
}

在这个示例中,每次循环迭代后,代码调用 ThrowIfCancellationRequested() 来检查是否应该取消操作。如果检测到取消请求,它将抛出异常并终止循环。

优化轮询频率

如果循环执行得非常快(例如大量迭代且每次的计算操作耗时较短),频繁检查取消请求可能会影响性能。在这种情况下,可以通过限制检查频率来提高效率。例如,只有在特定次数的迭代之后才检查一次取消请求:

public int CancelableMethod(CancellationToken cancellationToken)
{
    for (int i = 0; i < 100000; i++)
    {
        // 模拟某些计算操作
        Thread.Sleep(1);

        // 每隔 1000 次迭代检查一次是否有取消请求
        if (i % 1000 == 0)
        {
            cancellationToken.ThrowIfCancellationRequested();
        }
    }
    return 42;
}

在这个例子中,取消请求每 1000 次迭代后检查一次。这种策略适用于循环执行速度很快的情况,可以有效平衡取消的响应性和性能。

轮询频率的选择

决定检查取消请求的频率需要考虑以下因素:

  1. 任务的工作量:如果每次循环执行的任务非常耗时,应更频繁地检查取消请求,以确保及时响应。
  2. 取消的响应性要求:如果任务需要在发出取消请求后尽快停止,检查取消的频率应更高。
  3. 性能影响:频繁调用 ThrowIfCancellationRequested() 可能增加开销,因此在决定检查频率时,需要考虑性能测试结果。

IsCancellationRequested 的使用

除了 ThrowIfCancellationRequested()CancellationToken 还提供了 IsCancellationRequested 属性,它可以用来简单地检查取消状态:

if (cancellationToken.IsCancellationRequested)
{
    return null;  // 或者其他适当的退出逻辑
}

使用 IsCancellationRequested 通常用于不抛出异常的场景,例如提前退出或返回默认值。

最佳实践

  • 优先传递 CancellationToken: 在可能的情况下,将 CancellationToken 传递给下一层 API,避免手动轮询。
  • 遵循标准取消模式: 使用 ThrowIfCancellationRequested 抛出标准的 OperationCanceledException,确保代码行为一致性。
  • 避免滥用 IsCancellationRequested: 不建议直接检查 IsCancellationRequested 后返回默认值或 null,因为这不符合标准模式。如果确实需要此行为,应在文档中明确说明,并在更高层代码捕获异常后处理默认值逻辑。
  • 响应粒度: 对于长时间运行的任务,建议在循环的多个阶段或关键操作点检查取消信号,确保更细粒度的取消响应。

10.3 因超时自动取消

在某些情况下,我们希望运行的代码在指定时间内完成,超时后自动停止。这种场景非常适合使用取消机制。通过结合 CancellationTokenSource 和超时设置,可以在超时后自动触发取消请求。本节将介绍如何实现基于超时的自动取消

核心概念

超时取消的核心是利用 CancellationTokenSource 的超时功能,它可以设置一个计时器,在超时时间到达时自动触发取消请求。与其他取消场景一样,目标代码需要监听 CancellationToken 的状态,并在检测到取消信号时响应。

超时取消的实现方式有两种:

  1. 在创建 CancellationTokenSource 时传入超时时间
  2. 使用现有的 CancellationTokenSource,通过调用 CancelAfter 方法启用超时

使用场景

  • 网络请求: 限制请求的最长等待时间,以防止无限期挂起。
  • 耗时操作: 为可能超时的任务(如数据库查询或文件操作)设置执行时间上限。
  • 后台任务: 限制后台任务执行时间,确保系统资源的合理利用。

基本用法

  1. 通过构造函数设置超时
    在创建 CancellationTokenSource 时直接传入超时时间,超时后会自动触发取消请求:

    async Task IssueTimeoutAsync()
    {
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); // 设置5秒超时
        CancellationToken token = cts.Token;
    
        // 模拟一个需要取消的任务
        await Task.Delay(TimeSpan.FromSeconds(10), token);
    }
    

    在这个示例中,如果任务执行时间超过 5 秒,CancellationTokenSource 将发出取消信号,任务会因为检测到取消而终止。

  2. 对现有的 CancellationTokenSource 启用超时
    如果已经有一个 CancellationTokenSource 实例,可以通过调用 CancelAfter 方法动态设置超时时间:

    async Task IssueTimeoutAsync()
    {
        using var cts = new CancellationTokenSource();
        CancellationToken token = cts.Token;
    
        // 启用5秒超时
        cts.CancelAfter(TimeSpan.FromSeconds(5));
    
        // 模拟一个需要取消的任务
        await Task.Delay(TimeSpan.FromSeconds(10), token);
    }
    

    这种方式适合需要动态调整超时逻辑的场景。

注意事项

  1. 优先使用构造器设置超时
    当超时时间是固定值时,构造器的方式更简洁明了。

  2. 目标代码必须支持取消
    超时取消的前提是目标代码能够响应 CancellationToken,否则即使超时触发了取消信号,任务仍然会继续运行。例如,未监听取消令牌的任务无法轻松中止。

  3. 不可取消的代码
    如果代码本身不支持取消(如某些同步操作或第三方库调用),超时机制无法直接生效。这种情况下需要通过其他方式(如线程池管理或更高级的超时逻辑)来处理。

  4. 捕获取消异常
    在调用 Task 或异步方法时,捕获 OperationCanceledException 并优雅地处理取消逻辑。

10.4 取消异步代码

在异步代码中,为了支持取消操作,通常需要使用 CancellationToken。通过将取消令牌传递到异步方法中,可以实现任务的可控中止。

核心概念

  1. 传递 CancellationToken
    支持取消的异步代码的核心原则是:如果调用的 API 支持 CancellationToken,那么你的方法也应该接受并传递它。这使得上层调用者可以控制任务的取消。

  2. 异步 API 的取消
    许多 .NET 中的异步方法(如 Task.Delay)都支持取消令牌。通过将 CancellationToken 传递给这些方法,可以实现取消。

基本用法

以下是一个示例,展示了如何在异步代码中支持取消:

public async Task<int> CancelableMethodAsync(CancellationToken cancellationToken)
{
    // 使用支持 CancellationToken 的异步方法
    await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
    return 42;
}

在这个代码中:

  • Task.Delay 方法接受了 CancellationToken,使其能够在取消信号发出时提前终止。
  • 如果任务被取消,会抛出 OperationCanceledException

最佳实践:令牌的接受与传递

原则:

  • 接受令牌:方法应尽可能支持 CancellationToken,即使当前实现中暂时不需要使用它。
  • 传递令牌:将传入的令牌传递给所有支持 CancellationToken 的异步 API,例如 Task.DelayHttpClient.SendAsync 等。
public async Task ProcessDataAsync(CancellationToken cancellationToken)
{
    // 调用支持令牌的异步方法
    var result = await CancelableMethodAsync(cancellationToken);
    // 继续处理结果
    Console.WriteLine($"Result: {result}");
}

应对不支持取消的代码

某些异步方法或第三方库可能不支持 CancellationToken,此时可以考虑以下策略:

  1. 忽略结果模拟取消
    对于无法中断的操作,可以在高层代码中简单地忽略其结果:
public async Task NonCancelableOperation(CancellationToken cancellationToken)
{
    var task = SomeNonCancelableAsync();
    await Task.WhenAny(task, Task.Delay(Timeout.Infinite, cancellationToken));
}

注意

  • 这种方式不能中断底层任务,只是停止等待其结果。
  • 如果操作涉及资源(如文件、网络),需确保释放相关资源。
  1. 单独隔离不支持取消的逻辑
    将不支持取消的代码封装在单独的执行环境(如独立进程或线程)中,并通过外部机制强制中断:
public async Task IsolateAndRun(Action action, CancellationToken cancellationToken)
{
    var task = Task.Run(action, cancellationToken);
    await task;
}

为什么支持取消很重要

在异步方法中支持取消令牌是良好实践,因为:

  1. 调用链的可扩展性
    高层方法可能会调用你的方法,而高层代码通常需要控制任务的生命周期。如果你的方法不支持取消,将限制调用者的灵活性。

  2. 资源节省
    支持取消可以避免执行不必要的任务,减少资源消耗。

  3. 一致性
    在设计异步代码时,提供取消选项可以确保代码的行为一致,易于维护和扩展。

10.5 取消并行代码

在并行任务中引入取消支持是提升程序响应性的关键。并行计算通常占用大量 CPU 资源,因此在用户需求发生变化时及时取消任务,不仅能提升用户体验,还能更高效地利用系统资源。本节将介绍如何在 .NET 的并行编程模型中实现取消支持,并探讨适合不同场景的最佳实现方式。

核心概念

  1. ParallelPLINQ 支持取消
    并行代码可以通过 ParallelOptionsWithCancellation 方法直接支持 CancellationToken,让系统自动检测取消请求并中止任务。

  2. 直接在循环体中检测取消
    如果无法通过选项传递取消令牌,也可以在并行循环体内手动检查取消状态,但这并不是推荐的做法,因为异常会被包装在 AggregateException 中,增加了处理复杂性。

实现取消的两种方法

  1. 通过 ParallelOptions 传递取消令牌
    使用 ParallelOptions 是最推荐的方式,因为它让系统自动管理取消检测的频率,并减少额外的编码复杂性。例如:

    void RotateMatrices(IEnumerable<Matrix> matrices, float degrees, CancellationToken token)
    {
        Parallel.ForEach(matrices, new ParallelOptions { CancellationToken = token }, 
            matrix => matrix.Rotate(degrees));
    }
    

    在这个例子中:

    • ParallelOptionsCancellationToken 属性指定了取消令牌。
    • 如果取消请求被触发,Parallel.ForEach 会自动处理终止逻辑。
  2. 手动检测取消状态(不推荐)
    另一种方式是在并行循环体中手动检查取消状态,例如:

    void RotateMatrices2(IEnumerable<Matrix> matrices, float degrees, CancellationToken token)
    {
        Parallel.ForEach(matrices, matrix =>
        {
            matrix.Rotate(degrees);
            token.ThrowIfCancellationRequested(); // 检查取消状态
        });
    }
    

    虽然这种方式可以实现取消,但存在以下问题:

    • 异常会被包装在 AggregateException 中,处理起来更复杂。
    • 与系统自动管理相比,手动检查取消状态可能不够高效。

    因此,推荐使用 ParallelOptions 的方式来传递取消令牌,而非手动检查。

使用 PLINQ 支持取消

PLINQ(并行 LINQ)内置了对取消的支持,可以通过 WithCancellation 方法轻松实现。例如:

IEnumerable<int> MultiplyBy2(IEnumerable<int> values, CancellationToken cancellationToken)
{
    return values.AsParallel()
                 .WithCancellation(cancellationToken)
                 .Select(item => item * 2);
}
  • AsParallel() 将数据源转换为并行查询。
  • WithCancellation(cancellationToken) 为查询添加了取消支持。
  • 一旦取消请求触发,查询会提前终止。

并行任务为什么支持取消

对于并行处理或其他 CPU 密集型操作,支持取消有以下几个好处:

  1. 提升用户体验
    并行代码通常会占用大量 CPU 资源。如果用户想要中止操作但任务无法取消,会导致糟糕的用户体验。

  2. 避免资源浪费
    在任务被取消时,继续执行无意义的计算会浪费 CPU 和内存资源,影响其他任务的运行。

  3. 响应性
    支持取消可以让系统更快速地响应用户输入或外部事件,提高应用程序的交互性。

10.6 取消System.Reactive代码

在响应式编程中,System.Reactive 提供了一种独特的取消机制:丢弃订阅。通过释放订阅,可以逻辑上终止对可观察序列的监听。此外,.NET 提供的 CancellationToken 系统也可以与 System.Reactive 无缝集成,用于增强取消功能。这一节将探讨如何结合两种机制实现高效的取消操作。

通过 IDisposable 取消订阅

最基础的取消方法是直接释放订阅对象:

private IDisposable _mouseMovesSubscription;

private void StartButton_Click(object sender, RoutedEventArgs e)
{
    IObservable<Point> mouseMoves = Observable
        .FromEventPattern<MouseEventHandler, MouseEventArgs>(
            handler => (s, a) => handler(s, a),
            handler => MouseMove += handler,
            handler => MouseMove -= handler)
        .Select(x => x.EventArgs.GetPosition(this));

    _mouseMovesSubscription = mouseMoves.Subscribe(position =>
    {
        MousePositionLabel.Content = $"({position.X}, {position.Y})";
    });
}

private void CancelButton_Click(object sender, RoutedEventArgs e)
{
    _mouseMovesSubscription?.Dispose(); // 释放订阅以终止事件监听
}

说明

  • 通过调用 Dispose 方法,可以取消对事件的订阅。
  • 此方式直接针对 System.Reactive 的订阅机制,无需额外依赖其他取消系统。

在异步代码中支持 CancellationTokenSource

System.Reactive 提供了将可观察序列转换为任务的方法,支持与 CancellationToken 集成:

CancellationToken cancellationToken = ...;
IObservable<int> observable = ...;

// 异步获取最后一个元素
int lastElement = await observable.ToTask(cancellationToken);

// 异步获取第一个元素
int firstElement = await observable.Take(1).ToTask(cancellationToken);

// 异步获取整个序列
IList<int> allElements = await observable.ToList().ToTask(cancellationToken);

说明

ToTask 方法允许将响应式操作与任务模式结合,简化了与异步框架的集成。
通过传递 CancellationToken,可以随时中断任务操作。

最佳实践

  1. 优先使用订阅丢弃机制

    • 在响应式代码中,订阅丢弃(Dispose)是最简单、直接的取消方式,应作为首选。
  2. CancellationToken 无缝集成

    • 对异步边界的响应式代码,使用 ToTask 方法支持 CancellationToken,实现统一的取消逻辑。
  3. 避免多余的取消转换

    • 如果响应式代码已经提供良好的订阅管理能力,应尽量避免重复引入额外的取消机制。

10.7 取消数据流网格(TPL Dataflow)

TPL Dataflow 中,支持取消操作是管理数据流和资源的重要手段。通过 CancellationToken,可以灵活地中止数据流网格中的操作。

核心概念

  1. 数据流块的取消机制
    每个数据流块都可以通过 DataflowBlockOptions 设置一个 CancellationToken,从而支持取消操作。当取消请求触发时:

    • 数据流块会拒绝接收新数据。
    • 当前未处理的数据会被丢弃。
    • 数据流块将进入完成状态,取消被视为一种特殊形式的错误。
  2. 取消的传播
    在数据流网格中,完成状态会沿着链接传播。如果取消了一个块,取消状态会向下传播到所有链接的块,导致整个网格停止运行。

实现取消

  1. 为每个块设置 CancellationToken
    可以为网格中的每个块单独设置 CancellationToken,如下示例所示:

    IPropagatorBlock<int, int> CreateMyCustomBlock(CancellationToken cancellationToken)
    {
        // 设置块的选项,包括取消令牌
        var blockOptions = new ExecutionDataflowBlockOptions
        {
            CancellationToken = cancellationToken
        };
    
        // 定义数据流块
        var multiplyBlock = new TransformBlock<int, int>(item => item * 2, blockOptions);
        var addBlock = new TransformBlock<int, int>(item => item + 2, blockOptions);
        var divideBlock = new TransformBlock<int, int>(item => item / 2, blockOptions);
    
        // 设置链接选项,传播完成状态
        var flowCompletion = new DataflowLinkOptions
        {
            PropagateCompletion = true
        };
    
        // 链接数据流块
        multiplyBlock.LinkTo(addBlock, flowCompletion);
        addBlock.LinkTo(divideBlock, flowCompletion);
    
        // 将块封装为网格
        return DataflowBlock.Encapsulate(multiplyBlock, divideBlock);
    }
    
    • ExecutionDataflowBlockOptions:包含块的执行配置,例如 CancellationToken
    • 取消效果:当取消令牌触发时,每个块会停止接收新数据并丢弃未处理的数据。
  2. 取消整个网格
    如果网格的取消逻辑是全局的,可以只将 CancellationToken 应用于第一个块,完成状态会自动沿着链接传播到下游块:

    var blockOptions = new ExecutionDataflowBlockOptions
    {
        CancellationToken = cancellationToken
    };
    
    var firstBlock = new TransformBlock<int, int>(item => item * 2, blockOptions);
    var secondBlock = new TransformBlock<int, int>(item => item + 2);
    
    // 链接块并传播完成状态
    firstBlock.LinkTo(secondBlock, new DataflowLinkOptions { PropagateCompletion = true });
    
    // 手动触发第一个块的完成
    firstBlock.Complete();
    
    • 在这种情况下,仅取消第一个块即可,后续块会根据完成状态自动停止。

取消的注意事项

  1. 取消不是刷新
    取消数据流块时:

    • 块会立即丢弃所有未处理的数据。
    • 块会拒绝接收任何新输入。
      如果需要确保未处理的数据能够被处理完毕而后停止,应使用完成机制(Complete)而非取消。
  2. 数据丢失风险
    如果在数据流块运行时取消操作,可能会导致部分数据丢失。因此,取消操作需要谨慎使用,尤其是在需要保证数据完整性的场景。

  3. 性能和资源管理
    通过取消,可以避免长时间运行的任务占用资源,提升系统的响应性和资源利用率。

10.8 关联取消令牌

在某些场景中,代码需要响应取消请求,同时向下传递取消信号。通过 .NET 的 关联取消令牌(Linked Cancellation Token),可以将多个取消源关联在一起,创建一个组合令牌,用于管理复杂的取消逻辑。

核心概念

  1. 关联取消令牌
    使用 CancellationTokenSource.CreateLinkedTokenSource 可以将多个取消令牌组合在一起,创建一个新的取消令牌。当任意一个原始令牌被取消时,组合令牌也会被取消。

  2. 典型场景

    • 用户请求的取消(例如通过 CancellationToken)。
    • 系统级超时逻辑。
    • 应用程序特定的取消需求。
      通过组合这些取消来源,可以实现更灵活的取消逻辑。

示例:超时与用户取消的结合

以下代码展示了如何在异步 HTTP 请求中,结合用户取消和超时逻辑:

using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

public class CancelInjectionExample
{
    public async Task<HttpResponseMessage> GetWithTimeoutAsync(
        HttpClient client, 
        string url, 
        CancellationToken userCancellationToken)
    {
        // 创建关联取消令牌源,将用户的取消令牌与超时关联
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(userCancellationToken);
        cts.CancelAfter(TimeSpan.FromSeconds(5)); // 设置超时时间

        // 获取关联的取消令牌
        CancellationToken combinedToken = cts.Token;
        try
        {
            // 使用组合令牌发起 HTTP 请求
            return await client.GetAsync(url, combinedToken);
        }
        catch (OperationCanceledException) when (combinedToken.IsCancellationRequested)
        {
            Console.WriteLine("操作已取消(用户取消或超时)");
            throw;
        }
    }
}

  • 传入的 userCancellationToken(用户取消)和 CancelAfter(超时逻辑)通过 CreateLinkedTokenSource 组合在一起。
  • 如果用户取消了请求或超时触发,combinedToken 会被取消,导致 HttpClient 的请求提前终止。

关键点

  1. 多个取消源的关联
    CreateLinkedTokenSource 支持任意数量的令牌。例如:

    var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token1, token2, token3);
    var combinedToken = linkedCts.Token;
    

    无论 token1token2token3 中的任何一个被取消,组合令牌 combinedToken 都会被取消。

  2. ASP.NET 的实际应用
    在 ASP.NET 中,HttpContext.RequestAborted 提供了一个代表用户断开连接的取消令牌。可以将其与其他取消源组合,例如处理超时逻辑:

    async Task HandleRequestAsync(HttpContext context, CancellationToken cancellationToken)
    {
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, cancellationToken);
        CancellationToken combinedToken = linkedCts.Token;
    
        // 执行需要取消支持的操作
        await PerformOperationAsync(combinedToken);
    }
    
  3. 生命周期管理

    • 及时释放关联取消源
      关联取消令牌源会依附于原始令牌。如果不及时释放,可能导致内存泄漏。例如,如果一个方法多次被调用,但没有释放关联取消令牌源,原始令牌的生命周期可能会被意外延长。
    • 使用 using 语句或显式调用 Dispose 来确保取消源被及时释放。

    错误示例:

    async Task FaultyMethod(CancellationToken token)
    {
        // 创建关联取消令牌,但没有释放
        var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
        CancellationToken combinedToken = cts.Token;
    
        await Task.Delay(5000, combinedToken); // 假设方法被多次调用
    }
    

    此代码可能导致内存溢出,因为 cts 没有释放。

    正确示例:

    async Task CorrectMethod(CancellationToken token)
    {
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(token);
        CancellationToken combinedToken = cts.Token;
    
        await Task.Delay(5000, combinedToken);
    }
    

注意事项

  1. 取消传播
    关联取消令牌的取消会沿着链条传播。例如:

    • 如果用户取消了请求,组合令牌会被触发取消。
    • 如果超时触发,组合令牌也会被触发取消。
  2. 取消视为错误
    当一个块被取消时(例如超时触发),它会抛出 OperationCanceledException。这种行为被视为一种特殊的错误,需要在调用链中适当处理。

  3. 性能与资源管理

    • 关联的取消令牌源会为每个组合令牌分配一定的资源,因此需要及时释放。
    • 避免在长生命周期的上下文中频繁创建新的关联令牌。

10.9 与其他取消系统互操作

在处理外部代码或遗留代码时,可能需要将它们的取消机制与 .NET 的标准 CancellationToken 结合起来。通过 CancellationToken 的回调机制,可以实现两种取消系统的互操作,从而在更大范围内统一取消逻辑。

核心概念

  1. CancellationToken 的两种取消方式

    • 轮询:适合 CPU 密集型场景(如数据处理循环),通过定期检查令牌状态来检测取消请求。
    • 回调:适合需要响应取消的异步操作,通过 CancellationToken.Register 注册回调,在取消时触发指定的逻辑。
  2. 互操作场景
    当外部代码或遗留代码具有自己的取消机制(不使用 CancellationToken)时,可以通过 CancellationToken.Register 注册回调,将标准的取消请求与外部代码的取消逻辑关联。

示例:包装 Ping 的取消逻辑

以下示例展示如何为不支持 CancellationTokenSystem.Net.NetworkInformation.Ping 类型添加取消支持:

using System.Net.NetworkInformation;
using System.Threading;
using System.Threading.Tasks;

public class PingExample
{
    public async Task<PingReply> PingAsync(string hostNameOrAddress, CancellationToken cancellationToken)
    {
        using var ping = new Ping();

        // 创建 Ping 的异步任务
        Task<PingReply> task = ping.SendPingAsync(hostNameOrAddress);

        // 注册回调以响应取消请求
        using CancellationTokenRegistration registration = cancellationToken.Register(() =>
        {
            ping.SendAsyncCancel();
        });

        try
        {
            return await task;
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("Ping 操作已取消。");
            throw;
        }
    }
}

  • 逻辑分析
    • Ping.SendPingAsync 是一个基于 Task 的异步方法,但它不支持 CancellationToken
    • Ping.SendAsyncCancelPing 自身的取消方法。
    • 使用 cancellationToken.Register 注册取消回调,当 CancellationToken 被触发时,调用 SendAsyncCancel 来中止 Ping 操作。

注意事项

  1. 取消操作的范围

    • CancellationToken 的设计是一次性取消单个操作。
    • 如果外部取消系统影响多个操作(如关闭某些资源),需要特别说明其取消语义,并记录在文档中,避免混淆。
  2. 回调注册的生命周期

    • CancellationToken.Register 方法返回一个可释放的对象,表示回调的注册。
    • 必须在回调不再需要时释放该对象,否则会导致资源泄漏。
      示例中使用了 using 语句确保注册在异步操作完成后被清理。
  3. 资源泄漏的风险
    如果未释放注册对象,可能会导致以下问题:

    • 每次调用会增加一个新的回调,旧的回调不会被清理。
    • 回调可能延长外部资源(如 Ping 对象)的生命周期,导致内存或资源溢出。
posted @ 2024-12-09 16:13  平元兄  阅读(100)  评论(0编辑  收藏  举报