Exaustão do ThreadPool do .Net
Mais de uma vez em minha carreira me deparei com este cenário: a aplicação .Net frequentemente mostrando tempos de resposta elevados. Esta alta latência pode ter várias causas, como lentidão no acesso a algum recurso externo (um banco de dados ou uma API, por exemplo), uso de CPU “batendo” em 100%, sobrecarga de acesso a disco, entre outras. Quero adicionar à lista anterior outra possibilidade, muitas vezes pouco considerada: exaustão do ThreadPool.
Será apresentado de forma bem rápida como o ThreadPool do .Net funciona, e exemplos de códigos onde isto pode acontecer. Por fim, será demonstrado como evitar este problema.
O ThreadPool do .Net
O modelo de programação assíncrona baseado em Tasks (Task-based asynchronous programming) do .Net é bastante conhecido pela comunidade de desenvolvimento, mas acredito que seja pouco compreendido em seus detalhes de implementação – e é nos detalhes onde mora o perigo, como bem diz o ditado.
Por trás do mecanismo de execução de Tasks do .Net existe um Scheduler, responsável, como seu nome já indica, por escalonar a execução das Tasks. Salvo alguma mudança explícita, o scheduler padrão do .Net é o ThreadPoolTaskScheduler, que também como o nome indica, utiliza o ThreadPool padrão do .Net para realizar seu trabalho.
O ThreadPool gerencia então, como já se esperava, um pool de threads, para as quais ele atribui as Tasks que recebe usando uma fila. É nesta fila onde as Tasks ficam armazenadas até que haja um thread livre no pool, para então iniciar seu processamento. Por padrão, o número mínimo de threads do pool é igual ao número de processadores lógicos do host.
E aqui está o detalhe em seu funcionamento: quando existem mais Tasks a serem executadas do que o número de threads do pool, o ThreadPool pode esperar uma thread ficar livre ou criar mais threads. Se escolher criar uma nova thread e se o número atual de threads do pool for igual ou maior que o número mínimo configurado, este crescimento demora entre 1 e 2 segundos para cada nova thread adicionada ao pool.
Observação: a partir do .Net 6 foram introduzidas melhorias neste processo, permitindo que haja um aumento mais rápido no número de threads do ThreadPool, mas ainda assim a ideia principal se mantém.
Vamos a um exemplo para deixar mais claro: suponha um computador com 4 cores. O valor mínimo do ThreadPool será 4. Se todas as Tasks que chegarem processarem rapidamente seu trabalho, o pool poderá inclusive ter menos do que o mínimo de 4 threads ativas. Agora, imagine que 4 Tasks de duração um pouco mais longa chegaram simultaneamente, utilizando então todas as threads do pool. Quando a próxima Task chegar à fila, ele precisará esperar entre 1 e 2 segundos, até que uma nova thread seja adicionada ao pool, para então sair da fila e começar a processar. Se esta nova Task também tiver uma duração mais longa, as próximas Tasks esperarão novamente na fila e precisarão “pagar o pedágio” de 1 a 2 segundos antes de poderem começar a executar.
Se esse comportamento de novas Tasks de longa duração se mantiver por algum tempo, a sensação para os clientes deste processo será de lentidão, para qualquer nova tarefa que chegar à fila do ThreadPool. Este cenário é chamado de exaustão do ThreadPool (ThreadPool exhaustion ou ThreadPool starvation). Isso ocorrerá até que as Tasks finalizem seu trabalho e comecem a retornar as threads ao pool, possibilitando a redução da fila de Tasks pendentes, ou que o pool consiga crescer suficientemente para atender à demanda atual. Isso pode demorar vários segundos, dependendo da carga, e só então a lentidão observada anteriormente deixará de existir.
Código síncrono x assíncrono
É preciso agora fazer uma distinção importante sobre tipos de trabalho de longa duração. Geralmente eles podem ser classificados em 2 tipos: limitados pela CPU/GPU (CPU-bound ou GPU-bound), como a execução de cálculos complexos, ou limitados por operações de entrada/saída (I/O-bound), como o acesso a bancos de dados ou chamadas à rede.
No caso de tarefas CPU-bound, salvo otimizações de algoritmos, não há muito o que fazer: é preciso ter processadores em quantidade suficiente para atender à demanda.
Mas, no caso de tarefas I/O-bound, é possível liberar o processador para responder a outras requisições enquanto se espera a finalização da operação de I/O. E é exatamente isso que o ThreadPool faz quando APIs assíncronas de I/O são usadas. Neste caso, mesmo que a tarefa específica ainda seja demorada, a thread será retornada para o pool e poderá atender a uma outra Task da fila. Quando a operação de I/O finalizar, a Task será enfileirada novamente para então continuar a executar. Para saber mais detalhes sobre como o ThreadPool aguarda o fim de operações de I/O, clique aqui.
Entretanto, é importante observar que ainda existem APIs síncronas de I/O, que causam o bloqueio da thread e impedem sua liberação para o pool. Estas APIs – e qualquer outro tipo de chamada que bloqueie uma thread antes de retornar a execução – comprometem o bom funcionamento do ThreadPool, podendo causar sua exaustão quando submetidos a cargas suficientemente grandes e/ou longas.
Podemos dizer então que o ThreadPool – e por extensão o ASP.NET Core/Kestrel, desenhados para operar assincronamente – é otimizado para execução de tarefas de baixa complexidade computacional, com cargas I/O bound assíncronas. Neste cenário, um pequeno número de threads é capaz de processar um número bastante elevado de tasks/requisições de maneira eficiente.
Bloqueio de threads com ASP.NET Core
Vamos ver alguns exemplos de código que causam o bloqueio de threads do pool, usando ASP.NET Core 8.
Observação: estes códigos são exemplos simples, que não visam representar nenhuma prática, recomendação ou estilo em especial, exceto os pontos relacionados à demonstração do ThreadPool especificamente.
Para manter o comportamento idêntico entre os exemplos, será usado uma requisição a banco de dados SQL Server que simulará uma carga de trabalho que demora 1 segundo para retornar, usando a sentença WAITFOR DELAY.
Para gerar uma carga de utilização e demonstrar os efeitos práticos de cada exemplo, utilizaremos o siege, um utilitário de linha de comando gratuito destinado a esta finalidade.
Em todos os exemplos será simulada uma carga de 120 acessos concorrentes durante 1 minuto, com um atraso aleatório de até 200 milissegundos entre as requisições. Estes números são suficientes para demonstrar os efeitos sobre o ThreadPool sem gerar timeouts no acesso ao banco de dados.
Versão Síncrona
Vamos começar com uma implementação completamente síncrona:
A action DbCall é síncrona, e o método ExecuteNonQuery do DbCommand/SqlCommand é síncrono, portanto, bloqueará a thread até que haja o retorno do banco de dados. Abaixo está o resultado da simulação da carga (com o comando siege utilizado).
Vejam que conseguimos uma taxa de 27 requisições por segundo (Transaction rate), e um tempo de resposta médio (Response time) de cerca de 4 segundos, com a requisição mais longa (Longest transaction) durando mais de 16 segundos – um desempenho bastante precário.
Versão Assíncrona – Tentativa 1
Vamos agora utilizar uma action assíncrona (retornando Task<string>), mas ainda utilizar o método síncrono ExecuteNonQuery.
Executando o mesmo cenário de carga anterior, temos o seguinte resultado.
Vejam que o resultado foi ainda pior neste caso, com taxa de requisições de 14 por segundo (contra 27 da versão completamente síncrona) e tempo médio de resposta de mais de 7 segundos (contra 4 da anterior).
Versão Assíncrona – Tentativa 2
Nesta próxima versão, temos uma implementação que exemplifica uma tentativa comum – e não recomendada – de transformar uma chamada de I/O síncrona (no nosso caso, o ExecuteNonQuery ) em uma “API assíncrona”, usando Task.Run.
O resultado, após a simulação, mostra que o resultado é próximo da versão síncrona: taxa de requisições de 24 por segundo, tempo médio de resposta de mais de 4 segundos e requisição mais longa levando mais de 14 segundos para retornar.
Versão Assíncrona – Tentativa 3
Agora a variação conhecida como “sync over async”, onde utilizamos métodos assíncronos, como o ExecuteNonQueryAsync deste exemplo, mas é chamado o método .Wait() da Task retornada pelo método, como mostrado abaixo. Tanto o .Wait() quanto a propriedade .Result de uma Task tem o mesmo comportamento: causam o bloqueio da thread em execução!
Executando nossa simulação, podemos ver abaixo como o resultado também é ruim, com taxa de 32 requisições por segundo, tempo médio de mais de 3 segundos, com requisições chegando a levar 25 segundos para retornar. Não à toa, o uso de .Wait() ou .Result em uma Task é desaconselhado em código assíncrono.
Solução do problema
Finalmente, vamos ao código criado para funcionar da forma mais eficiente, através do de APIs assíncronas e aplicando async / await corretamente, seguindo recomendação da Microsoft.
Temos então a action assíncrona, com a chamada ExecuteNonQueryAsync com await.
O resultado da simulação fala por si só: taxa de requisições de 88 por segundo, tempo médio de resposta de 1,23 segundos e requisição levando no máximo 3 segundos para retornar – números em geral 3 vezes melhores que qualquer opção anterior.
A tabela abaixo sumariza os resultados das diferentes versões, para uma melhor comparação dos dados entre elas.
Versão do código | Taxa de requisições ( /s) | Tempo médio (s) | Tempo máximo (s) |
Síncrona | 27,38 | 4,14 | 16,93 |
Assíncrona 1 | 14,33 | 7,94 | 14,03 |
Assíncrona 2 | 24,90 | 4,57 | 14,80 |
Assíncrona 3 | 32,43 | 3,52 | 25,03 |
Solução | 88,91 | 1,23 | 3,18 |
Solução paliativa
Vale mencionar que podemos configurar o ThreadPool para ter um número mínimo de threads maior do que o padrão (o número de processadores lógicos). Com isto, ele conseguirá rapidamente aumentar o número de threads sem pagar aquele “pedágio” de 1 ou 2 segundos.
Existem pelo menos 3 formas para se fazer isto: por configuração dinâmica, usando o arquivo runtimeconfig.json, por configuração do projeto, ajustando a propriedade ThreadPoolMinThreads, ou por código, chamando o método ThreadPool.SetMinThreads.
Isto deve ser encarado como uma medida temporária, enquanto não se faz os devidos ajustes em código como mostrado anteriormente, ou após os devidos testes prévios para confirmar que traz benefícios sem efeitos colaterais de desempenho, conforme recomendação pela Microsoft.
Conclusão
A exaustão do ThreadPool é um detalhe de implementação que pode trazer consequências inesperadas. E que podem ser difíceis de detectar se considerarmos que .Net possui várias maneiras de obter o mesmo resultado, mesmo em suas APIs mais conhecidas – acredito que motivado por anos de evoluções na linguagem e do ASP.NET, sempre visando compatibilidade retroativa.
Quando falamos de funcionamento em taxas ou volumes crescentes, como ao passar de dezenas para centenas de requisições, é essencial conhecer as práticas e recomendações mais recentes. Além disso, conhecer um ou outro detalhe de implementação pode ser um diferencial para se evitar problemas de escala ou diagnosticá-los mais rapidamente.
Fique de olho nas próximas publicações do Proud Tech Writers. Em um próximo artigo, vamos explorar como diagnosticar a exaustão do ThreadPool e identificar a origem do problema em código a partir de um processo em execução.