Conteúdo

Thread pool starvation é uma das condições mais insidiosas em aplicações .NET. Ela ocorre quando todas as threads do pool estão bloqueadas em operações síncronas, e o runtime não consegue injetar novas threads rápido o suficiente para atender as requisições que continuam chegando. O resultado é uma aplicação que parece travada: requests ficam na fila por segundos ou minutos, timeouts disparam em cascata, e o Kestrel começa a emitir avisos de heartbeat. Este artigo apresenta os cinco padrões de código que mais causam starvation, com exemplos funcionais do problema e da correção. Além dos padrões destrutivos, você aprenderá a diagnosticar thread pool starvation em tempo real usando dotnet-counters, dotnet-stack e dotnet-trace. Verá como configurar monitoramento contínuo com OpenTelemetry para detectar o problema antes que ele afete usuários. E entenderá por que ThreadPool.SetMinThreads é uma solução temporária que pode, inclusive, falhar silenciosamente. Todos os exemplos de código são 100% funcionais em .NET 8+ e podem ser copiados e testados localmente.

Insights

  1. O thread pool do .NET injeta novas threads a uma taxa de apenas 1-2 por segundo: Quando todas as threads estão bloqueadas, a aplicação leva dezenas de segundos para se recuperar. Cada thread bloqueada é um recurso que não volta.
  2. Sync-over-async é o padrão mais destrutivo em aplicações ASP.NET Core: Chamar Task.Result ou Task.Wait() dentro de um handler HTTP bloqueia a thread do pool enquanto espera uma operação que, ironicamente, foi projetada para não bloquear.
  3. O Kestrel emite um aviso claro quando detecta starvation: A mensagem Heartbeat took longer than "00:00:01" é o sinal mais visível de que o thread pool está exausto. Se você vê essa mensagem nos logs, o problema já está acontecendo.
  4. dotnet-counters e dotnet-stack são suficientes para diagnosticar 90% dos casos: Monitore threadpool-thread-count crescendo acima de 50 e threadpool-queue-length acima de 0 persistentemente. Use dotnet-stack para encontrar as threads bloqueadas.
  5. ThreadPool.SetMinThreads é um curativo, não uma cura: Aumentar o número mínimo de threads apenas adia o problema. A única solução real é eliminar os bloqueios síncronos do código.

O que é thread pool starvation e por que ela derruba aplicações

O CLR mantém um pool de threads reutilizáveis para executar trabalho assíncrono, callbacks de I/O e itens enfileirados via ThreadPool.QueueUserWorkItem. Esse pool começa com um número de threads igual ao número de processadores lógicos da máquina. Quando todos os threads estão ocupados, o runtime detecta que há trabalho enfileirado e começa a injetar novas threads — mas a uma taxa deliberadamente lenta de 1 a 2 threads por segundo.

Essa taxa lenta é intencional. O algoritmo de hill climbing do thread pool tenta encontrar o número ideal de threads para maximizar throughput sem sobrecarregar o sistema com troca de contexto excessiva. Em condições normais, isso funciona perfeitamente. O problema começa quando as threads não estão executando trabalho real — elas estão bloqueadas, esperando por algo.

Quando uma thread bloqueia, ela sai do jogo. Ela não está disponível para processar outras requisições, mas também não “devolve” seu espaço no pool. Se 100 requisições chegam simultaneamente e cada uma bloqueia uma thread com Task.Result, o pool precisa criar 100 novas threads. A 1-2 por segundo, isso leva de 50 a 100 segundos. Enquanto isso, as requisições ficam na fila, timeouts disparam, e a aplicação parece completamente travada.

flowchart TD
    A[Requisição HTTP chega] --> B[ThreadPool atribui uma thread]
    B --> C{Thread executa código bloqueante?}
    C -->|Não| D[Thread completa e retorna ao pool]
    C -->|Sim| E[Thread fica bloqueada esperando]
    E --> F[ThreadPool detecta fila crescendo]
    F --> G[Injeta 1-2 threads por segundo]
    G --> H{Novas threads também bloqueiam?}
    H -->|Sim| E
    H -->|Não| D
    F --> I[Requisições acumulam na fila]
    I --> J[Latência dispara para segundos]
    J --> K[Kestrel heartbeat warning]
    K --> L[Timeouts em cascata]

