DEV Community

Cover image for Async/Await: Task.WhenAll + Exceptions = Dor de Cabeça!
Angelo Belchior
Angelo Belchior

Posted on

Async/Await: Task.WhenAll + Exceptions = Dor de Cabeça!

O uso de async/await em aplicações dotnet é muito comum, independente se o projeto é uma aplicação Desktop, Web UI, API ou até mesmo Mobile.

A impressão que eu tenho é que as pessoas utilizam essa feature (e várias outras) mas nem sempre se preocupam em saber como tudo funciona por debaixo do capô.

E eu não critico isso: O dia-a-dia de quem desenvolve muitas vezes é complicado, cheio de demandas, prazo curto, café morno, javascript, salário baixo... enfim... Só desgraça! E com isso, o foco se torna entregar a task e mudar o status no Jira. Segue o velório...

Eu procuro sempre me aprofundar nesses temas, entender como as coisas realmente funcionam, tanto que eu escrevi uma série de posts sobre esse assunto que você pode ler acessando esse link.

Hoje eu quero trazer uma questão bem interessante relacionada ao uso do Task.WhenAll: Como lidar com exceções!

O Task.WhenAll é um método que permite executar várias tarefas assíncronas em paralelo e aguardar a conclusão de todas elas. Ele recebe como argumento uma coleção de tarefas (Task[], IEnumerable<Task>) e retorna uma única tarefa que será concluída quando todas as tarefas fornecidas forem finalizadas. Esse método é útil para quando se deseja realizar operações assíncronas simultâneas e aguardar a conclusão de todas antes de continuar com a execução do código.

Ahhhh Angelo pára de drama, é só usar try/catch que tá tudo resolvido...

Como diria o professor, escritor e filósofo Mário Sergio Cortella: Será?

Vejamos o código abaixo...

var task1 = Task.Run(async () =>  
{  
    Console.WriteLine("Executando Task 1");  
    await Task.Delay(100);  
    throw new ArgumentNullException("ParameterA","Deu erro na Task 1");  
});  

var task2 = Task.Run(async () =>  
{  
    Console.WriteLine("Executando Task 2"); 
    await Task.Delay(200); 
    throw new InvalidOperationException("Deu erro na Task 2");
});  

var task3 = Task.Run(async () =>  
{  
    Console.WriteLine("Executando Task 3");  
    await Task.Delay(300);  
    throw new IndexOutOfRangeException("Deu erro na Task 3");  
});  

var result = Task.WhenAll(task1, task2, task3);  

try  
{  
    await result;  
}  
catch (Exception e)  
{  
    Console.WriteLine($"{e.GetType().Name} - {e.Message}");  
}  

Console.ReadLine();
Enter fullscreen mode Exit fullscreen mode

O que temos acima, são três tasks que são executadas juntas, cada uma com um tempo de duração pré estipulado (para atender ao exemplo) e que lançam um tipo de exception diferente uma das outras. Eu envolvo o await dessas tasks num try/catch maroto e escrevo o tipo e a mensagem da exception no console. Note que eu estou esperando uma Exception base, logo, qualquer tipo de exceção que for lançada, eu vou capturar.

Vamos executar e ver o que acontece:

Executando Task 1
Executando Task 2
Executando Task 3
ArgumentNullException - Deu erro na Task 1 (Parameter 'ParameterA')
Enter fullscreen mode Exit fullscreen mode

Bom, notamos que todas as tasks foram executadas, certo? Logo, todas elas lançaram uma exception. A primeira a lançar foi, obviamente, a task que executou mais rápido. Mas, e as outras tasks? ...

Ok, você pode nesse momento questionar se todas as tasks lançaram mesmo as suas exceptions. Na verdade isso não é tão trivial quanto parece, principalmente pra quem não conhece muito a fundo o async/await. O mais natural é imaginar que as outras duas tasks foram interrompidas, mas esse é o comportamento natural do Task.WhenAll. Mais abaixo você verá que, de fato, todas as tasks lançaram suas respectivas exceções.

É aqui que mora o perigo: O Task.WhenAll lança apenas a primeira exceção ocorrida dentro da lista de tasks a serem executadas! Esse é o comportamento adotado por esse método.

A princípio, seria mais interessante que uma AggregateException fosse lançada, já que podemos armazenar uma lista de exceções nessa classe.
Mas não! Por algum motivo foi feito dessa forma.

Eu li e reli todo o código do Task.WhenAll e alguma de suas entranhas mas não entendi o motivo. Talvez você que é mais inteligente consiga entender e me explicar aqui nos comentários.

Mas e agora? Como saberemos quais exceptions ocorreram?

Simples: A variável result que é uma Task, contém uma propriedade chamada Exception que é do tipo.. olha só... quem diria... você não vai acreditar...
AggregateException!
Vou alterar o código escrevendo no console a mensagem do result.Exception:

...

