这里有很多很好的答案,但我仍然想发表我的咆哮,因为我刚刚遇到了同样的问题并进行了一些研究。或跳至下方the TLDR version。
问题
等待Task.WhenAll 返回的task 只会引发存储在task.Exception 中的AggregateException 的第一个异常,即使多个任务出现故障也是如此。
current docs for Task.WhenAll 说:
如果提供的任何任务在故障状态下完成,则
返回的任务也将在故障状态下完成,其中它的
异常将包含未包装集合的聚合
每个提供的任务都有例外。
这是正确的,但它没有说明上述等待返回任务时的“展开”行为。
我想,文档没有提及它因为该行为并非特定于 Task.WhenAll。
很简单,Task.Exception 是 AggregateException 类型,对于 await 延续,它总是被解包为它的第一个内部异常,这是设计的。这对大多数情况都很好,因为通常Task.Exception 只包含一个内部异常。但是考虑一下这段代码:
Task WhenAllWrong()
{
var tcs = new TaskCompletionSource<DBNull>();
tcs.TrySetException(new Exception[]
{
new InvalidOperationException(),
new DivideByZeroException()
});
return tcs.Task;
}
var task = WhenAllWrong();
try
{
await task;
}
catch (Exception exception)
{
// task.Exception is an AggregateException with 2 inner exception
Assert.IsTrue(task.Exception.InnerExceptions.Count == 2);
Assert.IsInstanceOfType(task.Exception.InnerExceptions[0], typeof(InvalidOperationException));
Assert.IsInstanceOfType(task.Exception.InnerExceptions[1], typeof(DivideByZeroException));
// However, the exception that we caught here is
// the first exception from the above InnerExceptions list:
Assert.IsInstanceOfType(exception, typeof(InvalidOperationException));
Assert.AreSame(exception, task.Exception.InnerExceptions[0]);
}
这里,AggregateException 的一个实例被解包到它的第一个内部异常InvalidOperationException,这与我们在Task.WhenAll 中可能遇到的方式完全相同。如果我们不直接通过task.Exception.InnerExceptions,我们可能无法观察到DivideByZeroException。
Microsoft 的 Stephen Toub 在 the related GitHub issue 中解释了这种行为背后的原因:
我想说的是,它被深入讨论过,
几年前,当这些最初被添加时。我们最初做了什么
你的建议是,从 WhenAll 返回的任务包含一个
包含所有异常的单个 AggregateException,即
task.Exception 将返回一个 AggregateException 包装器,它
包含另一个 AggregateException 然后包含实际的
例外;然后当它被等待时,内部的 AggregateException
会被传播。我们收到的强烈反馈使我们
改变设计是 a) 绝大多数此类案例都有
相当同质的例外,例如在一个
聚合并不那么重要,b)然后传播聚合
打破对特定异常类型捕获的预期,
c) 对于有人确实想要聚合的情况,他们可以这样做
像我写的那样明确地用两行。我们也有广泛的
关于 await 的行为的讨论
包含多个异常的任务,这就是我们着陆的地方。
需要注意的另一件重要事情是,这种展开行为是肤浅的。即,它只会从AggregateException.InnerExceptions 中解开第一个异常并将其留在那里,即使它恰好是另一个AggregateException 的实例。这可能会增加另一层混乱。例如,让我们像这样更改WhenAllWrong:
async Task WhenAllWrong()
{
await Task.FromException(new AggregateException(
new InvalidOperationException(),
new DivideByZeroException()));
}
var task = WhenAllWrong();
try
{
await task;
}
catch (Exception exception)
{
// now, task.Exception is an AggregateException with 1 inner exception,
// which is itself an instance of AggregateException
Assert.IsTrue(task.Exception.InnerExceptions.Count == 1);
Assert.IsInstanceOfType(task.Exception.InnerExceptions[0], typeof(AggregateException));
// And now the exception that we caught here is that inner AggregateException,
// which is also the same object we have thrown from WhenAllWrong:
var aggregate = exception as AggregateException;
Assert.IsNotNull(aggregate);
Assert.AreSame(exception, task.Exception.InnerExceptions[0]);
Assert.IsInstanceOfType(aggregate.InnerExceptions[0], typeof(InvalidOperationException));
Assert.IsInstanceOfType(aggregate.InnerExceptions[1], typeof(DivideByZeroException));
}
解决方案 (TLDR)
所以,回到await Task.WhenAll(...),我个人想要的是能够:
- 如果只抛出一个异常,则获取一个异常;
- 如果一个或多个任务共同引发了多个异常,则获取
AggregateException;
- 避免只为了检查
Task.Exception而保存Task;
- 正确传播取消状态 (
Task.IsCanceled),因为这样的事情不会这样做:Task t = Task.WhenAll(...); try { await t; } catch { throw t.Exception; }。
为此,我整理了以下扩展:
public static class TaskExt
{
/// <summary>
/// A workaround for getting all of AggregateException.InnerExceptions with try/await/catch
/// </summary>
public static Task WithAggregatedExceptions(this Task @this)
{
// using AggregateException.Flatten as a bonus
return @this.ContinueWith(
continuationFunction: anteTask =>
anteTask.IsFaulted &&
anteTask.Exception is AggregateException ex &&
(ex.InnerExceptions.Count > 1 || ex.InnerException is AggregateException) ?
Task.FromException(ex.Flatten()) : anteTask,
cancellationToken: CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
scheduler: TaskScheduler.Default).Unwrap();
}
}
现在,以下内容按我想要的方式工作:
try
{
await Task.WhenAll(
Task.FromException(new InvalidOperationException()),
Task.FromException(new DivideByZeroException()))
.WithAggregatedExceptions();
}
catch (OperationCanceledException)
{
Trace.WriteLine("Canceled");
}
catch (AggregateException exception)
{
Trace.WriteLine("2 or more exceptions");
// Now the exception that we caught here is an AggregateException,
// with two inner exceptions:
var aggregate = exception as AggregateException;
Assert.IsNotNull(aggregate);
Assert.IsInstanceOfType(aggregate.InnerExceptions[0], typeof(InvalidOperationException));
Assert.IsInstanceOfType(aggregate.InnerExceptions[1], typeof(DivideByZeroException));
}
catch (Exception exception)
{
Trace.WriteLine($"Just a single exception: ${exception.Message}");
}