Blog Post

Desenvolvedores BR
7 MIN READ

Impacto dos Handlers de Exceptions na performance das aplicações .net core

ClaudioGodoy's avatar
ClaudioGodoy
Icon for Microsoft rankMicrosoft
Feb 14, 2022

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:

Tela de configurações do JMetter

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

 

Gráfico dos resultados do teste

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

 

Gráfico dos resultados do teste

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:

  1. 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.
  2. 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.
  3. 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

Are you aware that you have thrown over 40,000 exceptions in the last 3 hours? - If broken it is, fix it you should (tessferrandez.com)

Exceptions - C# language specification | Microsoft Docs

The Exception Model - cbrumme's WebLog - Site Home - MSDN Blogs (archive.org)

Apache JMeter - User's Manual

Updated Feb 11, 2022
Version 1.0
  • Essa provocação é muito pertinente, principalmente pelo fato de que é um problema que escala junto com o aumento de usuários. Costuma ser uma das principais barreiras no crescimento de negócios digitais. Por isso é importante rever estratégias de comunicação entre as camadas baseadas em exceções, o famoso “throw new InvalidOperationException("Erro");".  Vamos deixar exceções apenas para os casos excepcionais! Que tal dar uma olhada no padrão Notification Martin Fowler ?