catch (Exception e)  
{  
  Console.WriteLine($"{e.GetType().Name} - {e.Message}");  
  Console.WriteLine($"{result.Exception?.GetType().Name} - {result.Exception?.Message}");  
}
Enter fullscreen mode Exit fullscreen mode

E o resultado da execução é...

Executando Task 1
Executando Task 3
Executando Task 2
ArgumentNullException - Deu erro na Task 1 (Parameter 'ParameterA')
AggregateException - One or more errors occurred. 
(Deu erro na Task 1 (Parameter 'ParameterA')) 
(Deu erro na Task 2) 
(Deu erro na Task 3)
Enter fullscreen mode Exit fullscreen mode

Nesse momento podemos perceber que de fato todas as tasks foram executadas!
Ao imprimir a mensagem da AggregateException podemos notar que a task1, a task2 e a task3 foram executadas e lançaram exceção!

Sendo assim, a conclusão que a gente chega é que, para sabermos quais exceptions ocorreram dentro de um Task.WhenAll, precisamos avaliar o result.Exception que é uma AggregateException.

É muito comum o sistema "engolir" erros, quando a gente apenas loga a exceção capturada pelo catch. Eu já vi isso acontecer algumas vezes, e sempre dá aquela dorzinha de cabeça.

Eu espero que essa dica tenha sido útil pra você. O async/await é maravilhoso, mas sempre tem uma pegadinha ou outra.

Por fim, recomendo você efetuar alguns testes, como deixar o Task.Delay com um valor aleatório, lançar outros tipos de exceções, ou até mesmo utilizar o famigerado Cancellation Token na execução.

Se você não sabe pra que serve o Cancellation Token escrevi um post bacanudo explicando: https://dev.to/angelobelchior/asyncawait-para-que-serve-o-cancellationtoken-nm7.

Obrigado pessoal e até a próxima...


Ok, eu sei que você ficou com preguiça de implementar um Delay com valor aleatório e adicionar o Cancellation Token no código além de brincar com outras exceções. Vou quebrar seu galho... segue o código prontinho pra você...

var randomDelay = () => Random.Shared.Next(1000, 5000);  

var cts = new CancellationTokenSource();  

var task0 = Task.Run(async () =>  
{  
    // Simulo uma tarefa onde o usuário cancela a operação  
    Console.WriteLine("Executando Task do Cancellation Token");  
    // Forçando para ser a primeira task a ser executada  
    await Task.Delay(50);  
    cts.Cancel();  
});  

var task1 = Task.Run(async () =>  
{  
    Console.WriteLine("Executando Task 1");  
    await Task.Delay(randomDelay());  
    // Se o token de cancelamento foi solicitado, lança uma exceção  
    if (cts.Token.IsCancellationRequested)  
        throw new ArgumentNullException("ParameterA", "Deu erro na Task 1");  
});  

var task2 = Task.Run(async () =>  
{  
    Console.WriteLine("Executando Task 2");  
    await Task.Delay(randomDelay());  
    // Se o token de cancelamento foi solicitado, lança uma exceção  
    if (cts.Token.IsCancellationRequested)  
    {  
        // Experimentando outro tipo de exceção... só pra testar mesmo...  
        throw new OperationCanceledException("Deu erro na Task 2");  
    }       
});  

var task3 = Task.Run(async () =>  
{  
    Console.WriteLine("Executando Task 3");  
    await Task.Delay(randomDelay());  
    // Se o token de cancelamento foi solicitado, lança uma exceção  
    if (cts.Token.IsCancellationRequested)  
        throw new IndexOutOfRangeException("Deu erro na Task 3");  
});  

var result = Task.WhenAll(task0, task1, task2, task3);  
try  
{  
    await result;  
}  
catch (Exception e)  
{  
    Console.WriteLine($"{e.GetType().Name} - {e.Message}");  
    Console.WriteLine($"{result.Exception?.GetType().Name} - {result.Exception?.Message}");  
}  
Enter fullscreen mode Exit fullscreen mode

Pronto... é isso...

Comentei algumas linhas no código mais para você se situar e entender o que elas fazem... mas não tem nada demais...

Ta bom... Eu sei que você também gostaria de ver o resultado da operação acima...
De novo, vou quebrar seu galho...

Executando Task 1
Executando Task 2
Executando Task 3
Executando Task do Cancellation Token
IndexOutOfRangeException - Deu erro na Task 3
AggregateException - One or more errors occurred. 
(Deu erro na Task 3) 
(Deu erro na Task 1 (Parameter 'ParameterA'))
Enter fullscreen mode Exit fullscreen mode

Fechamos! É isso!! Valew pesso.........

Opaaaaaaa....... opaaaaa..... calma aê...

Image description

Eita... repararam no resultado da execução? Cadê a OperationCanceledException???????