O impacto no ASP.NET Core e no Kestrel

O Kestrel, servidor web do ASP.NET Core, depende diretamente do thread pool para processar requisições. Ele mantém um timer interno chamado heartbeat que dispara a cada segundo para realizar tarefas de manutenção como verificar timeouts de conexão e gerenciar keep-alive. Quando o thread pool está esgotado, até o callback do heartbeat fica na fila esperando uma thread disponível.

Quando o heartbeat leva mais de 1 segundo para ser executado, o Kestrel emite este aviso nos logs:

warn: Microsoft.AspNetCore.Server.Kestrel[22]
      Heartbeat took longer than "00:00:01" at "2024-01-15T10:23:45.1234567Z".
      This could be caused by thread pool starvation.

Se você vê essa mensagem, o problema já está acontecendo. A aplicação já está degradada e os usuários já estão sendo afetados. O ideal é detectar a starvation antes que ela chegue a esse ponto.

Cinco padrões de código que causam thread pool starvation

Os padrões a seguir são os causadores mais comuns de thread pool starvation em aplicações ASP.NET Core. Para cada um, apresento o código que causa o problema, a explicação de por que ele bloqueia threads, e a correção correta.

Padrão 1 — Task.Result e Task.Wait() bloqueiam a thread do pool

O padrão sync-over-async é o mais comum e o mais destrutivo. Ele ocorre quando código síncrono chama uma operação assíncrona e bloqueia a thread esperando o resultado.

Código que causa starvation:

Por que causa starvation: quando Task.Result é chamado, a thread atual do pool entra em estado de espera (blocking wait). Ela não é devolvida ao pool. Se a chamada HTTP leva 500ms e 200 requisições chegam simultaneamente, 200 threads ficam bloqueadas por 500ms cada. O thread pool precisa de 100 segundos para injetar 200 novas threads. Durante esse tempo, a fila cresce e a aplicação trava.

Código corrigido:

Com await, a thread é devolvida ao pool imediatamente quando a operação de I/O inicia. Quando a resposta HTTP chega, qualquer thread disponível do pool continua a execução. Nenhuma thread fica bloqueada.

Padrão 2 — Thread.Sleep dentro de métodos async congela a thread

Thread.Sleep suspende a thread atual pelo tempo especificado. Em código assíncrono, isso desperdiça uma thread do pool que poderia estar processando outras requisições.

Código que causa starvation:

Por que causa starvation: Thread.Sleep(5000) mantém a thread do pool suspensa por 5 segundos inteiros. Se 50 requisições de pedido chegam ao mesmo tempo, 50 threads ficam dormindo por 5 segundos. Em uma máquina com 8 núcleos, o pool começa com 8 threads. As primeiras 8 requisições bloqueiam todas as threads. As 42 restantes esperam na fila enquanto o runtime injeta 1-2 threads por segundo.

Código corrigido:

Task.Delay registra um timer e devolve a thread ao pool imediatamente. Quando o timer dispara, uma thread disponível continua a execução. Zero threads bloqueadas.

Padrão 3 — I/O síncrono no corpo da requisição bloqueia a thread

Ler o corpo de uma requisição HTTP de forma síncrona bloqueia a thread do pool enquanto os dados são transferidos da rede.

Código que causa starvation:

Por que causa starvation: ReadToEnd() é uma operação síncrona que bloqueia a thread até que todo o corpo da requisição seja lido da stream de rede. Para payloads grandes (1MB+), isso pode levar centenas de milissegundos. Para uploads lentos, pode levar segundos. A thread fica bloqueada durante toda a transferência.

Código corrigido:

JsonSerializer.DeserializeAsync lê a stream de forma assíncrona, devolvendo a thread ao pool enquanto espera dados da rede. Além de não bloquear, é mais eficiente em memória porque não precisa alocar uma string intermediária com todo o JSON.

Padrão 4 — SemaphoreSlim.Wait() bloqueia quando deveria esperar de forma assíncrona

