You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何处理Async/Await与取消逻辑:任务失败即终止所有运行任务

问题与解决方案

架构说明

网站 -> 调用 -> 代理API -> 调用 -> 外部API

需求

从网站向代理API发起最多25次调用,需实现:

  1. 获取所有调用结果(当前使用Task.WhenAll);
  2. 任一调用失败时,立即捕获异常并取消所有其他任务。

用户问题

  1. 计划改用自定义ForEachAsync模式,此改动能否实现上述目标?
  2. 尝试用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.18 14:23:09