O catch capturou uma exception chamada IndexOutOfRangeException pois ela foi a primeira a ser lançada. (Pode ser que essa não seja a primeira exception lançada quando você efetuar testes, mas sabemos que o catch sempre captura a primeira exceção). Até aqui, tudo certo.

Porém, o nosso famigerado AggregateException não trouxe todas as exceções, ele simplesmente ignorou o OperationCanceledException.

E é aqui que a gente começa a ficar mais perdido que cebola na salada de frutas...

Por que causa, motivo, razão ou circunstância essa exception não foi lançada?

Pois é... mais uma daquelas situações que só quem perdeu horas e horas do dia estudando a fundo como as coisas funcionam que consegue pelo menos resolver o problema (ja que entender o motivo pelo qual foi implementado assim é algo meio que nebuloso...).

Mas vamos lá... pegue aquele café maroto e vem comigo...

Quando o async/await foi criado, em meados de 2012, Corinthians Campeão da Libertadores e Mundial, na versão 5.0 do csharp, ele trouxe uma possibilidade muito importante (e mal usada até hoje) que é a de poder cancelar uma task ou mais tasks executadas.

Imagine você apertar um botão de uma tela de relatório e perceber que escolheu o filtro de datas erradas, fazendo com que o sistema busque dados de 1950 até hoje. Isso geraria uma consulta pesada e levaria tempo para retornar todas as informações.

Usando conceitos de async/await com CancellationToken é possível emitir um sinal informando que deseja cancelar as tarefas executadas. Sendo assim, toda task criada dentro desse fluxo, pode parar sua execução. Reforço aqui o post sobre CancellationToken que eu escrevi.

Porém, durante a execução do processo, o Task.WhenAll (e alguns outros métodos) precisam identificar se a interrupção daquela task foi efetuada por um CancellationToken ou por alguma exceção ocorrida. E a forma encontrada foi criar uma exceção específica para diferenciar esses dois casos e optou-se por adicionar na lista de exceções da AggregateException apenas as tasks que deram erro, de fato (exception != OperationCanceledException).

Tanto é que, quando você invoca o método cts.Token.ThrowIfCancellationRequested() internamente essa exceção é lançada.
Olhando o código fonte da classe CancellationToken.cs temos:

...

public void ThrowIfCancellationRequested()  
{  
    if (IsCancellationRequested)  
        ThrowOperationCanceledException();  
}  

...

// Throws an OCE; separated out to enable better inlining of ThrowIfCancellationRequested 
private void ThrowOperationCanceledException() =>  
    throw new OperationCanceledException(SR.OperationCanceled, this);
Enter fullscreen mode Exit fullscreen mode

Ok, mas como eu faço pra saber se uma task foi ou não cancelada?
Simples, olhando seu status.
Fiz um ajuste no código para mostrar o status de cada task. Ficou assim:

...

catch (Exception e)  
{  
    Console.WriteLine($"{e.GetType().Name} - {e.Message}");  
    Console.WriteLine($"{result.Exception?.GetType().Name} - {result.Exception?.Message}");  
    Console.WriteLine($"Task1: {task1.Status} - {task1.Exception?.GetType().Name} - {task1.Exception?.Message}");  
    Console.WriteLine($"Task2: {task2.Status} - {task2.Exception?.GetType().Name} - {task2.Exception?.Message}");  
    Console.WriteLine($"Task3: {task3.Status} - {task3.Exception?.GetType().Name} - {task3.Exception?.Message}");  
}
Enter fullscreen mode Exit fullscreen mode

E o resultado:

Executando Task do Cancellation Token
Executando Task 1
Executando Task 2
Executando Task 3
IndexOutOfRangeException - Deu erro na Task 3
AggregateException - One or more errors occurred. (Deu erro na Task 3) (Deu erro na Task 1 (Parameter 'ParameterA'))
Task1: Faulted - AggregateException - One or more errors occurred. (Deu erro na Task 1 (Parameter 'ParameterA'))
Task2: Canceled -  - 
Task3: Faulted - AggregateException - One or more errors occurred. (Deu erro na Task 3)
Enter fullscreen mode Exit fullscreen mode

Bom, nesse ponto, percebemos que o tratamento de exceções dentro de um processo que envolve async/await não é tão trivial.
O ideal é sempre verificar o status de execução da task para saber o que realmente aconteceu com ela. Não basta apenas adicionar um try/catch e nem mesmo verificar o result.Exception esperando que o AggregateException traga todas as informações necessárias.
E o mais interessante ao usar essa abordagem é que, ao saber o status da task, é possível, por exemplo, tentar reprocessar aquelas que foram canceladas.

É isso!

E ai? Já sofreu com tratamento de exceções usando async/await?
Conta ai pra mim nos comentários!

Forte abraço, e até a próxima.

Top comments (2)

Collapse
 
informago profile image
Luciano de Almeida Reis

Excelente artigo!

Collapse
 
angelobelchior profile image
Angelo Belchior

Muito obrigado