A aplicação consome poucos recursos, mas performa mal. Entendendo thread pool starvation

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.

Compartilhe este insight:

Comentários

Participe deixando seu comentário sobre este artigo a seguir:

Subscribe
Notify of
guest
4 Comentários
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
José Nery
José Nery
23 dias atrás

Fala aí Castilho, ótimo artigo! Qual ferramenta você usou para fazer teste de carga?

Alvaro
Alvaro
9 dias atrás

Olá Raphael!
Qual opção tu marcou para coletar o ThreadPoolWorkerThreadAdjustment no PerfView ?

AUTOR

Raphael Castilho
Desenvolvedor especialista em .NET com experiência em aplicações corporativas de larga escala.

SOLUÇÕES EXIMIACO

Codificação de Software

ESTRATÉGIA & EXECUÇÃO EM TI

Simplificamos, potencializamos 
aceleramos resultados usando a tecnologia do jeito certo.

INSIGHTS EXIMIACO

Confira outros insights de nossos consultores relacionados a esta solução de negócio:

27/09/2021
Francisco Schneider
Desenvolvedor especialista em .NET com experiência em aplicações corporativas complexas
27/09
2021
13/07/2021
13/07
2021
09/07/2021
Raphael Castilho
Desenvolvedor Especialista em aplicações corporativas .NET
09/07
2021

COMO PODEMOS LHE AJUDAR?

Vamos marcar uma conversa para que possamos entender melhor sua situação e juntos avaliar de que forma a tecnologia pode trazer mais resultados para o seu negócio.

COMO PODEMOS LHE AJUDAR?

Vamos marcar uma conversa para que possamos entender melhor sua situação e juntos avaliar de que forma a tecnologia pode trazer mais resultados para o seu negócio.

+55 51 3049-7890
[email protected]

+55 51 3049-7890 |  [email protected]

4
0
Queremos saber a sua opinião, deixe seu comentáriox
()
x

Tenho interesse em conversar

Se você está querendo gerar resultados através da tecnologia, preencha este formulário que um de nossos consultores entrará em contato com você:

O seu insight foi excluído com sucesso!

O seu insight foi excluído e não está mais disponível.

O seu insight foi salvo com sucesso!

Ele está na fila de espera, aguardando ser revisado para ter sua publicação programada.