Wiele dobrych odpowiedzi tutaj, ale nadal chciałbym opublikować mój rant, ponieważ właśnie natknąłem się na ten sam problem i przeprowadziłem badania. Lub przejdź do wersji TLDR poniżej.
Problem
Oczekiwanie na task
zwrócone przez Task.WhenAll
tylko zgłasza pierwszy wyjątek AggregateException
przechowywanego w task.Exception
, nawet jeśli wystąpił błąd wielu zadań.
Te obecne docs dlaTask.WhenAll
słownie:
Jeśli którekolwiek z dostarczonych zadań zakończy się w stanie błędu, zwrócone zadanie również zakończy się w stanie Niepowodzenie, w którym jego wyjątki będą zawierać agregację zestawu nieopakowanych wyjątków z każdego z podanych zadań.
Co jest poprawne, ale nie mówi nic o wspomnianym wcześniej zachowaniu „rozpakowywania”, kiedy oczekuje się zwróconego zadania.
Przypuszczam, że doktorzy nie wspominają o tym, ponieważ to zachowanie nie jest specyficzne dlaTask.WhenAll
.
Jest to po prostu Task.Exception
typ AggregateException
i dla await
kontynuacji zawsze zostaje rozpakowane jako pierwszy wewnętrzny wyjątek, zgodnie z projektem. Jest to świetne w większości przypadków, ponieważ zwykle Task.Exception
składa się tylko z jednego wewnętrznego wyjątku. Ale rozważ ten kod:
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)
{
Assert.IsTrue(task.Exception.InnerExceptions.Count == 2);
Assert.IsInstanceOfType(task.Exception.InnerExceptions[0], typeof(InvalidOperationException));
Assert.IsInstanceOfType(task.Exception.InnerExceptions[1], typeof(DivideByZeroException));
Assert.IsInstanceOfType(exception, typeof(InvalidOperationException));
Assert.AreSame(exception, task.Exception.InnerExceptions[0]);
}
W tym przypadku wystąpienie polecenia AggregateException
zostaje rozpakowane do pierwszego wewnętrznego wyjątku InvalidOperationException
w dokładnie taki sam sposób, w jaki moglibyśmy go mieć Task.WhenAll
. Moglibyśmy nie obserwować DivideByZeroException
, gdybyśmy nie przeszli task.Exception.InnerExceptions
bezpośrednio.
Stephen Toub z Microsoftu wyjaśnia przyczynę tego zachowania w powiązanym problemie z GitHub :
Chodzi mi o to, że zostało to szczegółowo omówione lata temu, kiedy zostały one pierwotnie dodane. Pierwotnie zrobiliśmy to, co sugerujesz, z Task zwróconym z WhenAll zawierającym pojedynczy AggregateException, który zawierał wszystkie wyjątki, tj. Task.Exception zwrócił opakowanie AggregateException, które zawierało inny AggregateException, który następnie zawierał rzeczywiste wyjątki; wtedy, gdy był oczekiwany, zostanie propagowany wewnętrzny wyjątek AggregateException. Silna informacja zwrotna, którą otrzymaliśmy, która skłoniła nas do zmiany projektu, była taka, że a) zdecydowana większość takich przypadków miała dość jednorodne wyjątki, takie że propagowanie wszystkiego w sumie nie było tak ważne, b) propagowanie agregatu, a następnie przełamanie oczekiwań dotyczących połowów dla określonych typów wyjątków, oraz c) w przypadkach, w których ktoś chciał zagregować, mógł to zrobić wyraźnie z dwoma wierszami, tak jak napisałem. Przeprowadziliśmy również obszerne dyskusje na temat tego, jakie powinno być zachowanie await w odniesieniu do zadań zawierających wiele wyjątków i na tym wylądowaliśmy.
Jeszcze jedna ważna rzecz, na którą należy zwrócić uwagę, to zachowanie podczas rozpakowywania jest płytkie. Oznacza to, że tylko rozpakuje pierwszy wyjątek AggregateException.InnerExceptions
i pozostawi go tam, nawet jeśli jest to wystąpienie innego AggregateException
. Może to dodać kolejną warstwę zamieszania. Na przykład zmieńmy w WhenAllWrong
ten sposób:
async Task WhenAllWrong()
{
await Task.FromException(new AggregateException(
new InvalidOperationException(),
new DivideByZeroException()));
}
var task = WhenAllWrong();
try
{
await task;
}
catch (Exception exception)
{
Assert.IsTrue(task.Exception.InnerExceptions.Count == 1);
Assert.IsInstanceOfType(task.Exception.InnerExceptions[0], typeof(AggregateException));
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));
}
Rozwiązanie (TLDR)
Wracając do await Task.WhenAll(...)
tego, czego osobiście chciałem, to móc:
- Uzyskaj pojedynczy wyjątek, jeśli tylko jeden został zgłoszony;
- Uzyskaj,
AggregateException
jeśli więcej niż jeden wyjątek został zgłoszony łącznie przez jedno lub więcej zadań;
- Unikaj konieczności zapisywania
Task
jedynego w celu sprawdzenia jego Task.Exception
;
- Propagować status anulowania prawidłowo (
Task.IsCanceled
), a coś takiego nie zrobi, że: Task t = Task.WhenAll(...); try { await t; } catch { throw t.Exception; }
.
Przygotowałem do tego następujące rozszerzenie:
public static class TaskExt
{
public static Task WithAggregatedExceptions(this Task @this)
{
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();
}
}
Teraz działa tak, jak chcę:
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");
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}");
}
AggregateException
. Gdybyś użyłTask.Wait
zamiastawait
w swoim przykładzie, złapałbyśAggregateException