如何处理Async/Await与取消逻辑:任务失败即终止所有运行任务
问题与解决方案
架构说明
网站 -> 调用 -> 代理API -> 调用 -> 外部API
需求
从网站向代理API发起最多25次调用,需实现:
- 获取所有调用结果(当前使用
Task.WhenAll); - 任一调用失败时,立即捕获异常并取消所有其他任务。
用户问题
- 计划改用自定义
ForEachAsync模式,此改动能否实现上述目标? - 尝试用
CancellationTokenSource取消任务,但最终仅捕获到TaskCanceledException而非原始异常,这是否正常?如何获取原始异常?
现有代码
网站代码
async Task ProcessApiCallsAsync( ApiInfo[] apiInfos, CancellationToken token ) { var apiTasks = apiInfos .Select( apiInfo => { // 简化代码:返回Task<T>函数调用,不使用await return GetWebApiResultAsync( apiInfo, token ); } ); var xmlResults = await Task.WhenAll( apiTasks ); foreach( var result in xmlResults ) { // 处理结果 } } async Task<XElement> GetWebApiResultAsync( ApiInfo info, CancellationToken ct = default ) { // 简化逻辑:调用SendAsync,前后处理信息后返回XElement using var response = await httpClient.SendAsync( request, cancellationToken: ct ); return new XElement( "ResultsGeneratedFromRESTCall" ); }
代理API代码
app.MapGet("/call-external", async (CancellationToken token) => { // 准备调用外部API的代码(逻辑与上方类似) using var externalResponse = await httpClient.SendAsync( httpRequest, token ); externalResponse.EnsureSuccessStatusCode(); var externalResult = ( await externalResponse.Content.ReadFromJsonAsync<JsonObject>() )!; if ( externalResult[ "CustomCheck" ] != null ) { throw new ApplicationException( "Custom failure from external source" ); } // 生成结果(可能需要额外调用外部API) });
改用ForEachAsync的网站代码
async Task ProcessApiCallsAsync( ApiInfo[] apiInfos, CancellationToken token ) { var xmlResults = await apiInfos.ForEachAsync( new ParallelOptions { MaxDegreeOfParallelism = apiInfos.Length, CancellationToken = token }, async ( apiInfo, ct ) => { // 使用await返回结果 return await GetWebApiResultAsync( apiInfo, ct ); } ); // 剩余代码不变 }
更新:使用CancellationTokenSource取消任务
async Task ProcessApiCallsAsync( ApiInfo[] apiInfos, CancellationToken token ) { using var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource( token ); var xmlResults = await apiInfos.ForEachAsync( new ParallelOptions { MaxDegreeOfParallelism = apiInfos.Length, CancellationToken = cancelTokenSource.Token }, async ( apiInfo, ct ) => { // 使用await返回结果 try { if ( simulatedException ) { await Task.Delay( 1500 ); throw new ApplicationException( "Emails endpoint is not supported" ); } return await GetWebApiResultAsync( apiInfo, ct ); } catch { cancelTokenSource.Cancel(); throw; } } ); // 剩余代码不变 }
解决方案
1. 自定义ForEachAsync能否实现目标?
取决于你的ForEachAsync具体实现,只要满足以下条件即可实现需求:
- 支持并行执行任务:通过
ParallelOptions.MaxDegreeOfParallelism设置为数组长度,确保所有API调用同时发起; - 支持异常触发全局取消:在任一任务抛出异常时,立即调用
CancellationTokenSource.Cancel(),让其他任务响应取消令牌终止; - 支持结果/异常优先级处理:全部成功时返回所有结果,若有异常则优先抛出原始异常而非取消异常。
如果使用.NET 6+内置的Parallel.ForEachAsync,默认不会自动取消其他任务,需要手动在异常时触发取消,配合自定义异常捕获逻辑就能实现需求。
2. 捕获到TaskCanceledException是否正常?如何获取原始异常?
这种现象是正常的。原因是:调用cancelTokenSource.Cancel()后,所有关联该令牌的任务会立即触发取消并抛出TaskCanceledException,而你在任务内部抛出的原始异常会被后续的取消异常覆盖(ForEachAsync检测到令牌取消时,会优先抛出取消相关的异常)。
要获取原始异常,可通过提前捕获并保存第一个异常的方式处理,修改代码如下:
async Task ProcessApiCallsAsync( ApiInfo[] apiInfos, CancellationToken token ) { using var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource( token ); Exception? firstException = null; var xmlResults = new List<XElement>(); await apiInfos.ForEachAsync( new ParallelOptions { MaxDegreeOfParallelism = apiInfos.Length, CancellationToken = cancelTokenSource.Token }, async ( apiInfo, ct ) => { try { if ( simulatedException ) { await Task.Delay( 1500 ); throw new ApplicationException( "Emails endpoint is not supported" ); } var result = await GetWebApiResultAsync( apiInfo, ct ); // 加锁保证线程安全 lock(xmlResults) xmlResults.Add(result); } catch (Exception ex) when (firstException == null) { // 仅捕获第一个异常 firstException = ex; // 触发全局取消 cancelTokenSource.Cancel(); // 此处不重新抛出,避免被取消异常覆盖 } } ); // 若存在原始异常,直接抛出 if (firstException != null) throw firstException; // 处理收集到的结果 foreach(var result in xmlResults) { // ... } }
核心逻辑:
- 用变量
firstException保存第一个抛出的原始异常; - 触发取消后不在任务内部抛出异常,避免被取消异常干扰;
ForEachAsync执行完成后,检查并抛出保存的原始异常。
内容的提问来源于stack exchange,提问作者Terry
相关产品推荐
相关产品推荐

