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.GetAsync、Stream.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这个方法非常强大,它创建了一个“逻辑或”关系的组合令牌。只要传入的任意一个令牌被取消,返回的链接令牌就会立即被取消。这在分布式系统或微服务架构中特别有用,因为一个操作可能依赖多个服务,任何一个服务失败都应该取消整个操作。
我在一个电商系统中用过这个模式。用户下单后,我们需要同时:
- 扣减库存(库存服务)
- 创建订单(订单服务)
- 扣款(支付服务)
如果其中任何一步失败,其他步骤都应该取消。用链接令牌就能完美实现这个需求。
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); // 将用户令牌作为策略上下文 } }这个模式的关键是区分不同类型的取消:
- 用户主动取消:不应该重试,立即失败
- 网络超时取消:应该重试
- 策略超时取消:应该重试
通过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提供了一套统一的模式来处理各种取消场景。在实际项目中,我建议从简单的超时控制开始,逐步应用到更复杂的场景,最终形成一套完整的取消策略。