处理来自异步并行任务的多个异常

9

问题

有多个任务在并行运行,所有任务都可能抛出异常,也可能没有任何一个任务抛出异常。当所有任务完成后,必须报告所有可能发生的异常(通过日志、电子邮件、控制台输出等方式)。

期望行为

我可以使用异步 lambda 在 linq 中构建所有任务,然后使用 Task.WhenAll(tasks) 并行运行它们。然后我可以捕获 AggregateException 并报告每个单独的内部异常。

实际行为

会抛出一个 AggregateException,但它只包含一个内部异常,无论抛出了多少个单独的异常。

最小完整可验证示例

static void Main(string[] args)
{
    try
    {
        ThrowSeveralExceptionsAsync(5).Wait();
    }
    catch (AggregateException ex)
    {
        ex.Handle(innerEx =>
        {
            Console.WriteLine($"\"{innerEx.Message}\" was thrown");
            return true;
        });
    }

    Console.ReadLine();
}

private static async Task ThrowSeveralExceptionsAsync(int nExceptions)
{
    var tasks = Enumerable.Range(0, nExceptions)
        .Select(async n =>
        {
            await ThrowAsync(new Exception($"Exception #{n}"));
        });

    await Task.WhenAll(tasks);
}

private static async Task ThrowAsync(Exception ex)
{
    await Task.Run(() => {
        Console.WriteLine($"I am going to throw \"{ex.Message}\"");
        throw ex;
    });
}

输出结果

请注意,“我要扔”消息的输出顺序可能会因竞争条件而改变。

I am going to throw "Exception #0"
I am going to throw "Exception #1"
I am going to throw "Exception #2"
I am going to throw "Exception #3"
I am going to throw "Exception #4"
"Exception #0" was thrown

2
有点相关的是,你可以使你的入口点成为异步的,只需添加static async Task Main - Brad M
@BradM 如果你这样做,它会完全崩溃(可能是由于async Main内部的工作方式)。 - Camilo Terevinto
有趣,我之前不知道这些。虽然这会是另一个问题的内容。 - Daniel García Rubio
1个回答

14

那是因为await“解开”聚合异常,并且始终仅抛出第一个异常(正如await的文档中所述),即使您等待Task.WhenAll也可能导致多个错误。 您可以像这样访问聚合异常:

var whenAll = Task.WhenAll(tasks);
try {
    await whenAll;
}
catch  {
    // this is `AggregateException`
    throw whenAll.Exception;
}

或者你可以遍历任务并检查每个任务的状态和异常情况。

注意,在修复后,您还需要进行另一项操作:

try {
    ThrowSeveralExceptionsAsync(5).Wait();
}
catch (AggregateException ex) {
    // flatten, unwrapping all inner aggregate exceptions
    ex.Flatten().Handle(innerEx => {
        Console.WriteLine($"\"{innerEx.Message}\" was thrown");
        return true;
    });
}

由于由ThrowSeveralExceptionsAsync返回的任务包含我们抛出的AggregateException,因此我们将其包装在另一个AggregateException中。


在原始的AggregateException上调用Handle()和在“展平”的异常上调用它有什么区别? - cosh
3
如果你不进行扁平化处理,那么在这种情况下Handle方法中的 innerEx 将会是另一个AggregateException(来自于Task.WhenAll),它有点没用。扁平化会展开所有内部聚合异常。 - Evk
@Evk 谢谢,这解决了问题。你知道这个是否有文档记录吗?对我来说真的很不直观,每当我期望这种行为时都必须实现这个 try-catch-throw 模式感觉很奇怪。我想知道这个设计背后的原因(我相信它们是好的原因)。 - Daniel García Rubio
1
@DanielGarcíaRubio 是的,await 关键字的文档中已经详细描述了它的异常处理(参见“Exceptions”章节):https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/await。这里有一个微软员工解释为什么设计 await 的原因:https://social.msdn.microsoft.com/Forums/en-US/e439770e-6c27-40d9-91af-c15d26743a5f/whenall-and-exception?forum=async。如果你经常使用 Task.WhenAll,可以创建扩展方法并在其中提取重复逻辑。 - Evk
在第一个代码块中,catch { throw whenAll.Exception; } 可能会抛出 NullReferenceException,因为对于已取消的任务,Exception 为空。更安全的做法是 catch { whenAll.Wait(); } - Theodor Zoulias
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接