SemaphoreSlim é frequentemente usado para limitar concorrência. A versão síncrona Wait() bloqueia a thread, enquanto WaitAsync() a libera.

Código que causa starvation:

Por que causa starvation: se o semáforo permite 3 execuções simultâneas e 50 requisições chegam, 3 threads executam o relatório e as outras 47 ficam bloqueadas em Wait(). Essas 47 threads estão bloqueadas, não disponíveis para processar outras requisições. Se a geração do relatório leva 10 segundos, essas 47 threads ficam presas por até 10 segundos.

Código corrigido:

Com WaitAsync(), as 47 threads que não conseguem entrar no semáforo são devolvidas ao pool. Elas ficam disponíveis para processar outras requisições. Quando uma vaga no semáforo abre, qualquer thread disponível continua a execução.

Padrão 5 — acesso síncrono a HttpContext.Request.Form bloqueia a leitura do corpo

Acessar Request.Form de forma síncrona força a leitura completa do corpo da requisição de forma bloqueante.

Código que causa starvation:

Por que causa starvation: a propriedade Request.Form internamente chama ReadForm(), que lê e faz o parse de todo o corpo da requisição de forma síncrona. Para uploads de arquivos, isso pode levar vários segundos dependendo do tamanho do arquivo e da velocidade da conexão. A thread fica bloqueada durante toda a leitura.

Código corrigido:

ReadFormAsync() lê o corpo da requisição de forma assíncrona, devolvendo a thread ao pool enquanto os dados são transferidos da rede.

Como diagnosticar thread pool starvation com ferramentas do .NET

O diagnóstico de thread pool starvation requer combinar métricas em tempo real com análise de call stacks. As ferramentas da plataforma .NET fornecem tudo o que você precisa.

Diagnóstico com dotnet-counters

dotnet-counters é a ferramenta mais rápida para confirmar se starvation está ocorrendo. Instale e execute:

Os três contadores mais importantes para diagnosticar starvation:

ContadorValor normalValor sob starvationO que indica
threadpool-thread-countPróximo ao número de CPUs (4-16)Crescendo continuamente (50, 100, 200+)Threads sendo criadas para compensar bloqueios
threadpool-queue-length0 ou próximo de 0Acima de 0 persistentemente (10, 50, 100+)Trabalho acumulado esperando thread disponível
cpu-usageProporcional à cargaBaixo mesmo com alta latênciaThreads bloqueadas não consomem CPU

O padrão clássico de starvation é: CPU baixa, thread count crescendo, queue length acima de zero. Se a CPU está baixa mas o número de threads continua subindo, as threads não estão executando trabalho real — elas estão bloqueadas.

Diagnóstico com dotnet-stack

Depois de confirmar que há starvation com dotnet-counters, use dotnet-stack para identificar onde as threads estão bloqueadas:

Procure por estes padrões nas call stacks — eles indicam threads bloqueadas:

# Padrão 1: sync-over-async (Task.Result / Task.Wait)
System.Threading.Tasks.Task.SpinThenBlockingWait
System.Threading.ManualResetEventSlim.Wait

# Padrão 2: Thread.Sleep
System.Threading.Thread.Sleep

# Padrão 3: Locks e semáforos síncronos
System.Threading.SemaphoreSlim.Wait
System.Threading.Monitor.Enter

# Padrão 4: I/O síncrono
System.IO.StreamReader.ReadToEnd
System.IO.Stream.Read

Se dezenas de threads mostram o mesmo padrão de bloqueio, você encontrou a causa raiz. Siga a stack para cima até encontrar o código da aplicação que está chamando a operação bloqueante.

O aviso do Kestrel

O Kestrel monitora automaticamente a saúde do thread pool através do seu mecanismo de heartbeat. Quando o heartbeat atrasa, o seguinte aviso é registrado:

warn: Microsoft.AspNetCore.Server.Kestrel[22]
      Heartbeat took longer than "00:00:01" at "2024-01-15T10:23:45.1234567Z".
      This could be caused by thread pool starvation.

