这里有很多好的答案,但我仍然想发表我的咆哮,因为我刚刚遇到了同样的问题并进行了一些研究。或者跳到下面的 TLDR 版本。
问题
等待task
返回的 byTask.WhenAll
只会引发AggregateException
存储的第一个异常task.Exception
,即使多个任务出现故障也是如此。
当前的文档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
. DivideByZeroException
如果我们不直接通过,我们可能无法观察到task.Exception.InnerExceptions
。
Microsoft 的Stephen Toub在相关的 GitHub 问题中解释了这种行为背后的原因:
我试图说明的一点是,几年前,当这些最初被添加时,它已经被深入讨论过。我们最初按照您的建议做了,从 WhenAll 返回的 Task 包含一个包含所有异常的 AggregateException ,即 task.Exception 将返回一个 AggregateException 包装器,其中包含另一个 AggregateException 然后包含实际异常;然后当它被等待时,内部的 AggregateException 将被传播。我们收到的强烈反馈导致我们改变了设计:a) 绝大多数此类案例都有相当同质的异常,因此在聚合中传播所有内容并不那么重要,b) 传播聚合然后打破对捕获的预期对于特定的异常类型,c)对于有人确实想要聚合的情况,他们可以像我写的那样用两行明确地这样做。我们还就包含多个异常的任务的 await sould 行为进行了广泛的讨论,这就是我们着陆的地方。
另一件需要注意的重要事情是,这种展开行为很浅。即,它只会解开第一个异常AggregateException.InnerExceptions
并将其留在那里,即使它恰好是 another 的实例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
唯一用于检查其Task.Exception
;
- 正确传播取消状态(
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}");
}