Sempre que falamos em performance provavelmente o tópico mais pertinente a ser colocado em pauta será o volume, por exemplo:
- Volume de usuários;
- Volume de requisições;
- Volume de dados;
Sobre o tratamento de exceções não poderia ser diferente, então proponho uma reflexão: Qual é o volume de exceções que a sua aplicação trata por dia/hora/minuto? Qual é o impacto disto na performance? O que acontece se esse volume aumentar?
O objetivo não é questionar a veracidade de uma exceção, pois em muitos casos elas são extremamente necessárias e eficientes, o ponto aqui é, demonstrar o custo computacional relacionado ao uso de uma estratégia tratamento de exceção (Exception-Handling), para que você as use conscientemente e saiba quando evitá-las.
Como exceções são tratadas?
O mecanismo de tratamento de exceção do .NET Framework foi construído sobre o mecanismo do Windows nomeado de Structured Exception Handling (SEH).
Observe o código a seguir:
try{
}
catch (NullReferenceException e)
{
}
catch (Exception e)
{
}
finally
{
}
As exceções são tratadas por uma instrução try.
Quando ocorre uma exceção, o sistema procura a cláusula catch mais próxima que possa tratar a exceção, conforme determinado pelo tipo da exceção.
Em um alto nível o que acontece em tempo de execução é o seguinte:
- O método atual é pesquisado, em busca de uma instrução try envolvente, e as cláusulas catch associadas da instrução try são consideradas em ordem.
- Se isso falhar, o método que chamou o método atual é pesquisado em busca de uma instrução try que inclui o ponto da chamada para o método atual.
- Esta pesquisa continua até que uma cláusula catch que possa tratar a exceção atual seja encontrada.
- Uma cláusula catch que não nomeia uma classe de exceção pode tratar qualquer exceção.
- Depois que uma cláusula catch correspondente é encontrada, o sistema se prepara para transferir o controle para a primeira instrução da cláusula catch.
- Antes do início da execução da cláusula catch, o sistema primeiro executa, em ordem, todas as cláusulas finally que foram associadas às instruções try aninhadas mais do que aquela que detectou a exceção.
- Se nenhuma cláusula catch correspondente for encontrada, uma das duas coisas ocorre:
- Se a busca por uma cláusula catch correspondente atingir um construtor estático (construtores estáticos) ou inicializador de campo estático, uma System.TypeInitializationException é lançada no ponto que acionou a invocação do construtor estático.
- Se a busca por cláusulas catch correspondentes atingir o código que inicialmente iniciou o encadeamento, a execução do encadeamento será encerrada.
Sugiro a leitura da documentação oficial da Microsoft para entender com mais profundidade: Exceptions - C# language specification | Microsoft Docs
Até aqui entendemos muito no alto nível o que acontece, porém existem diversos outros detalhes tanto do CLR quanto do próprio SO que não foram discutidos.
Chris Brumme escreveu um excelente artigo chamado The Exception Model que detalha de forma cirúrgica todo o processo de exception-handling do CLR: The Exception Model - cbrumme's WebLog - Site Home - MSDN Blogs (archive.org)
Em resumo deste artigo podemos definir que, por baixo dos panos os seguintes fenômenos também acontecem:
- Rastrear a stack para guiar o tratamento da exceção.
- Executar uma cadeia de handlers na stack, chamando cada handlers duas vezes.
- Compensar as incompatibilidades entre SEH, C ++ e exceções gerenciadas.
- Alocar uma instância derivada de System.Exception e executar seu construtor.
- Talvez, implicar em uma viagem pelo kernel do sistema operacional. Frequentemente, capturando uma exceção de hardware.
- Notificar quaisquer depuradores, profilers, manipuladores de exceção vetorizados e outras partes interessadas anexados.
Devo evitar o uso de Try/Catch/Finally?
Até aqui acredito que concordamos que, tratar exceções não é uma tarefa simples, tão pouco barata em termos computacionais, todavia, o uso destes mecanismos nos oferece uma robustez enorme.
O famoso dialeto “melhor prevenir do que remediar” vai exatamente de encontro com o que quero propor aqui.
Analise este método:
public int Dividir(int a, int b)
{
try
{
return a / b;
}
catch (DivideByZeroException ex)
{
return throw ex;
}
}
Meu ponto aqui é: Se eu sei que o usuário pode e vai colocar uma entrada inválida, o fato de eu estar ciente que esse cenário acontece com certa frequência, já me indica para evitar o lançamento desta Exception.
Após uma pequena refatoração chegaremos ao seguinte código:
public int Dividir(int a, int b)
{
try
{
if (a == 0 || b == 0) return 0;
return a / b;
}
catch
{
return 0;
}
}
Repare que não retirei o bloco try/catch pois neste ponto do código outros problemas dos quais ainda não tenho consciência podem ocorrer, entretanto agora eu definitivamente tenho a certeza de que não ocorrerá problemas com divisão por 0.
Vale a ressalva de que esse tipo de abordagem traz um efeito negativo para expressividade do seu código, esse tema foi bastante discutido no livro “Clean Code: A Handbook of Agile Software Craftsmanship” do Robert C. Martin.
O ideal aqui é colocar na balança: Performance vs Expressividade.
Para tomar essa decisão sempre precisamos pensar no volume de requisições que nossa aplicação normalmente/excepcionalmente vai lidar.
Efeitos na performance da aplicação
Um pouco de empirismo nunca é demais.
Publiquei um repositório no GitHub com uma API para que você consiga reproduzir o mesmo cenário que o meu: https://github.com/claudiogodoy99/Exceptions-Lab.git
Para isso você vai precisar ter:
- Visual Studio 2022;
- JMetter;
- Inscrição no Azure;
Publiquei a API no recurso de PaaS do Azure chamado Azure Web App, isso ajudou muito a ter um ambiente completamente limpo para os testes.
O cenário de teste é bem simples, teremos dois endpoints com a mesma função:
app.MapGet("/dividir/handle", (int a, int b) => {
try{
int result = a / b;
return result;
}
catch (DivideByZeroException ex){
throw ex;
}
});
app.MapGet("/dividir", (int a, int b) => {
if (a == 0 || b == 0) return 0;
return a / b;
});
A sutil diferença entre eles é que um trata os valores de entrada afim de evitar um Exception de divisão por 0.
No primeiro caso o throw no bloco catch é proposital, toda API AspNet já tem um global exception handler default, que traduz qualquer exception para o Status Response 500, o que estamos fazendo aqui é deixar que esse global exception handler capture nossa exceção de divisão por 0.
Usaremos a ferramenta JMetter para simular um alto volume de requisições simultâneas e planilhar os resultados.
Meu ponto aqui não é te ensinar a configurar um teste de carga, para entender melhor o que vou falar a seguir sugiro uma breve lida na documentação oficial do JMtter.
Essa é a estrutura do nosso cenário de teste:
Basicamente vou reutilizar o mesmo teste para os dois endpoints, com uma carga idêntica (50% das requisições contêm valores inválidos), e comparar os resultados.
Assim poderemos claramente evidenciar todos os efeitos discutidos anteriormente, e ainda melhor, vamos ver isso ocorrendo com um alto volume de requisições simultâneas.
Os parâmetros do teste de carga são:
- 60 usuários (threads) simultâneos;
- 2 requisições por usuário, sendo uma com dados inválidos;
- É considerado concluído quando atingir 12.000 requisições;
Primeiro cenário de teste
app.MapGet("/dividir/handle", (int a, int b) => {
try
{
int result = a / b;
return result;
}
catch (DivideByZeroException ex){
throw ex;
}
});
A carga foi completa em 2,36 minutos:
Label |
# Samples |
Average |
Min |
Max |
Throughput |
Request OK |
6000 |
312 |
151 |
2885 |
42,41872 |
Request With Exception |
6000 |
819 |
157 |
15990 |
42,61757 |
TOTAL |
12000 |
566 |
151 |
15990 |
84,71705 |
Segundo cenário de teste
app.MapGet("/dividir", (int a, int b) =>{
if (a == 0 || b == 0) return 0;
return a / b;
});
A carga foi completa em 41 segundos:
Label |
# Samples |
Average |
Throughput |
Request OK |
6000 |
180 |
145,6735 |
Request With Exception |
6000 |
175 |
150,09005 |
TOTAL |
12000 |
178 |
289,9181 |
Vou destacar os seguintes pontos:
- O tempo de resposta médio do endpoint com a entrada igual a 0 foi de 175 ms;
- A aplicação conseguiu responder a 289,918 requisições por segundo;
Conclusão empírica
Até aqui acredito que ficou claro a diferença drástica entre os resultados, mas para todo efeito vou elencar todos os fatos até aqui:
- No primeiro cenário o endpoint que não gerou exceptions teve o seu tempo de resposta médio 173% maior que no segundo caso, portanto, em um cenário de concorrência, a estratégia de tratamento de exceções pode interferir no tempo de resposta de endpoints completamente distintos.
- No primeiro caso, as requests com dados incorretos obtiveram o tempo de resposta médio 468% maior. 175 ms no segundo caso vs 819 ms do primeiro cenário.
- Por fim, o throughput melhorou em 344% partindo de 84 reqs/sec para 289 reqs/sec.
O próximo passo agora é conseguir responder a seguinte pergunta: Como eu identifico que minha aplicação está gerando mais exception do que deveria? Como eu encontro a causa raiz dessas exceptions e penso em uma forma de mitigá-las?
Pois até aqui nós montamos um cenário completamente simples e controlado, na realidade as coisas são um pouco mais complexas e críticas.
Mas vou deixar essas perguntas em aberto para respondê-las em breve.
Espero ter contribuído de alguma forma para sua jornada! Até a próxima!
Referências
Exceptions - C# language specification | Microsoft Docs
The Exception Model - cbrumme's WebLog - Site Home - MSDN Blogs (archive.org)