news 2026/8/31 2:48:12

CancellationToken实战指南:从基础到高级的异步任务取消策略

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CancellationToken实战指南:从基础到高级的异步任务取消策略

1. 理解CancellationToken:你的异步任务“紧急停止按钮”

想象一下,你正在用手机下载一部高清电影,进度条已经走到一半,突然发现下错了文件,或者手机快没电了。这时候你会怎么做?当然是立刻点击“取消下载”。在异步编程的世界里,CancellationToken就是那个至关重要的“取消按钮”。

我刚开始接触异步编程时,经常遇到一个头疼的问题:一个任务启动后,就像脱缰的野马,想停都停不下来。要么是用户点了取消,但程序还在后台吭哧吭哧地跑;要么是任务超时了,但资源还被占用着。直到我发现了CancellationToken,才真正解决了这个痛点。

简单来说,CancellationToken是一个轻量级的对象,专门用来在异步操作之间传递“取消”信号。它不是强制终止线程(那太粗暴了,容易导致数据不一致或资源泄漏),而是礼貌地通知任务:“嘿,可以停下来了,请做好收尾工作。” 这是一种协作式的取消机制,任务收到信号后,可以决定如何优雅地结束自己。

它的核心工作模式就像一个广播系统。CancellationTokenSource是广播站(信号源),负责创建和发出“取消”指令。而CancellationToken则是每个任务手里的收音机(令牌),用来监听这个指令。当你调用CancellationTokenSource.Cancel()时,所有通过关联的CancellationToken注册了监听的任务,都会收到这个“停止”通知。

为什么这种方式比直接终止线程要好?我举个例子。假设你有一个任务正在向数据库写入一批数据,写到一半时被强行终止,数据库可能就留下了一堆不完整的脏数据。而使用CancellationToken,任务可以在收到信号后,先把当前这组数据安全地提交,记录好断点,再释放数据库连接,最后从容退出。整个过程可控、安全。

2. 基础实战:从零开始使用CancellationToken

2.1 创建与触发:你的第一个取消操作

让我们从一个最简单的场景开始。假设我们有一个模拟长时间运行的任务,比如从网络下载文件。用户随时可能点击取消按钮。