Para capturar essa mensagem, certifique-se de que o nível de log para Microsoft.AspNetCore.Server.Kestrel está em Warning ou inferior no appsettings.json:

Diagnóstico avançado com dotnet-trace no .NET 9+

A partir do .NET 9, o runtime emite eventos WaitHandleWait que permitem rastrear exatamente quais operações de espera estão bloqueando threads. Use dotnet-trace para capturá-los:

Abra o trace no PerfView ou Speedscope para visualizar quais wait handles estão causando bloqueios e por quanto tempo.

Monitoramento contínuo em produção com OpenTelemetry

Diagnosticar starvation em desenvolvimento é relativamente simples. O desafio real é detectá-la em produção antes que afete usuários. OpenTelemetry é a solução padrão da indústria para isso.

Configurando métricas de runtime com OpenTelemetry

Instale os pacotes necessários:

Configure a instrumentação de runtime no Program.cs:

Métricas expostas para monitoramento de starvation

Com a instrumentação de runtime ativa, as seguintes métricas ficam disponíveis:

Métrica OpenTelemetryDescriçãoAlerta recomendado
dotnet.thread_pool.thread.countNúmero atual de threads no poolAcima de 3x o número de CPUs por mais de 30 segundos
dotnet.thread_pool.queue.lengthItens na fila esperando threadAcima de 0 por mais de 10 segundos
dotnet.thread_pool.completed_items.countTotal de itens completadosQueda abrupta indica starvation

Exemplo de regra de alerta no Prometheus

ThreadPool.SetMinThreads é um curativo, não uma cura

Quando thread pool starvation é diagnosticada, a reação comum é aumentar o número mínimo de threads:

Isso funciona como paliativo, mas traz problemas:

  1. Não resolve a causa raiz: as threads continuam bloqueadas. Você apenas tem mais threads para bloquear antes que a fila comece a crescer.
  1. Cada thread consome ~1MB de stack: 200 threads extras consomem 200MB de memória só em stacks. Em containers com limite de memória, isso pode causar OOMKill.
  1. SetMinThreads pode falhar silenciosamente: se você chamar SetMinThreads com um valor menor que o atual ou com valores inválidos, ele retorna false sem lançar exceção. Muitos desenvolvedores não verificam o retorno:

Configuração via runtimeconfig.json

Para aplicações que não podem ser modificadas em código, é possível configurar o mínimo de threads via runtimeconfig.json:

Ou via variável de ambiente:

Use SetMinThreads apenas como medida emergencial enquanto trabalha na correção real: eliminar os bloqueios síncronos do código.

Programa completo que simula e diagnostica thread pool starvation

O programa abaixo cria uma API ASP.NET Core que demonstra starvation em ação. Ele expõe dois endpoints: um que causa starvation e outro que funciona corretamente. Você pode usar dotnet-counters para observar a diferença em tempo real.

Script para reproduzir starvation

Use o seguinte script bash para gerar carga no endpoint que causa starvation:

Compare com o mesmo teste no endpoint correto:

Resultado esperado:

  • /starvation/{id}: as primeiras requisições completam em ~2s, mas as últimas levam 20-50s porque ficam na fila esperando threads
  • /correto/{id}: todas as requisições completam em ~2s porque nenhuma thread é bloqueada

Observando com dotnet-counters

Enquanto o teste de starvation executa, observe no dotnet-counters:

[System.Runtime]
    ThreadPool Thread Count                            87      ← crescendo rapidamente
    ThreadPool Queue Length                             23      ← trabalho acumulado
    ThreadPool Completed Work Item Count          1,247,891
    CPU Usage (%)                                       3      ← CPU baixa = threads bloqueadas

Compare com o teste do endpoint correto:

[System.Runtime]
    ThreadPool Thread Count                             9      ← estável
    ThreadPool Queue Length                              0      ← sem fila
    ThreadPool Completed Work Item Count          1,248,102
    CPU Usage (%)                                      12      ← CPU proporcional à carga

Resumo das boas práticas para evitar thread pool starvation

