Muitas respostas boas aqui, mas eu ainda gostaria de postar meu discurso retórico, pois acabei de encontrar o mesmo problema e conduzi algumas pesquisas. Ou pule para a versão TLDR abaixo.
O problema
Aguardar o task
retornado por Task.WhenAll
apenas lança a primeira exceção do AggregateException
armazenado em task.Exception
, mesmo quando várias tarefas falharam.
Os documentos atuais paraTask.WhenAll
dizer:
Se qualquer uma das tarefas fornecidas for concluída em um estado de falha, a tarefa retornada também será concluída em um estado de Falha, onde suas exceções conterão a agregação do conjunto de exceções não agrupadas de cada uma das tarefas fornecidas.
O que está correto, mas não diz nada sobre o comportamento de "desembrulhar" mencionado acima de quando a tarefa retornada é aguardada.
Suponho que os documentos não mencionem isso porque esse comportamento não é específico paraTask.WhenAll
.
É simplesmente que Task.Exception
é do tipo AggregateException
e para await
continuações sempre é desembrulhado como sua primeira exceção interna, por design. Isso é ótimo para a maioria dos casos, porque geralmente Task.Exception
consiste em apenas uma exceção interna. Mas considere este código:
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]);
}
Aqui, uma instância de AggregateException
é desempacotada em sua primeira exceção interna InvalidOperationException
exatamente da mesma maneira que poderíamos ter feito Task.WhenAll
. Poderíamos ter falhado em observar DivideByZeroException
se não tivéssemos passado task.Exception.InnerExceptions
diretamente.
Stephen Toub, da Microsoft, explica a razão por trás desse comportamento no problema relacionado do GitHub :
O que eu estava tentando enfatizar é que isso foi discutido em profundidade, anos atrás, quando estes foram originalmente adicionados. Originalmente, fizemos o que você está sugerindo, com a Tarefa retornada de WhenAll contendo uma única AggregateException que continha todas as exceções, ou seja, task.Exception retornaria um wrapper AggregateException que continha outra AggregateException que continha as exceções reais; então, quando fosse aguardado, o AggregateException interno seria propagado. O forte feedback que recebemos que nos levou a mudar o design foi que a) a grande maioria desses casos tinha exceções razoavelmente homogêneas, de modo que a propagação de tudo em um agregado não era tão importante, b) a propagação do agregado quebrou as expectativas em torno das capturas para os tipos de exceção específicos, ec) para os casos em que alguém deseja o agregado, pode fazê-lo explicitamente com as duas linhas, como escrevi. Também tivemos extensas discussões sobre qual deveria ser o comportamento de await em relação a tarefas que continham múltiplas exceções, e foi aí que pousamos.
Outra coisa importante a se notar, esse comportamento de desembrulhar é superficial. Ou seja, ele apenas desembrulhará a primeira exceção AggregateException.InnerExceptions
e a deixará lá, mesmo se acontecer de ser uma instância de outra AggregateException
. Isso pode adicionar mais uma camada de confusão. Por exemplo, vamos mudar WhenAllWrong
assim:
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));
}
Uma solução (TLDR)
Então, voltando ao await Task.WhenAll(...)
, o que eu pessoalmente queria é ser capaz de:
- Obtenha uma única exceção se apenas uma tiver sido lançada;
- Obtenha um
AggregateException
se mais de uma exceção tiver sido lançada coletivamente por uma ou mais tarefas;
- Evite ter que salvar
Task
apenas para verificar seu Task.Exception
;
- Propagar o status de cancelamento adequadamente (
Task.IsCanceled
), como algo como isso não faria isso: Task t = Task.WhenAll(...); try { await t; } catch { throw t.Exception; }
.
Eu juntei a seguinte extensão para isso:
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();
}
}
Agora, o seguinte funciona da maneira que eu quero:
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 você usou emTask.Wait
vez deawait
em seu exemplo, você pegariaAggregateException