Molte buone risposte qui, ma vorrei comunque pubblicare il mio sproloquio poiché ho appena incontrato lo stesso problema e condotto alcune ricerche. Oppure passa alla versione TLDR di seguito.
Il problema
Attendere il task
restituito da Task.WhenAll
genera solo la prima eccezione del AggregateException
contenuto task.Exception
, anche quando più attività hanno avuto un errore.
I documenti attuali perTask.WhenAll
dire:
Se una delle attività fornite viene completata in uno stato di errore, l'attività restituita verrà completata anche in uno stato di errore, dove le sue eccezioni conterranno l'aggregazione del set di eccezioni non incluse in ciascuna delle attività fornite.
Il che è corretto, ma non dice nulla sul comportamento di "scartare" di cui sopra quando si attende l'attività restituita.
Suppongo che i documenti non lo menzionino perché quel comportamento non è specifico perTask.WhenAll
.
È semplicemente che Task.Exception
è di tipo AggregateException
e per le await
continuazioni viene sempre scartato come prima eccezione interna, per design. Questo è ottimo per la maggior parte dei casi, perché di solito Task.Exception
consiste in una sola eccezione interna. Ma considera questo codice:
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]);
}
Qui, un'istanza di AggregateException
viene scartata alla sua prima eccezione interna InvalidOperationException
esattamente nello stesso modo in cui avremmo potuto averla con Task.WhenAll
. Avremmo potuto non osservare DivideByZeroException
se non fossimo passati task.Exception.InnerExceptions
direttamente.
Stephen Toub di Microsoft spiega il motivo di questo comportamento nel relativo problema di GitHub :
Il punto che stavo cercando di sottolineare è che è stato discusso in profondità, anni fa, quando questi furono aggiunti originariamente. Inizialmente abbiamo fatto ciò che suggerisci, con l'attività restituita da WhenAll contenente una singola AggregateException che conteneva tutte le eccezioni, ovvero task.Exception restituiva un wrapper AggregateException che conteneva un'altra AggregateException che conteneva le eccezioni effettive; quindi, quando era atteso, veniva propagata l'eccezione AggregateException interna. Il forte feedback che abbiamo ricevuto che ci ha portato a modificare il design è stato che a) la stragrande maggioranza di questi casi presentava eccezioni abbastanza omogenee, in modo tale che propagare tutto in un aggregato non era così importante, b) propagare l'aggregato ha poi infranto le aspettative sulle catture per i tipi di eccezione specifici, e c) per i casi in cui qualcuno desiderava l'aggregato, poteva farlo esplicitamente con le due righe come ho scritto. Abbiamo anche avuto discussioni approfondite su quale potrebbe essere il comportamento di await so per quanto riguarda le attività contenenti più eccezioni, ed è qui che siamo atterrati.
Un'altra cosa importante da notare, questo comportamento di scartare è superficiale. Cioè, scarterà solo la prima eccezione AggregateException.InnerExceptions
e la lascerà lì, anche se si tratta di un'istanza di un'altra AggregateException
. Ciò potrebbe aggiungere un ulteriore livello di confusione. Ad esempio, cambiamo in WhenAllWrong
questo modo:
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));
}
Una soluzione (TLDR)
Quindi, tornando a await Task.WhenAll(...)
ciò che personalmente volevo essere in grado di:
- Ottieni una singola eccezione se ne è stata lanciata una sola;
- Ottieni un messaggio
AggregateException
se più di un'eccezione è stata lanciata collettivamente da una o più attività;
- Evita di dover salvare il
Task
solo per controllarlo Task.Exception
;
- Propagare la cancellazione dello stato correttamente (
Task.IsCanceled
), come qualcosa di simile non l'avrebbe fatto: Task t = Task.WhenAll(...); try { await t; } catch { throw t.Exception; }
.
Ho messo insieme la seguente estensione per questo:
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();
}
}
Ora, quanto segue funziona nel modo desiderato:
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}");
}
AggregateException
. Se usassi alTask.Wait
posto delawait
tuo esempio, prenderestiAggregateException