第十章:C#取消:CancellationToken
第十章:取消
.NET 4.0 引入了一套强大且优雅的取消功能,它使开发者可以在异步或并发代码中方便地请求和响应取消操作。本章将深入探讨 取消源(CancellationTokenSource) 和 取消接收器(CancellationToken) 的使用方法,探讨其背后的机制,并给出与非标准取消模式互操作的最佳实践。
10.1 发起取消请求:CancellationTokenSource
的用法
在支持取消的代码中,CancellationTokenSource
扮演着取消请求的发起者角色。它通过 Token
属性生成一个与之关联的 CancellationToken
,并通过调用 Cancel
方法发送取消信号。本节将讲解如何创建和使用 CancellationTokenSource
,以及应对取消可能导致的多种结果。
核心概念
CancellationTokenSource
:负责发起取消请求。它的Cancel
方法会发出取消信号。CancellationToken
:从CancellationTokenSource
获取的取消令牌,传递给任务或方法,用于检测取消请求并作出响应。
取消机制的核心思想是协作,取消请求只是一种信号,代码需显式支持并响应此信号。CancellationTokenSource
是取消请求的源头,负责发起取消操作,而关联的 CancellationToken
则用于向任务或操作传递取消信号。
基本用法
以下是使用 CancellationTokenSource
发起取消请求的基本步骤:
- 创建
CancellationTokenSource
并获取CancellationToken
。 - 将
CancellationToken
传递给可取消的任务。 - 发出取消请求(调用
Cancel
方法)。 - 处理取消:任务会检查
CancellationToken
,并在取消时抛出OperationCanceledException
。
示例代码
下面的代码展示了如何使用 CancellationTokenSource
和 CancellationToken
发起和处理取消请求:
void IssueCancelRequest()
{
using var cts = new CancellationTokenSource();
var task = CancelableMethodAsync(cts.Token); // 启动可取消任务
// 发起取消请求
cts.Cancel();
}
在这个示例中,CancelableMethodAsync
是一个接受 CancellationToken
的异步方法。任务启动后,cts.Cancel()
发出取消信号。任务在执行过程中会定期检查 CancellationToken
,并在检测到取消时抛出 OperationCanceledException
。
等待任务并处理结果
通常,你需要等待任务完成,并根据不同的取消状态处理结果。任务完成时可能有三种情况:
- 任务成功完成。
- 任务响应取消请求,抛出
OperationCanceledException
。 - 任务因其他错误抛出非取消相关的异常。
下面的代码展示了如何处理这些情况:
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
。当用户点击“启动”按钮时,异步操作开始;点击“取消”按钮时,发出取消请求。通过禁用按钮,确保用户不能同时启动和取消任务。
竞争条件与取消结果
在发起取消请求时,任务的状态可能存在竞争条件。当取消请求发出时,任务可能已经执行完毕,可能正在执行,或者正好即将检查取消令牌。这会导致以下三种可能的结果:
- 任务响应了取消,抛出
OperationCanceledException
。 - 任务成功完成,即便取消请求已发出,但任务没有及时检查到取消信号。
- 任务遇到其他错误,抛出与取消无关的异常。
因此,处理取消时需要考虑这些不同的结果,并在代码中适当地处理它们。
最佳实践
- 独立性:
每个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 次迭代后检查一次。这种策略适用于循环执行速度很快的情况,可以有效平衡取消的响应性和性能。
轮询频率的选择
决定检查取消请求的频率需要考虑以下因素:
- 任务的工作量:如果每次循环执行的任务非常耗时,应更频繁地检查取消请求,以确保及时响应。
- 取消的响应性要求:如果任务需要在发出取消请求后尽快停止,检查取消的频率应更高。
- 性能影响:频繁调用
ThrowIfCancellationRequested()
可能增加开销,因此在决定检查频率时,需要考虑性能测试结果。
IsCancellationRequested
的使用
除了 ThrowIfCancellationRequested()
,CancellationToken
还提供了 IsCancellationRequested
属性,它可以用来简单地检查取消状态:
if (cancellationToken.IsCancellationRequested)
{
return null; // 或者其他适当的退出逻辑
}
使用 IsCancellationRequested
通常用于不抛出异常的场景,例如提前退出或返回默认值。
最佳实践
- 优先传递 CancellationToken: 在可能的情况下,将
CancellationToken
传递给下一层 API,避免手动轮询。 - 遵循标准取消模式: 使用
ThrowIfCancellationRequested
抛出标准的OperationCanceledException
,确保代码行为一致性。 - 避免滥用 IsCancellationRequested: 不建议直接检查
IsCancellationRequested
后返回默认值或null
,因为这不符合标准模式。如果确实需要此行为,应在文档中明确说明,并在更高层代码捕获异常后处理默认值逻辑。 - 响应粒度: 对于长时间运行的任务,建议在循环的多个阶段或关键操作点检查取消信号,确保更细粒度的取消响应。
10.3 因超时自动取消
在某些情况下,我们希望运行的代码在指定时间内完成,超时后自动停止。这种场景非常适合使用取消机制。通过结合 CancellationTokenSource
和超时设置,可以在超时后自动触发取消请求。本节将介绍如何实现基于超时的自动取消
核心概念
超时取消的核心是利用 CancellationTokenSource
的超时功能,它可以设置一个计时器,在超时时间到达时自动触发取消请求。与其他取消场景一样,目标代码需要监听 CancellationToken
的状态,并在检测到取消信号时响应。
超时取消的实现方式有两种:
- 在创建
CancellationTokenSource
时传入超时时间。 - 使用现有的
CancellationTokenSource
,通过调用CancelAfter
方法启用超时。
使用场景
- 网络请求: 限制请求的最长等待时间,以防止无限期挂起。
- 耗时操作: 为可能超时的任务(如数据库查询或文件操作)设置执行时间上限。
- 后台任务: 限制后台任务执行时间,确保系统资源的合理利用。
基本用法
-
通过构造函数设置超时
在创建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
将发出取消信号,任务会因为检测到取消而终止。 -
对现有的
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); }
这种方式适合需要动态调整超时逻辑的场景。
注意事项
-
优先使用构造器设置超时:
当超时时间是固定值时,构造器的方式更简洁明了。 -
目标代码必须支持取消:
超时取消的前提是目标代码能够响应CancellationToken
,否则即使超时触发了取消信号,任务仍然会继续运行。例如,未监听取消令牌的任务无法轻松中止。 -
不可取消的代码:
如果代码本身不支持取消(如某些同步操作或第三方库调用),超时机制无法直接生效。这种情况下需要通过其他方式(如线程池管理或更高级的超时逻辑)来处理。 -
捕获取消异常:
在调用 Task 或异步方法时,捕获 OperationCanceledException 并优雅地处理取消逻辑。
10.4 取消异步代码
在异步代码中,为了支持取消操作,通常需要使用 CancellationToken
。通过将取消令牌传递到异步方法中,可以实现任务的可控中止。
核心概念
-
传递
CancellationToken
支持取消的异步代码的核心原则是:如果调用的 API 支持CancellationToken
,那么你的方法也应该接受并传递它。这使得上层调用者可以控制任务的取消。 -
异步 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.Delay
、HttpClient.SendAsync
等。
public async Task ProcessDataAsync(CancellationToken cancellationToken)
{
// 调用支持令牌的异步方法
var result = await CancelableMethodAsync(cancellationToken);
// 继续处理结果
Console.WriteLine($"Result: {result}");
}
应对不支持取消的代码
某些异步方法或第三方库可能不支持 CancellationToken
,此时可以考虑以下策略:
- 忽略结果模拟取消
对于无法中断的操作,可以在高层代码中简单地忽略其结果:
public async Task NonCancelableOperation(CancellationToken cancellationToken)
{
var task = SomeNonCancelableAsync();
await Task.WhenAny(task, Task.Delay(Timeout.Infinite, cancellationToken));
}
注意:
- 这种方式不能中断底层任务,只是停止等待其结果。
- 如果操作涉及资源(如文件、网络),需确保释放相关资源。
- 单独隔离不支持取消的逻辑
将不支持取消的代码封装在单独的执行环境(如独立进程或线程)中,并通过外部机制强制中断:
public async Task IsolateAndRun(Action action, CancellationToken cancellationToken)
{
var task = Task.Run(action, cancellationToken);
await task;
}
为什么支持取消很重要
在异步方法中支持取消令牌是良好实践,因为:
-
调用链的可扩展性:
高层方法可能会调用你的方法,而高层代码通常需要控制任务的生命周期。如果你的方法不支持取消,将限制调用者的灵活性。 -
资源节省:
支持取消可以避免执行不必要的任务,减少资源消耗。 -
一致性:
在设计异步代码时,提供取消选项可以确保代码的行为一致,易于维护和扩展。
10.5 取消并行代码
在并行任务中引入取消支持是提升程序响应性的关键。并行计算通常占用大量 CPU 资源,因此在用户需求发生变化时及时取消任务,不仅能提升用户体验,还能更高效地利用系统资源。本节将介绍如何在 .NET 的并行编程模型中实现取消支持,并探讨适合不同场景的最佳实现方式。
核心概念
-
Parallel
和PLINQ
支持取消
并行代码可以通过ParallelOptions
和WithCancellation
方法直接支持CancellationToken
,让系统自动检测取消请求并中止任务。 -
直接在循环体中检测取消
如果无法通过选项传递取消令牌,也可以在并行循环体内手动检查取消状态,但这并不是推荐的做法,因为异常会被包装在AggregateException
中,增加了处理复杂性。
实现取消的两种方法
-
通过
ParallelOptions
传递取消令牌
使用ParallelOptions
是最推荐的方式,因为它让系统自动管理取消检测的频率,并减少额外的编码复杂性。例如:void RotateMatrices(IEnumerable<Matrix> matrices, float degrees, CancellationToken token) { Parallel.ForEach(matrices, new ParallelOptions { CancellationToken = token }, matrix => matrix.Rotate(degrees)); }
在这个例子中:
ParallelOptions
的CancellationToken
属性指定了取消令牌。- 如果取消请求被触发,
Parallel.ForEach
会自动处理终止逻辑。
-
手动检测取消状态(不推荐)
另一种方式是在并行循环体中手动检查取消状态,例如: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 密集型操作,支持取消有以下几个好处:
-
提升用户体验:
并行代码通常会占用大量 CPU 资源。如果用户想要中止操作但任务无法取消,会导致糟糕的用户体验。 -
避免资源浪费:
在任务被取消时,继续执行无意义的计算会浪费 CPU 和内存资源,影响其他任务的运行。 -
响应性:
支持取消可以让系统更快速地响应用户输入或外部事件,提高应用程序的交互性。
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,可以随时中断任务操作。
最佳实践
-
优先使用订阅丢弃机制
- 在响应式代码中,订阅丢弃(
Dispose
)是最简单、直接的取消方式,应作为首选。
- 在响应式代码中,订阅丢弃(
-
与
CancellationToken
无缝集成- 对异步边界的响应式代码,使用
ToTask
方法支持CancellationToken
,实现统一的取消逻辑。
- 对异步边界的响应式代码,使用
-
避免多余的取消转换
- 如果响应式代码已经提供良好的订阅管理能力,应尽量避免重复引入额外的取消机制。
10.7 取消数据流网格(TPL Dataflow)
在 TPL Dataflow 中,支持取消操作是管理数据流和资源的重要手段。通过 CancellationToken
,可以灵活地中止数据流网格中的操作。
核心概念
-
数据流块的取消机制:
每个数据流块都可以通过DataflowBlockOptions
设置一个CancellationToken
,从而支持取消操作。当取消请求触发时:- 数据流块会拒绝接收新数据。
- 当前未处理的数据会被丢弃。
- 数据流块将进入完成状态,取消被视为一种特殊形式的错误。
-
取消的传播:
在数据流网格中,完成状态会沿着链接传播。如果取消了一个块,取消状态会向下传播到所有链接的块,导致整个网格停止运行。
实现取消
-
为每个块设置
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
。- 取消效果:当取消令牌触发时,每个块会停止接收新数据并丢弃未处理的数据。
-
取消整个网格
如果网格的取消逻辑是全局的,可以只将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();
- 在这种情况下,仅取消第一个块即可,后续块会根据完成状态自动停止。
取消的注意事项
-
取消不是刷新:
取消数据流块时:- 块会立即丢弃所有未处理的数据。
- 块会拒绝接收任何新输入。
如果需要确保未处理的数据能够被处理完毕而后停止,应使用完成机制(Complete
)而非取消。
-
数据丢失风险:
如果在数据流块运行时取消操作,可能会导致部分数据丢失。因此,取消操作需要谨慎使用,尤其是在需要保证数据完整性的场景。 -
性能和资源管理:
通过取消,可以避免长时间运行的任务占用资源,提升系统的响应性和资源利用率。
10.8 关联取消令牌
在某些场景中,代码需要响应取消请求,同时向下传递取消信号。通过 .NET 的 关联取消令牌(Linked Cancellation Token),可以将多个取消源关联在一起,创建一个组合令牌,用于管理复杂的取消逻辑。
核心概念
-
关联取消令牌:
使用CancellationTokenSource.CreateLinkedTokenSource
可以将多个取消令牌组合在一起,创建一个新的取消令牌。当任意一个原始令牌被取消时,组合令牌也会被取消。 -
典型场景:
- 用户请求的取消(例如通过
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
的请求提前终止。
关键点
-
多个取消源的关联:
CreateLinkedTokenSource
支持任意数量的令牌。例如:var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token1, token2, token3); var combinedToken = linkedCts.Token;
无论
token1
、token2
或token3
中的任何一个被取消,组合令牌combinedToken
都会被取消。 -
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); }
-
生命周期管理:
- 及时释放关联取消源:
关联取消令牌源会依附于原始令牌。如果不及时释放,可能导致内存泄漏。例如,如果一个方法多次被调用,但没有释放关联取消令牌源,原始令牌的生命周期可能会被意外延长。 - 使用
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); }
- 及时释放关联取消源:
注意事项
-
取消传播:
关联取消令牌的取消会沿着链条传播。例如:- 如果用户取消了请求,组合令牌会被触发取消。
- 如果超时触发,组合令牌也会被触发取消。
-
取消视为错误:
当一个块被取消时(例如超时触发),它会抛出OperationCanceledException
。这种行为被视为一种特殊的错误,需要在调用链中适当处理。 -
性能与资源管理:
- 关联的取消令牌源会为每个组合令牌分配一定的资源,因此需要及时释放。
- 避免在长生命周期的上下文中频繁创建新的关联令牌。
10.9 与其他取消系统互操作
在处理外部代码或遗留代码时,可能需要将它们的取消机制与 .NET 的标准 CancellationToken
结合起来。通过 CancellationToken
的回调机制,可以实现两种取消系统的互操作,从而在更大范围内统一取消逻辑。
核心概念
-
CancellationToken
的两种取消方式:- 轮询:适合 CPU 密集型场景(如数据处理循环),通过定期检查令牌状态来检测取消请求。
- 回调:适合需要响应取消的异步操作,通过
CancellationToken.Register
注册回调,在取消时触发指定的逻辑。
-
互操作场景:
当外部代码或遗留代码具有自己的取消机制(不使用CancellationToken
)时,可以通过CancellationToken.Register
注册回调,将标准的取消请求与外部代码的取消逻辑关联。
示例:包装 Ping
的取消逻辑
以下示例展示如何为不支持 CancellationToken
的 System.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.SendAsyncCancel
是Ping
自身的取消方法。- 使用
cancellationToken.Register
注册取消回调,当CancellationToken
被触发时,调用SendAsyncCancel
来中止Ping
操作。
注意事项
-
取消操作的范围:
CancellationToken
的设计是一次性取消单个操作。- 如果外部取消系统影响多个操作(如关闭某些资源),需要特别说明其取消语义,并记录在文档中,避免混淆。
-
回调注册的生命周期:
CancellationToken.Register
方法返回一个可释放的对象,表示回调的注册。- 必须在回调不再需要时释放该对象,否则会导致资源泄漏。
示例中使用了using
语句确保注册在异步操作完成后被清理。
-
资源泄漏的风险:
如果未释放注册对象,可能会导致以下问题:- 每次调用会增加一个新的回调,旧的回调不会被清理。
- 回调可能延长外部资源(如
Ping
对象)的生命周期,导致内存或资源溢出。