Na minha máquina performa bem
Quando se pensa em desenvolvimento de aplicações profissionais, uma das preocupações é desenvolver soluções que consomem os recursos computacionais adequadamente. Esta abordagem nos permite executar operações onerosas em máquinas enxutas, reduzindo os custos com infraestrutura e evitando desperdícios da aplicação.
Além de performance, muitos dos problemas de instabilidade e indisponibilidade são causados pela ingenuidade do time de desenvolvimento em não considerar o dimensionamento correto da escala. Aquele código que funciona bem no ambiente de desenvolvimento pode ser um fiasco quando submetido ao cenário real de produção.
Em algumas situações, nos deparamos com cenários onde, em tese, não deveriam apresentar problemas de performance. Não existem operações que exigem processamento intenso, não é necessário lidar com grandes volumes de dados e operações que envolvem I/O respondem em tempos aceitáveis mesmo sob pressão. Ao acompanhar o desempenho da aplicação através de alguma ferramenta de monitoramento, nenhum dos recursos computacionais se mostram em uso intenso, mesmo sob alta demanda. Entretanto, a experiência desejada não é atingida devido a lentidão e até mesmo instabilidade. Neste caso, é necessário aprofundar a investigação levando em conta a escala.
Primeiro passo: Identificar o gargalo
Tomemos como exemplo essa situação baseada em um case real. Ao mapear os hot paths, identificamos as operações que apresentam os maiores problemas de performance. Analise o seguinte endpoint para iniciarmos a investigação:
public IActionResult Get(int id) { var product = _productRepository.Get(id); return Ok(product); }
O código é simples e analisando-o, não existe nenhum indício de problema. Partindo para a verificação da fonte de dados do repositório verificamos que mesmo sob pressão, o tempo de resposta não se degrada. Ao executar a aplicação e fazer uma chamada para o endpoint o retorno da requisição é de aproximadamente 220 milissegundos, o que neste contexto soa bastante razoável.
Alguns problemas só ficam evidentes quando submetidos a escala real
Com o objetivo de simular um cenário similar ao que ocorre em produção, criamos um teste de carga configurando 600 usuários virtuais solicitando 10.000 requisições concorrentemente.
O teste inteiro levou 3 minutos e 41 segundos para executar, a média de tempo de duração das requisições foi de 11,32 segundos, houveram requisições que chegaram a durar 1 minuto e o percentil 90 foi de 19,89 segundos. Um outro ponto importante a ser observado foi que das 10.000 requisições, 849 retornaram com falha por conta de timeout. Um código simples, aparentemente bom, mas com um péssimo resultado quando submetido a pressão.
Descendo o nível de investigação
O problema foi evidenciado, porém, o motivo não foi identificado. Partimos para uma investigação mais profunda com o auxílio de uma ferramenta de análise de performance que nos permite analisar o cenário com mais detalhes, o PerfView. Enquanto a aplicação executa, coletamos as informações com o PerfView e iniciamos o mesmo teste de carga. Após a coleta, verificamos a opção Thread Time Stacks e identificamos o gargalo. 99,6% do custo da execução está em Blocked_Time, o que indica que as threads estão ficando bloqueadas pela maior parte do tempo.
Ao analisar os eventos de thread, constatamos que o thread pool precisou ajustar a quantidade de threads várias vezes por conta de Starvation.
Entendendo o comportamento
Em aplicações .NET, o thread pool é utilizado para o gerenciamento de threads. Uma de suas características é a de criar mais threads quando a quantidade provisionada para o processo não é suficiente para suportar a demanda de tarefas enfileiradas. Este comportamento tem o objetivo de aumentar o paralelismo e maximizar o throughput.
No cenário apresentado, as requisições são recebidas e enfileiradas pelo thread pool, que é responsável pela alocação da tarefa em uma thread. Ao iniciar, a operação espera o retorno do repositório, e durante esse tempo a thread fica bloqueada, mesmo que não esteja processando algo. A thread fica bloqueada por se tratar de uma execução síncrona. Como o volume de requisições recebido é expressivamente maior que o volume de requisições completado, faz-se necessário criar mais threads para poder suportar a demanda. Este processo ocorre pela escassez de threads disponíveis (thread starvation), logo o nome thread pool starvation. Isto se torna um problema grave, pois a criação de uma thread leva em torno de um segundo, e além disso, a aplicação passa a ter que gerenciar mais threads, onerando o tempo de execução.
Não existe almoço grátis
É importante destacar que mesmo que a aplicação suporte uma quantidade maior de threads que a quantidade de processadores, isso pode ser prejudicial, pois as operações só ocorrem em paralelo de acordo com o número de processadores existentes, e o custo de gerenciamento das threads pode não compensar.Otimização do uso de threads com operações assíncronas
Se a thread não processa nada enquanto espera o retorno de uma operação que envolva I/O, podemos aproveitar essa ociosidade processando outra tarefa que aguarda na fila do thread pool. Para isso, alteramos o código para que execute a operação de forma assíncrona.
public async Task<IActionResult> GetAsync(int id) { var product = await _productRepository.GetAsync(id); return Ok(product); }
Após executarmos o teste de carga, verificamos que o resultado é expressivamente melhor. Além de ocorrer menos ajustes no thread pool (nenhum por conta de starvation), nenhuma requisição retornou com erro. A sessão levou 4,9 segundos para executar, a média de tempo de duração das requisições foi de 270,93 milissegundos, o máximo que uma requisição chegou a durar foi 597,39 milissegundos, e o percentil 90 foi de 305,96 milissegundos.
Se atente aos detalhes
Mesmo implementando métodos assíncronos, é necessário se atentar aos detalhes. Uma única operação dentro do método que não esteja implementada de forma adequada ou que não suporte operações assíncronas irá bloquear a thread, comprometendo a eficiência da assincronicidade.
Apagando o incêndio temporariamente
Existem cenários onde alterar o código para deixá-lo assíncrono não é tarefa trivial, pode levar muito tempo e/ou possuir um risco elevado na mudança. Nestes casos, uma medida paliativa pode ser calibrar as quantidades mínima e máxima de threads do thread pool com o objetivo de minimizar os impactos negativos de escassez de threads. Embora não seja uma prática recomendada, se feita de maneira correta pode aliviar as dores de performance e instabilidade temporariamente.
Vale destacar a contra indicação apresentada pela Microsoft na documentação do método SetMinThreads:
Recomendações para evitar problemas
Programe para produção e não para desenvolvimento
No desenvolvimento profissional de aplicações, fundamente suas decisões técnicas considerando a escala e suas evoluções. Funcionar bem em ambiente de desenvolvimento não significa bom desempenho em produção.
Faça experimentos de carga
Em cenários de alta escala, adote teste de carga como prática de engenharia padrão dentro do processo de desenvolvimento.
Não se iluda por indicadores de CPU e memória
Garanta que você tenha um ambiente instrumentado para monitorar indicadores como CPU, memória e acesso a disco, porém não se limite a eles.
Fala aí Castilho, ótimo artigo! Qual ferramenta você usou para fazer teste de carga?
Valeu Zé Nery! A ferramenta usada foi o k6 (https://k6.io/).
Olá Raphael!
Qual opção tu marcou para coletar o ThreadPoolWorkerThreadAdjustment no PerfView ?
Oi Alvaro!
É necessário marcar a opção “.NET” em “Advanced Options” para coletar eventos desse tipo.
Ótimo artigo! Parabéns
Excelente material, obrigado por compartilhar.
Esse recurso de async/await do .NET é fantástico, sempre achei uma grande vantagem frente ao Java e outras linguagens que bloqueiam threads para aguardar I/O.
Mas recentemente o Java lançou o recurso de VirtualThreads, e nesse artigo sugerem que o resultado é melhor que o obtido em C#.
https://www.infoq.com/articles/java-virtual-threads/