Beaucoup de bonnes réponses ici, mais j'aimerais tout de même publier ma diatribe car je viens de rencontrer le même problème et j'ai mené des recherches. Ou passez à la version TLDR ci-dessous.
Le problème
Attendre le task
retour par Task.WhenAll
lève uniquement la première exception du AggregateException
stocké dans task.Exception
, même lorsque plusieurs tâches ont échoué.
La documentation actuelle pourTask.WhenAll
dire:
Si l'une des tâches fournies se termine dans un état défectueux, la tâche retournée se terminera également dans un état défectueux, où ses exceptions contiendront l'agrégation de l'ensemble d'exceptions déballées de chacune des tâches fournies.
Ce qui est correct, mais cela ne dit rien sur le comportement de "déroulement" mentionné ci-dessus lorsque la tâche retournée est attendue.
Je suppose que les documents ne le mentionnent pas parce que ce comportement n'est pas spécifique àTask.WhenAll
.
C'est simplement ce qui Task.Exception
est de type AggregateException
et pour les await
suites, il est toujours déballé comme sa première exception intérieure, par conception. C'est génial dans la plupart des cas, car il se Task.Exception
compose généralement d'une seule exception interne. Mais considérez ce code:
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]);
}
Ici, une instance de AggregateException
est déroulée dans sa première exception interne InvalidOperationException
exactement de la même manière que nous l'aurions pu avoir avec Task.WhenAll
. Nous aurions pu ne pas observer DivideByZeroException
si nous ne passions pas task.Exception.InnerExceptions
directement.
Stephen Toub de Microsoft explique la raison de ce comportement dans le problème GitHub associé :
Ce que j'essayais de faire valoir, c'est que cela a été discuté en profondeur, il y a des années, lorsque ceux-ci ont été ajoutés à l'origine. Nous avons initialement fait ce que vous suggérez, avec la tâche retournée par WhenAll contenant une seule AggregateException qui contenait toutes les exceptions, c'est-à-dire que task.Exception renverrait un wrapper AggregateException qui contenait une autre AggregateException qui contenait alors les exceptions réelles; puis quand elle était attendue, l'exception AggregateException interne serait propagée. La forte rétroaction que nous avons reçue et qui nous a amenés à changer la conception était que a) la grande majorité de ces cas avaient des exceptions assez homogènes, de sorte que la propagation du tout dans un agrégat n'était pas si important, b) la propagation de l'agrégat, puis brisé les attentes concernant les captures. pour les types d'exceptions spécifiques, et c) pour les cas où quelqu'un voulait l'agrégat, il pouvait le faire explicitement avec les deux lignes comme je l'ai écrit. Nous avons également eu des discussions approfondies sur ce que devrait être le comportement de await en ce qui concerne les tâches contenant plusieurs exceptions, et c'est là que nous avons atterri.
Une autre chose importante à noter, ce comportement de déballage est peu profond. C'est-à-dire qu'il ne déroulera que la première exception AggregateException.InnerExceptions
et la laissera là, même s'il s'agit d'une instance d'une autre AggregateException
. Cela peut ajouter encore une autre couche de confusion. Par exemple, changeons WhenAllWrong
comme ceci:
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));
}
Une solution (TLDR)
Donc, revenons à await Task.WhenAll(...)
, ce que je voulais personnellement, c'est pouvoir:
- Obtenez une seule exception si une seule a été lancée;
- Obtenez un
AggregateException
si plus d'une exception a été levée collectivement par une ou plusieurs tâches;
- Évitez d'avoir à enregistrer le
Task
seul pour vérifier son Task.Exception
;
- Propager l'état d'annulation correctement (
Task.IsCanceled
), comme quelque chose comme cela ne ferait pas cela: Task t = Task.WhenAll(...); try { await t; } catch { throw t.Exception; }
.
J'ai mis en place l'extension suivante pour cela:
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();
}
}
Maintenant, ce qui suit fonctionne comme je le souhaite:
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
. Si vous avez utiliséTask.Wait
au lieu deawait
dans votre exemple, vous attraperiezAggregateException