PráticaAção
Nunca use Task.Result ou Task.Wait()Substitua por await
Nunca use Thread.Sleep em código asyncSubstitua por await Task.Delay()
Nunca leia streams de forma síncronaUse ReadToEndAsync() ou DeserializeAsync()
Nunca use SemaphoreSlim.Wait() em código asyncSubstitua por await WaitAsync()
Nunca acesse Request.Form diretamenteUse await ReadFormAsync()
Monitore threadpool-thread-count e threadpool-queue-lengthConfigure alertas no OpenTelemetry
Trate SetMinThreads como medida emergencialCorrija o código bloqueante
Configure logs do Kestrel em nível WarningCapture avisos de heartbeat

FAQ – Perguntas Frequentes

1. Como sei se minha aplicação está sofrendo thread pool starvation e não outro tipo de problema de performance?

O padrão característico de thread pool starvation é: alta latência com CPU baixa. Se a CPU está abaixo de 20% mas as requisições demoram 10-30 segundos, provavelmente há threads bloqueadas. Confirme com dotnet-counters: se threadpool-thread-count está crescendo continuamente acima do número de CPUs e threadpool-queue-length permanece acima de zero, é starvation. Em problemas de CPU (hot path, algoritmo ineficiente), a CPU estaria alta. Em problemas de memória (GC pressure), o GC pause time estaria alto. Starvation é especificamente threads bloqueadas com CPU ociosa.

2. Por que o .NET não cria threads mais rápido quando detecta starvation?

O algoritmo de hill climbing do thread pool usa uma taxa deliberadamente lenta de 1-2 threads por segundo porque criar threads em excesso causa problemas piores: troca de contexto excessiva, consumo de memória (cada thread usa ~1MB de stack) e contenção de locks. O runtime assume que os bloqueios são temporários e que a demanda vai diminuir. A solução correta não é criar threads mais rápido, mas não bloquear threads em primeiro lugar. O design assíncrono do .NET é construído em torno dessa premissa: operações de I/O devem usar await para devolver a thread ao pool durante a espera.

3. ConfigureAwait(false) ajuda a prevenir thread pool starvation?

ConfigureAwait(false) não previne starvation diretamente. Ele apenas indica que a continuação após o await não precisa retornar ao synchronization context original (relevante em aplicações WPF/WinForms, não em ASP.NET Core). Em ASP.NET Core, que não tem synchronization context, ConfigureAwait(false) não tem efeito prático. O que previne starvation é usar await em vez de Task.Result ou Task.Wait(). O await em si já é a solução, independente do ConfigureAwait.

4. Thread pool starvation pode acontecer mesmo se todo o meu código é async/await?

Sim, embora seja raro. Pode ocorrer se você usa bibliotecas de terceiros que internamente fazem chamadas síncronas, ou se o volume de trabalho CPU-bound enfileirado via Task.Run é maior do que o pool consegue processar. Outro cenário é quando callbacks de timers ou event handlers executam operações bloqueantes. Use dotnet-stack para inspecionar todas as threads e identificar qual código está bloqueando. Verifique também dependências NuGet — nem todas as bibliotecas seguem boas práticas assíncronas.

5. Quando devo usar ThreadPool.SetMinThreads e qual valor devo configurar?

Use ThreadPool.SetMinThreads apenas como medida emergencial enquanto trabalha na correção real do código bloqueante. O valor depende do cenário: se você tem 100 requisições simultâneas e cada uma bloqueia por 2 segundos, precisaria de pelo menos 100 threads mínimas para evitar starvation durante picos. Uma regra prática é configurar um valor entre 50 e 200. Mas lembre-se: cada thread consome ~1MB de memória. Em containers com 512MB de limite, 200 threads extras podem causar OOMKill. Sempre verifique o retorno de SetMinThreads — ele retorna false se o valor for menor que o mínimo atual do runtime ou inválido, e muitos desenvolvedores ignoram esse retorno silenciosamente.

Compartilhe:

Tiago Tartari

Tiago Tartari

Eu ajudo e capacito pessoas e organizações a transformar problemas complexos em soluções práticas usando a tecnologia para atingir resultados extraordinários.

Qual é o desafio
que você tem hoje?