O algoritmo Hill-Climbing retarda a criação de threads quando todas estão bloqueadas.
A probe morre muito antes do runtime conseguir injetar threads suficientes.
Uso de .Result, .Wait() ou bloqueios pesados em worker threads.
# Simulador interativo: fila global da ThreadPool
Dispare carga bloqueante e observe como os 3 mecanismos de resposta reagem à saturação da pool.
Cada tick simula 500ms de runtime. Os 8 workers são o MinThreads padrão do container.
listener.GetContext()# Fundamentação teórica e trade-offs
Fundamentos de concorrência que explicam por que métodos tradicionais falham catastroficamente sob saturação de threads, e o custo sistêmico de cada modelo arquitetural.
Heurística Hill-Climbing e fila global
O CLR não aloca threads arbitrariamente para evitar degradação por context switching e colapso de coerência de cache L1/L2/L3. Usa uma heurística de Teoria de Controle Feedback-Loop:
// se Throughput não sobe com +Threads,
// o CLR freia a injeção para 1 thread a cada ~500ms-1000ms
Quando tarefas bloqueadas em .Result ocupam todas as MinThreads, o
Throughput medido cai a zero. A heurística hesita e atrasa a alocação de novas threads, gerando o
deadly lag.
Kernel I/O vs userspace continuation
Há uma dicotomia essencial entre aceitar uma conexão e
processar seu payload. No Linux (epoll/io_uring) ou
Windows (IOCP):
- O handshake TCP de camada 4 ocorre inteiramente na pilha do kernel.
- A notificação de prontidão de I/O acorda uma thread de I/O do CLR.
- Porém, a continuação da Task (
await) deve ser agendada na ThreadPool de trabalho.
O MetricServer consegue concluir o handshake TCP no socket, mas o método que formata o
payload UTF-8 das métricas fica aguardando despacho de execução.
Kernel scheduler & bloqueio syscall
Uma thread dedicada criada com new Thread() é registrada como entidade de primeira
classe na tabela de processos do kernel (task_struct no Linux / ETHREAD no
Windows):
Estado no kernel: TASK_INTERRUPTIBLE
// consumo de CPU = 0% enquanto bloqueada
Ao receber os pacotes do kubelet, a interrupção de hardware (NIC IRQ) faz o escalonador do SO colocar
a thread dedicada em TASK_RUNNING. Ela atende o socket sem interagir com filas
gerenciadas.
# Matriz de compromissos arquiteturais
Nenhuma solução de software é isenta de custos. Avalie as penalidades operacionais e de engenharia de cada escolha:
Kestrel nativo (/health)
Padrão idiomáticoReutiliza alocadores de buffers (ArrayPool), suporte total a TLS/HTTPS, compressão, DI e todo o ecossistema ASP.NET Core.
Acoplamento total ao destino da aplicação: se uma rota de negócio travar a pool, a integridade do processo é reportada como falha, disparando SIGKILL no container.
Sistemas puramente assíncronos (zero sync-over-async) e probes de Readiness.
MetricServer (:1234)
Scrape isoladoRemove a raspagem de métricas volumosas do pipeline público do Kestrel, evitando que scrapes externos poluam logs de acesso da API.
Falsa sensação de isolamento: a porta é distinta, mas a execução assíncrona dos delegates ainda consome worker threads da mesma ThreadPool compartilhada.
Coleta de telemetria em redes internas com Prometheus, sem expor métricas na internet.
Listener dedicado (OS thread)
Contingência / mitigaçãoImunidade absoluta à starvation: responde a probes em tempo determinístico (<1ms), eliminando o loop de reinício (CrashLoop) no Kubernetes.
Ocultamento de falhas (failure masking): o container parece saudável pro K8s mesmo que nenhuma requisição de cliente esteja sendo processada. Exige controle rígido de Readiness em paralelo.
Exclusivamente como Liveness Probe temporário, em monolitos legados durante refatoração de código síncrono.
Um Pod que não responde ao Kestrel não deve ser sumariamente eliminado por Liveness se estiver apenas temporariamente lento (ex.: processando uma fila pesada ou aguardando Hill-Climbing). A função da Liveness Probe é detectar deadlock irrecuperável ou falha catastrófica de memória. A função da Readiness Probe é retirar o Pod do balanceamento de carga para que novas requisições não cheguem até a fila ser drenada. Usar a thread dedicada só no Liveness garante conformidade exata com essa separação de responsabilidades.
# Como decidir qual mecanismo usar
Fluxo de decisão prático — pensado pra times que herdaram um monolito com sync-over-async espalhado e não podem corrigir tudo antes do próximo deploy.
flowchart TD
A["Pod reiniciando em\nCrashLoopBackOff?"] -->|Sim| B{"dotnet-counters mostra\nThreadPool Queue Length > 0\nde forma contínua?"}
A -->|Não| Z["Sem starvation.\nInvestigue outra causa\n(OOMKilled, exceção não tratada)"]
B -->|Sim| C{"É viável corrigir o\nsync-over-async agora?"}
B -->|Não| Z
C -->|Sim, curto prazo| D["Corrija .Result/.Wait()\ne use SetMinThreads como\nmitigação temporária"]
C -->|Não, é legado grande| E["Suba um listener dedicado\nem thread própria só para\nlivenessProbe (/livez)"]
E --> F["Mantenha readinessProbe\napontando pro Kestrel normal"]
D --> G["Releia com dotnet-counters\naté Queue Length ficar em 0"]
F --> H["Rastreie o backlog de correção\n— thread dedicada é workaround,\nnão solução definitiva"] # Outras mitigações antes de isolar o listener
O listener dedicado é a última linha de defesa. Antes de chegar lá, vale esgotar ajustes que atacam a causa raiz em vez de mascará-la:
ThreadPool.SetMinThreads(n, n)eleva o piso de threads sempre disponíveis, mas só empurra o problema pra frente — se a carga bloqueante crescer, o novo piso também satura.- Trocar
.Result/.Wait()porawaitde ponta a ponta elimina a causa raiz; ferramentas como o analyzerVSTHRD002(Roslyn) sinalizam esses pontos em build. HealthCheckOptions.Timeoutmaior noMapHealthChecksreduz falso-positivo, mas se otimeoutSeconds/periodSecondsdo Kubernetes não acompanhar, o kubelet derruba o Pod primeiro mesmo assim.ASPNETCORE_<n>=/DOTNET_ThreadPool_UsePortableThreadPoole variáveis de ambiente de tuning do runtime ajudam em cenários específicos, mas exigem medir antes/depois — não são bala de prata.- Separar o processo que sofre a carga bloqueante (ex.: um worker de integração legado) do processo que serve tráfego HTTP evita que a starvation de um contamine o health check do outro.
# Matriz comparativa técnica
Visão detalhada das características de execução e adequação arquitetural para contêineres e Kubernetes.
| Critério | Kestrel (/health) | MetricServer | Listener dedicado |
|---|---|---|---|
| Pipeline e hospedagem | Kestrel compartilhado com a API principal; middlewares e DI completos. | HttpListener isolado em porta dedicada (ex.: :1234). | Socket ou HttpListener cru rodando em loop síncrono. |
| Alocação de threads | Enfileira requisição inteira na ThreadPool. | Continuação de await despachada na ThreadPool. | Thread nativa dedicada do SO (new Thread()). Zero dependência da pool. |
| Comportamento em starvation | Falha / timeout (Pod reinicia) | Atraso / timeout nas métricas | Responde em <1ms |
| Overhead de memória | Zero adicional (reusa pools do Kestrel). | Baixo (~alguns KB para buffers do listener). | ~1 MB a 1.5 MB por thread para a stack nativa. |
| Uso recomendado | Padrão para readinessProbe e serviços 100% assíncronos. | Coleta padrão de métricas fora da rota pública. | Workaround para livenessProbe em legado com sync-over-async. |
# Playbook de diagnóstico e investigação
Como comprovar tecnicamente que a ThreadPool está em starvation e rastrear a linha exata de código responsável pelas retenções.
Monitorar filas no Pod Kubernetes
Execute via container temporário ou debug terminal no Pod para inspecionar os contadores de runtime:
dotnet-counters monitor -p 1 --counters System.Runtime
ThreadPool Queue Length: se permanecer maior que 0 de forma contínua, requisições estão retidas aguardando threads.
ThreadPool Thread Count: indica a tentativa lenta do runtime de criar novas threads de trabalho.
Localizar a linha com .Result / .Wait
Gere o dump exatamente quando as probes começarem a falhar e execute a análise SOS:
dotnet-dump collect -p 1 -o /tmp/starve.dmp dotnet-dump analyze /tmp/starve.dmp
> clrthreads // lista todas as threads ativas do CLR
> dumpasync -stacks // revela tasks assíncronas bloqueadas
> eestack // procure por Task.Wait e Monitor.Enter
Implementação: listener isolado para LivenessProbe
Workaround seguro para evitar restarts contínuos no Kubernetes enquanto o código assíncrono é corrigido.
// HostedService ou inicializado no Program.cs
public static class DedicatedLivenessListener
{
private static Thread? _thread;
private static volatile bool _running = true;
public static void Start(int port = 8081)
{
_thread = new Thread(() =>
{
using var listener = new HttpListener();
listener.Prefixes.Add($"http://*:{port}/livez/");
listener.Start();
byte[] okResponse = "OK"u8.ToArray();
while (_running)
{
try
{
// Bloqueio SÍNCRONO puro no SO: NÃO toca na ThreadPool
var context = listener.GetContext();
context.Response.StatusCode = 200;
context.Response.ContentType = "text/plain";
context.Response.ContentLength64 = okResponse.Length;
context.Response.OutputStream.Write(okResponse, 0, okResponse.Length);
context.Response.Close();
}
catch (HttpListenerException) when (!_running) { break; }
catch (Exception ex)
{
Console.Error.WriteLine($"[LivenessListener] Erro: {ex.Message}");
}
}
})
{
IsBackground = true,
Name = "DedicatedLivenessWorkerThread",
Priority = ThreadPriority.AboveNormal // Preempção suave em alta carga de CPU
};
_thread.Start();
}
public static void Stop() => _running = false;
} # Onde ler mais sobre cada assunto
Compilado estruturado por tema com documentações oficiais, código-fonte do runtime, papers de concorrência e guias de diagnóstico.
1. ThreadPool internals, Hill-Climbing & fila de trabalho (FIFO vs LIFO)
Como o CLR gerencia worker threads, I/O completion ports, e o papel de SetMinThreads/SetMaxThreads.
A implementação real em C++ do Hill Climbing (hillclimbing.cpp) e o intervalo de 500ms-1000ms para injeção de threads sob baixa vazão.
Kevin Gosse demonstrando como requisições enfileiradas na fila global (FIFO) competem com continuações assíncronas nas filas locais (LIFO/work-stealing).
2. O antipadrão sync-over-async (.Result / .Wait) e concorrência assíncrona
A bíblia prática de ASP.NET Core: regras de "DO" e "DO NOT" para chamadas síncronas, cancelamento e criação segura de background threads.
Diretrizes oficiais condenando Task.Wait() e Task.Result no pipeline web para evitar esgotamento de threads e deadlocks.
3. Diagnóstico em produção, memory dumps & profiling
4. Kubernetes probes (Liveness vs Readiness) & escalonador do SO
Semântica exata: por que Liveness só deve reiniciar o Pod quando o processo é irrecuperável, e Readiness deve desviar tráfego em lentidão temporária.
Como o escalonador preemptivo atribui quantum e como ThreadPriority.AboveNormal ganha preferência sobre threads normais.