using System; using System.Threading; using System.Threading.Tasks; class Program { static async Task Main(string[] args) { // 1. 创建“广播站” - CancellationTokenSource var cts = new CancellationTokenSource(); // 2. 获取“收音机” - CancellationToken CancellationToken token = cts.Token; Console.WriteLine("开始下载任务..."); // 3. 启动异步任务,并传入令牌 var downloadTask = DownloadFileAsync("bigfile.zip", token); // 模拟用户3秒后点击取消 await Task.Delay(3000); Console.WriteLine("\n用户点击了取消按钮!"); // 4. 发出取消信号 cts.Cancel(); try { // 等待任务完成(或被取消) await downloadTask; Console.WriteLine("下载完成!"); } catch (OperationCanceledException) { // 5. 处理取消异常 Console.WriteLine("下载已被用户取消。"); } finally { // 6. 释放资源(重要!) cts.Dispose(); } } static async Task DownloadFileAsync(string fileName, CancellationToken token) { const int totalSteps = 10; for (int i = 0; i < totalSteps; i++) { // 关键一步:检查是否收到取消请求 token.ThrowIfCancellationRequested(); // 模拟下载一个数据块 await Task.Delay(500, token); // 注意:这里也传入了token Console.WriteLine($"已下载 {((i + 1) * 10)}%"); } } }

运行这段代码,你会看到类似这样的输出:

开始下载任务... 已下载 10% 已下载 20% 已下载 30% 已下载 40% 已下载 50% 已下载 60% 用户点击了取消按钮! 下载已被用户取消。

这里有几个关键点我踩过坑,需要特别注意:

第一,ThrowIfCancellationRequested()这个方法名很直白:如果取消被请求了,就抛出异常。这是最常用的检查方式,它会抛出一个OperationCanceledException。注意,这不是一个错误,而是一个正常的控制流异常,表示任务是被预期取消的。

第二,注意Task.Delay(500, token)这行代码。很多异步方法(比如HttpClient.GetAsyncStream.ReadAsync)都支持直接传入CancellationToken。这样做的好处是,如果取消信号在等待期间到达,这些方法能立即响应,而不是傻等到时间结束。这是第一道防线。

第三,一定要用try-catch包裹await调用。因为取消是通过异常机制实现的,不捕获的话,异常会往上抛,可能导致程序崩溃。OperationCanceledException是专门为这种场景设计的异常类型。

第四,别忘了finally块里的cts.Dispose()CancellationTokenSource内部可能使用了定时器等资源,及时释放是好习惯。更推荐的做法是使用using语句,这样即使发生异常也能确保释放。

2.2 两种检查方式:ThrowIfCancellationRequested vs IsCancellationRequested

你可能注意到了,检查取消有两种方式。它们看起来相似,但使用场景完全不同。让我用实际例子说明它们的区别。

方式一:ThrowIfCancellationRequested()- 立即停止型

async Task ProcessDataImmediately(CancellationToken token) { // 在关键操作前检查 token.ThrowIfCancellationRequested(); await LoadConfigurationAsync(token); token.ThrowIfCancellationRequested(); var data = await FetchDataAsync(token); token.ThrowIfCancellationRequested(); await SaveToDatabaseAsync(data, token); // 如果上面任何一处检查到取消,方法会立即抛出异常退出 }

这种方式适合“要么全部完成,要么全部不做”的场景。比如银行转账,如果中途取消,我们宁愿整个操作回滚,也不希望只转了一半的钱。

方式二:IsCancellationRequested- 优雅退出型

async Task ProcessDataGracefully(CancellationToken token) { var results = new List<string>(); for (int i = 0; i < 100; i++) { // 检查但不抛出异常 if (token.IsCancellationRequested) { Console.WriteLine("检测到取消请求,正在保存已处理的数据..."); // 保存部分结果 await SavePartialResultsAsync(results); // 清理资源 await CleanupResourcesAsync(); Console.WriteLine("已安全退出。"); return; // 正常返回,不抛异常 } // 继续处理 var result = await ProcessItemAsync(i, token); results.Add(result); } await SaveAllResultsAsync(results); }

这种方式适合可以“部分完成”的场景。比如批量处理图片,用户取消时,我们已经处理好的图片应该保存,未处理的就停止。

那么,在实际项目中该如何选择呢?我的经验法则是:

  • 如果任务不能被部分完成(比如事务操作),用ThrowIfCancellationRequested()
  • 如果任务有中间状态需要保存,用IsCancellationRequested
  • 在循环中,如果每次迭代都很耗时,可以在循环内用IsCancellationRequested;如果迭代很快,用ThrowIfCancellationRequested更简洁
  • 对外公开的API,通常使用ThrowIfCancellationRequested,让调用者知道任务是被取消的

3. 进阶模式:超时控制与组合令牌

3.1 内置超时:让任务自动过期

在实际开发中,很多操作都需要超时控制。比如调用外部API,如果5秒没响应,就应该放弃等待,而不是无限期等下去。CancellationTokenSource原生支持超时设置,非常方便。

async Task CallExternalApiWithTimeout() { // 方法1:创建时直接指定超时时间(3秒) using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); try { var result = await httpClient.GetAsync("https://api.example.com/data", cts.Token); Console.WriteLine($"API调用成功: {await result.Content.ReadAsStringAsync()}"); } catch (OperationCanceledException) { Console.WriteLine("请求超时(3秒限制)"); } } async Task AnotherWayToSetTimeout() { // 方法2:先创建,后设置超时 using var cts = new CancellationTokenSource(); // 5秒后自动取消 cts.CancelAfter(TimeSpan.FromSeconds(5)); try { // 模拟一个耗时操作 await LongRunningOperationAsync(cts.Token); } catch (OperationCanceledException) { Console.WriteLine("操作超时(5秒限制)"); } }

CancelAfter方法有个很重要的特性:它是非阻塞的。调用后立即返回,不会卡住当前线程。时间到了会自动触发取消。这在UI程序中特别有用,不会让界面卡死。

我遇到过的一个实际场景是文件上传功能。用户上传大文件时,我们设置10分钟超时。如果网络太慢,10分钟还没传完,就自动取消,并提示用户“上传超时,请检查网络后重试”。用CancelAfter实现这个功能只需要一行代码。

3.2 组合令牌:应对复杂取消逻辑

有时候,一个任务可能需要响应多种取消信号。比如一个数据导出任务,既要响应用户的手动取消,也要在系统内存不足时自动取消,还要在超过10分钟时超时取消。这时候就需要组合令牌。

async Task ExportDataWithMultipleCancellationSources() { // 创建三个独立的取消源 using var userCancelSource = new CancellationTokenSource(); // 用户手动取消 using var memoryMonitorSource = new CancellationTokenSource(); // 内存监控取消 using var timeoutSource = new CancellationTokenSource(TimeSpan.FromMinutes(10)); // 10分钟超时 // 创建关联令牌:任意一个源取消,组合令牌就取消 using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource( userCancelSource.Token, memoryMonitorSource.Token, timeoutSource.Token ); // 模拟内存监控(当内存超过80%时取消) _ = Task.Run(async () => { while (!linkedSource.Token.IsCancellationRequested) { var memoryUsage = GetMemoryUsagePercentage(); if (memoryUsage > 80) { Console.WriteLine("内存使用超过80%,取消导出操作"); memoryMonitorSource.Cancel(); break; } await Task.Delay(1000); // 每秒检查一次 } }); try { // 使用组合令牌执行导出任务 await ExportLargeDatasetAsync(linkedSource.Token); Console.WriteLine("导出成功!"); } catch (OperationCanceledException ex) { // 判断是哪个原因导致的取消 if (timeoutSource.IsCancellationRequested) Console.WriteLine("导出超时(超过10分钟)"); else if (memoryMonitorSource.IsCancellationRequested) Console.WriteLine("因内存不足取消导出"); else if (userCancelSource.IsCancellationRequested) Console.WriteLine("用户取消了导出"); else Console.WriteLine("导出被取消(原因未知)"); } }

CreateLinkedTokenSource这个方法非常强大,它创建了一个“逻辑或”关系的组合令牌。只要传入的任意一个令牌被取消,返回的链接令牌就会立即被取消。这在分布式系统或微服务架构中特别有用,因为一个操作可能依赖多个服务,任何一个服务失败都应该取消整个操作。

我在一个电商系统中用过这个模式。用户下单后,我们需要同时:

  1. 扣减库存(库存服务)
  2. 创建订单(订单服务)
  3. 扣款(支付服务)

如果其中任何一步失败,其他步骤都应该取消。用链接令牌就能完美实现这个需求。

4. 高级技巧:注册回调与资源清理

4.1 注册取消回调:最后的清理机会

有些资源清理工作必须在取消发生时立即执行,不能等到方法结束。比如关闭文件句柄、断开数据库连接、删除临时文件等。这时候可以使用Register方法注册回调函数。

async Task ProcessWithCleanup(CancellationToken token) { // 创建一个临时文件 string tempFile = Path.GetTempFileName(); Console.WriteLine($"创建临时文件: {tempFile}"); try { // 注册取消回调:删除临时文件 using var registration = token.Register(() => { if (File.Exists(tempFile)) { Console.WriteLine("取消发生,正在删除临时文件..."); File.Delete(tempFile); Console.WriteLine("临时文件已删除"); } }); // 模拟处理过程 for (int i = 0; i < 10; i++) { token.ThrowIfCancellationRequested(); // 向临时文件写入数据 await File.AppendAllTextAsync(tempFile, $"Data block {i}\n", token); Console.WriteLine($"已处理 {i + 1}/10"); await Task.Delay(500, token); } Console.WriteLine("处理完成,保存最终文件..."); // 处理成功,将临时文件移动到正式位置 File.Move(tempFile, "final_result.txt", true); } catch (OperationCanceledException) { Console.WriteLine("操作被取消"); // 注意:回调已经自动执行过了,这里不需要再清理 throw; } finally { // 最终检查:如果临时文件还存在(比如正常完成时已移动),就删除 if (File.Exists(tempFile)) { File.Delete(tempFile); } } }

这里有几个重要细节:

第一,Register返回一个CancellationTokenRegistration对象,它实现了IDisposable。用using语句包裹可以确保在不需要时正确注销回调。虽然不注销通常也不会内存泄漏(因为CancellationTokenSource被释放时会清理所有回调),但显式注销是好习惯。

第二,回调的执行是同步的,并且在调用Cancel()的线程上执行。这意味着如果回调很耗时,会阻塞取消操作的完成。所以回调函数应该尽量简单快速。

第三,如果注册回调时令牌已经被取消,回调会立即执行。这个特性可以用来确保清理逻辑一定会执行。

第四,多个回调按注册顺序执行。如果你有依赖关系(比如先关闭数据库连接再删除临时文件),要注意注册顺序。

4.2 在异步迭代器中使用取消令牌

C# 8.0 引入了异步流(IAsyncEnumerable<T>),配合取消令牌可以创建可取消的数据流。

public static async IAsyncEnumerable<int> GenerateNumbersAsync( [EnumeratorCancellation] CancellationToken token = default) { for (int i = 0; i < 100; i++) { // 每次迭代前检查取消 token.ThrowIfCancellationRequested(); // 模拟一些工作 await Task.Delay(100, token); yield return i; } } // 消费异步流 async Task ConsumeAsyncStream() { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); await foreach (var number in GenerateNumbersAsync(cts.Token) .WithCancellation(cts.Token)) // 这里也需要指定令牌 { Console.WriteLine($"收到: {number}"); // 可以在消费过程中决定取消 if (number > 20) { Console.WriteLine("已收到足够数据,主动取消"); cts.Cancel(); break; } } }

注意[EnumeratorCancellation]这个属性,它允许我们在调用WithCancellation时传入新的取消令牌。这样设计很灵活,生产者和消费者可以使用不同的取消逻辑。

5. 实战场景:在Web API和客户端中的应用

5.1 ASP.NET Core Web API中的取消令牌

在ASP.NET Core中,控制器方法会自动接收到一个CancellationToken参数,它会在客户端断开连接时自动触发取消。这个特性对于防止资源浪费特别有用。

[ApiController] [Route("api/[controller]")] public class DataController : ControllerBase { private readonly IDataService _dataService; public DataController(IDataService dataService) { _dataService = dataService; } [HttpGet("large-report")] public async Task<IActionResult> GenerateLargeReport(CancellationToken token) { try { // token会自动在以下情况被取消: // 1. 客户端关闭连接 // 2. 请求超时(Kestrel默认超时时间) // 3. 服务器关闭 var report = await _dataService.GenerateReportAsync(token); return Ok(report); } catch (OperationCanceledException) { // 客户端取消了请求,这不是错误,是正常情况 // 返回499 Client Closed Request(虽然不是标准HTTP状态码,但Nginx等常用) return StatusCode(499, "请求已被客户端取消"); } catch (Exception ex) { // 其他真正的错误 return StatusCode(500, $"服务器错误: {ex.Message}"); } } } public class DataService : IDataService { public async Task<Report> GenerateReportAsync(CancellationToken token) { // 将令牌传递给所有底层操作 var data = await _database.FetchDataAsync(token); var processed = await ProcessDataAsync(data, token); var report = await FormatReportAsync(processed, token); return report; } private async Task<List<Data>> FetchDataAsync(CancellationToken token) { // 在数据库查询中传递令牌 // 支持CancellationToken的ORM(如EF Core)会利用它取消查询 return await _context.Data .Where(d => d.IsActive) .ToListAsync(token); } }

这里的关键是令牌传播。从控制器接收到的CancellationToken应该传递给所有异步操作。这样,只要客户端断开连接,整个调用链上的所有操作都会收到取消信号,立即停止工作,释放资源。

我做过一个性能测试:一个生成报表的接口,如果不传递取消令牌,即使用户在1秒后关闭页面,服务器仍然会继续生成完整的报表(可能耗时30秒)。传递令牌后,用户关闭页面时,所有数据库查询、计算都会立即停止。

5.2 在WinForms/WPF客户端中的应用

在桌面客户端中,CancellationToken通常与“取消”按钮配合使用。这里有个常见陷阱:UI线程和工作线程的交互。

public partial class MainForm : Form { private CancellationTokenSource _currentOperationCts; private async void btnStart_Click(object sender, EventArgs e) { // 禁用开始按钮,启用取消按钮 btnStart.Enabled = false; btnCancel.Enabled = true; progressBar1.Value = 0; // 重要:每次开始新操作时创建新的CancellationTokenSource // 不要复用旧的,否则可能取消错误的操作 _currentOperationCts = new CancellationTokenSource(); var token = _currentOperationCts.Token; try { // 启动长时间操作 await ProcessDataAsync(token); // 操作完成 MessageBox.Show("处理完成!", "成功", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (OperationCanceledException) { // 用户取消了操作,这不是错误 MessageBox.Show("操作已取消", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { // 真正的错误 MessageBox.Show($"处理失败: {ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { // 恢复UI状态 btnStart.Enabled = true; btnCancel.Enabled = false; _currentOperationCts?.Dispose(); _currentOperationCts = null; } } private void btnCancel_Click(object sender, EventArgs e) { // 触发取消 _currentOperationCts?.Cancel(); btnCancel.Enabled = false; } private async Task ProcessDataAsync(CancellationToken token) { for (int i = 0; i < 100; i++) { token.ThrowIfCancellationRequested(); // 模拟工作 await Task.Delay(50, token); // 更新UI进度(必须回到UI线程) if (InvokeRequired) { Invoke(new Action(() => { progressBar1.Value = i + 1; lblStatus.Text = $"处理中... {i + 1}%"; })); } else { progressBar1.Value = i + 1; lblStatus.Text = $"处理中... {i + 1}%"; } } } }

这个例子中有几个重要实践:

第一,_currentOperationCts是类级别的字段,这样取消按钮才能访问到它。但每次开始新操作时都要创建新的实例,不能复用。

第二,UI更新必须回到UI线程。WinForms中可以用Invoke,WPF中可以用Dispatcher.Invoke。注意检查InvokeRequired,避免在UI线程上再次调用Invoke导致的死锁。

第三,finally块中一定要恢复UI状态并释放资源。即使用户取消了操作,也要让界面回到可用的状态。

第四,错误处理要区分“取消”和“真正错误”。取消是正常流程,不应该作为错误提示给用户。

6. 性能优化与最佳实践

6.1 避免过度检查的性能损耗

在紧密循环中频繁检查IsCancellationRequested可能会有性能影响。虽然单次检查成本很低(就是读取一个布尔值),但在每秒数百万次的循环中,这个开销就不可忽视了。

// 不推荐的写法:每次迭代都检查 async Task ProcessMillionsOfItems(List<Item> items, CancellationToken token) { foreach (var item in items) // 假设有100万个item { token.ThrowIfCancellationRequested(); // 检查100万次! await ProcessItemAsync(item, token); } } // 推荐的写法:每N次迭代检查一次 async Task ProcessMillionsOfItemsOptimized(List<Item> items, CancellationToken token) { const int CheckInterval = 1000; // 每1000次检查一次 for (int i = 0; i < items.Count; i++) { // 每1000次迭代检查一次取消 if (i % CheckInterval == 0) { token.ThrowIfCancellationRequested(); } await ProcessItemAsync(items[i], token); } // 循环结束后再检查一次,确保没有遗漏 token.ThrowIfCancellationRequested(); }

这个优化技巧的平衡点是响应性和性能。如果CheckInterval设得太大,用户点击取消后可能要等很久才有反应。设得太小,又失去了优化意义。根据我的经验,对于CPU密集型循环,1000到10000是个不错的范围;对于IO密集型操作,因为本身就有await间隙,可以更频繁地检查。

6.2 处理不支持取消的遗留代码

现实项目中,我们经常需要调用一些不支持CancellationToken的老代码或第三方库。这时候可以用一些技巧来增加取消支持。

// 方法1:使用Task.Run包装(适合CPU密集型操作) async Task CallLegacyCodeWithCancellation(CancellationToken token) { // 将同步方法包装成Task,并传入token await Task.Run(() => LegacySynchronousMethod(), token); } // 方法2:使用TaskCompletionSource创建可取消的包装 async Task<int> CallThirdPartyApiWithTimeout(string url, CancellationToken token) { var tcs = new TaskCompletionSource<int>(); // 注册取消回调 using var registration = token.Register(() => { tcs.TrySetCanceled(token); }); // 调用不支持取消的第三方API _ = Task.Run(async () => { try { // 假设这是不支持取消的第三方方法 var result = await ThirdPartyLibrary.MakeRequestAsync(url); tcs.TrySetResult(result); } catch (Exception ex) { tcs.TrySetException(ex); } }); return await tcs.Task; } // 方法3:使用Polly等重试库的取消支持 async Task ResilientApiCall(string url, CancellationToken token) { var policy = Policy .Handle<HttpRequestException>() .Or<TimeoutException>() .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); // Polly策略天然支持CancellationToken return await policy.ExecuteAsync(async ct => { var response = await _httpClient.GetAsync(url, ct); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); }, token); }

6.3 诊断与调试技巧

当取消不按预期工作时,调试可能会很困难。这里分享几个我常用的诊断技巧:

// 技巧1:给CancellationTokenSource命名,方便调试 var cts = new CancellationTokenSource(); // 在调试器中可以看到这个名称 cts.GetType().GetField("m_name", BindingFlags.NonPublic | BindingFlags.Instance) ?.SetValue(cts, "UserExportOperation"); // 技巧2:注册调试回调 cts.Token.Register(() => { Debug.WriteLine($"令牌在 {DateTime.Now:HH:mm:ss.fff} 被取消"); // 可以在这里记录堆栈跟踪 Debug.WriteLine($"取消调用堆栈:\n{new StackTrace()}"); }); // 技巧3:使用调试代理包装令牌 public class DebuggableCancellationToken { private readonly CancellationToken _token; private readonly string _operationName; public DebuggableCancellationToken(CancellationToken token, string operationName) { _token = token; _operationName = operationName; } public bool IsCancellationRequested { get { if (_token.IsCancellationRequested) { Debug.WriteLine($"[{_operationName}] 检测到取消请求"); LogCancellationSource(); } return _token.IsCancellationRequested; } } public void ThrowIfCancellationRequested() { if (_token.IsCancellationRequested) { Debug.WriteLine($"[{_operationName}] 抛出取消异常"); LogCancellationSource(); _token.ThrowIfCancellationRequested(); } } private void LogCancellationSource() { // 可以在这里记录更多上下文信息 Debug.WriteLine($"操作 '{_operationName}' 被取消"); } } // 使用方式 var debugToken = new DebuggableCancellationToken(cts.Token, "数据导出"); await ExportDataAsync(debugToken);

7. 分布式系统中的取消令牌传递

在微服务架构中,一个用户请求可能经过多个服务。如果第一个服务取消了操作,后续服务也应该知道并停止工作。这就需要跨服务传递取消令牌。

7.1 通过HTTP头部传递取消信号

// 客户端:将取消信息放入HTTP头部 public class CancellationAwareHttpClient { private readonly HttpClient _httpClient; public async Task<string> GetWithCancellationAsync( string url, CancellationToken token, TimeSpan timeout) { using var timeoutCts = new CancellationTokenSource(timeout); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( token, timeoutCts.Token); var request = new HttpRequestMessage(HttpMethod.Get, url); // 将取消信息放入自定义头部 request.Headers.Add("X-Cancellation-Timeout", timeout.TotalMilliseconds.ToString()); // 可以添加请求ID用于跟踪 request.Headers.Add("X-Request-Id", Guid.NewGuid().ToString()); var response = await _httpClient.SendAsync(request, linkedCts.Token); return await response.Content.ReadAsStringAsync(linkedCts.Token); } } // 服务端:从头部读取取消信息 [ApiController] public class DistributedController : ControllerBase { [HttpGet("process")] public async Task<IActionResult> ProcessDistributed(CancellationToken token) { // 从头部获取超时时间(如果有) if (Request.Headers.TryGetValue("X-Cancellation-Timeout", out var timeoutStr)) { if (long.TryParse(timeoutStr, out var timeoutMs)) { // 创建带超时的链接令牌 using var timeoutCts = new CancellationTokenSource( TimeSpan.FromMilliseconds(timeoutMs)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( token, timeoutCts.Token); return await ProcessWithTimeoutAsync(linkedCts.Token); } } // 没有超时头部,使用原始令牌 return await ProcessAsync(token); } private async Task<IActionResult> ProcessWithTimeoutAsync(CancellationToken token) { // 调用其他服务时传递令牌 var result1 = await _serviceA.ProcessAsync(token); var result2 = await _serviceB.ProcessAsync(token); return Ok(new { result1, result2 }); } }

7.2 使用Polly实现弹性取消策略

public class ResilientServiceClient { private readonly IAsyncPolicy _retryPolicy; public ResilientServiceClient() { _retryPolicy = Policy .Handle<HttpRequestException>() .Or<TimeoutException>() .Or<OperationCanceledException>() // 注意:这里要区分是超时取消还是用户取消 .WaitAndRetryAsync( retryCount: 3, sleepDurationProvider: retryAttempt => { // 指数退避 return TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)); }, onRetry: (exception, timeSpan, retryCount, context) => { // 如果是取消异常,判断是否应该重试 if (exception is OperationCanceledException oce) { // 如果是超时导致的取消,可以重试 // 如果是用户主动取消,不应该重试 if (oce.CancellationToken == context.PolicyKey) { // 这是策略超时,可以重试 Log.Warning($"请求超时,第{retryCount}次重试..."); } else { // 这是用户取消,不应该重试 Log.Information("用户取消了请求,停止重试"); throw; // 重新抛出,停止重试 } } }); } public async Task<string> CallServiceWithResilienceAsync( string url, CancellationToken userToken) { // 创建策略超时(比如每个请求最多等5秒) var perRequestTimeout = TimeSpan.FromSeconds(5); return await _retryPolicy.ExecuteAsync(async (ct) => { using var requestCts = new CancellationTokenSource(perRequestTimeout); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( ct, requestCts.Token, userToken); var response = await _httpClient.GetAsync(url, linkedCts.Token); return await response.Content.ReadAsStringAsync(linkedCts.Token); }, userToken); // 将用户令牌作为策略上下文 } }

这个模式的关键是区分不同类型的取消:

  1. 用户主动取消:不应该重试,立即失败
  2. 网络超时取消:应该重试
  3. 策略超时取消:应该重试

通过OperationCanceledException中的CancellationToken可以判断取消的来源。

8. 常见陷阱与解决方案

8.1 陷阱一:忘记传递令牌

这是最常见的错误。你创建了CancellationTokenSource,但在调用深层方法时忘记传递令牌。

// 错误示例:令牌没有传递下去 async Task ProcessOrderAsync(CancellationToken token) { // 这里检查了令牌 token.ThrowIfCancellationRequested(); // 但是调用子方法时忘记传递了! var user = await _userService.GetUserAsync(order.UserId); // 这里应该传token var inventory = await _inventoryService.CheckAsync(order.ProductId); // 这里也应该传 // 结果:即使token被取消,这些子调用还会继续执行 } // 正确做法:始终传递令牌 async Task ProcessOrderAsync(CancellationToken token) { token.ThrowIfCancellationRequested(); // 将令牌传递给所有异步调用 var user = await _userService.GetUserAsync(order.UserId, token); var inventory = await _inventoryService.CheckAsync(order.ProductId, token); // 即使这里被取消,子调用也会收到信号 }

我的经验是:在方法签名中加上CancellationToken token = default参数,这样调用者可以选择是否传递。在方法内部,把这个令牌传递给所有支持取消的异步调用。

8.2 陷阱二:在finally块中执行长时间操作

// 危险示例:finally中的阻塞操作 async Task DangerousFinallyAsync(CancellationToken token) { try { await DoWorkAsync(token); } finally { // finally块中的代码在取消后仍然会执行 // 但如果这里也有异步操作,可能会被取消 await CleanupAsync(); // 如果token已取消,这里可能抛出异常! } } // 更好的做法:使用单独的清理令牌 async Task SafeFinallyAsync(CancellationToken token) { // 创建链接令牌,但给清理操作更多时间 using var cleanupCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( token, cleanupCts.Token); try { await DoWorkAsync(linkedCts.Token); } finally { // 清理操作使用独立的超时,而不是用户令牌 try { await CleanupAsync(cleanupCts.Token); } catch (OperationCanceledException) { // 清理操作超时了,记录日志但不要抛出 Log.Warning("清理操作超时,但已尽力而为"); } } }

8.3 陷阱三:没有处理AggregateException中的取消

当使用Task.WhenAll等待多个任务时,如果多个任务都取消了,会抛出AggregateException,里面包含多个OperationCanceledException

// 错误处理方式 try { await Task.WhenAll(task1, task2, task3); } catch (OperationCanceledException) // 这里抓不到AggregateException! { Console.WriteLine("任务被取消"); } // 正确处理方式 try { await Task.WhenAll(task1, task2, task3); } catch (OperationCanceledException) { Console.WriteLine("单个任务被取消"); } catch (AggregateException agEx) { // 检查是否所有内部异常都是取消异常 if (agEx.InnerExceptions.All(ex => ex is OperationCanceledException)) { Console.WriteLine("所有任务都被取消"); } else { // 有其他类型的异常 throw; } }

在实际项目中,我通常会写一个辅助方法来统一处理这种复杂情况:

public static async Task WhenAllWithCancellationHandling( IEnumerable<Task> tasks, CancellationToken token) { var taskList = tasks.ToList(); try { await Task.WhenAll(taskList).WaitAsync(token); } catch (OperationCanceledException) when (token.IsCancellationRequested) { // 外部令牌取消:等待所有任务完成或取消 await Task.WhenAll(taskList.Select(t => t.ContinueWith(_ => { }, TaskContinuationOptions.ExecuteSynchronously))); // 检查任务状态 var canceledCount = taskList.Count(t => t.IsCanceled); var faultedCount = taskList.Count(t => t.IsFaulted); var completedCount = taskList.Count(t => t.IsCompletedSuccessfully); Log.Information($"取消统计: {canceledCount}取消, {faultedCount}失败, {completedCount}完成"); throw; } }

掌握CancellationToken需要一些实践,但一旦习惯,你会发现它能让你的异步代码更加健壮和可控。关键是要记住:取消是协作式的,不是命令式的。你的代码需要主动检查取消信号,并做出恰当的响应。从简单的单任务取消,到复杂的分布式取消传播,CancellationToken提供了一套统一的模式来处理各种取消场景。在实际项目中,我建议从简单的超时控制开始,逐步应用到更复杂的场景,最终形成一套完整的取消策略。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/18 1:05:33

SiameseAOE中文-base一文详解:Prompt工程在中文ABSA任务中的设计范式

SiameseAOE中文-base一文详解&#xff1a;Prompt工程在中文ABSA任务中的设计范式 1. 引言&#xff1a;当AI学会“阅读理解”与“情感分析” 想象一下&#xff0c;你是一家电商公司的运营&#xff0c;每天要面对成千上万条用户评论。你想知道用户对“手机电池”这个属性的评价…

作者头像 李华
网站建设 2026/8/17 1:04:35

大专教师结构化面试8大高频考点解析(附浙江某校真题拆解)

大专教师结构化面试&#xff1a;从真题内核到高分应答的深度实战指南 又到了一年高校招聘季&#xff0c;对于志在踏入高职院校的准教师们来说&#xff0c;那道名为“结构化面试”的关卡&#xff0c;常常让人既感压力又觉迷茫。市面上充斥着大量通用面试技巧&#xff0c;但当你真…

作者头像 李华
网站建设 2026/8/18 9:05:41

如何通过负载电容调整无源晶振频偏?一个实例带你搞懂ppm计算

如何通过负载电容调整无源晶振频偏&#xff1f;一个实例带你搞懂ppm计算 时钟信号就像电子系统的心跳&#xff0c;每一次精准的跳动都关乎着数据通信的可靠、测量结果的准确。对于硬件工程师&#xff0c;尤其是深耕通信设备、精密仪器、高端消费电子等领域的开发者而言&#xf…

作者头像 李华
网站建设 2026/8/18 9:35:56

深入解析Spotify WebApi中的Token机制与应用实践

1. 从零开始&#xff1a;理解Spotify Web API的两种关键令牌 如果你和我一样&#xff0c;是个喜欢折腾的音乐爱好者&#xff0c;想把Spotify的丰富数据整合到自己的小项目里&#xff0c;比如做个个性化的播放列表分析工具&#xff0c;或者一个音乐推荐机器人&#xff0c;那你肯…

作者头像 李华
网站建设 2026/8/18 9:27:49

MOCREAK:一键高效部署Microsoft Office的全方位解决方案

1. 为什么你需要MOCREAK&#xff1f;一个老用户的真心话 说实话&#xff0c;我折腾Office安装的年头&#xff0c;可能比很多朋友用电脑的时间都长。从早些年满世界找光盘镜像&#xff0c;到后来在官网下载器前苦等&#xff0c;再到尝试各种第三方部署工具&#xff0c;踩过的坑数…

作者头像 李华
网站建设 2026/8/18 9:38:42

Qwen3-VL-30B实战:用本地AI自动分析财报图表,效率翻倍

Qwen3-VL-30B实战&#xff1a;用本地AI自动分析财报图表&#xff0c;效率翻倍 你是不是也经历过这样的场景&#xff1f;面对一份几十页的PDF财报&#xff0c;里面塞满了各种柱状图、折线图、饼图&#xff0c;老板让你“快速分析一下关键趋势”。你只能一张张截图&#xff0c;手…

作者头像 李华