// CASE DE ESTUDO · CONCORRÊNCIA .NET 10 + KUBERNETES

Por que seu /health simples falha sob ThreadPool starvation?

Ciclo de vida do agendador de threads no .NET, a dinâmica de SetMinThreads, o comportamento do Kestrel vs. HttpListener e por que um listener com thread dedicada contorna os restarts do Kubernetes.

ATIVO .NET 10 THREADPOOL KUBERNETES HEALTH CHECKS DIAGNÓSTICO
Injeção pós-MinThreads
1 a 2 threads/seg

O algoritmo Hill-Climbing retarda a criação de threads quando todas estão bloqueadas.

Timeout típico no K8s
1s a 3s

A probe morre muito antes do runtime conseguir injetar threads suficientes.

Causa raiz predominante
Sync-over-Async

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.

Fila global · 8 worker threads mínimas

Cada tick simula 500ms de runtime. Os 8 workers são o MinThreads padrão do container.

Threads ocupadas / Min
0 / 8
Itens na fila global
0
Estado do runtime
Normal (livre)
Última probe K8s
Aguardando teste
Worker threads ativas (ThreadPool gerenciada)Priority: Normal
Thread dedicada do SO (isolada da pool)Priority: AboveNormal
OS-Dedicated-Liveness-Worker
Loop síncrono em listener.GetContext()
Pronta (0ms)
Fila de itens & log em tempo real
[00:00:00] Runtime .NET inicializado. ThreadPool com 8 workers mínimos.

# 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.

01

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:

Throughput = (Tasks Concluídas) / Δt
// 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.

Impacto: Kestrel enfileira na fila global e sofre starvation direta.
02

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.

Impacto: o socket conecta, mas o corpo da resposta nunca é despachado a tempo.
03

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):

Syscall: accept() ou select()
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.

Impacto: resposta determinística com jitter sub-milissegundo garantido.

# 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ático
Vantagens

Reutiliza alocadores de buffers (ArrayPool), suporte total a TLS/HTTPS, compressão, DI e todo o ecossistema ASP.NET Core.

Trade-offs & penalidades

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.

Cenário ótimo

Sistemas puramente assíncronos (zero sync-over-async) e probes de Readiness.

MetricServer (:1234)

Scrape isolado
Vantagens

Remove a raspagem de métricas volumosas do pipeline público do Kestrel, evitando que scrapes externos poluam logs de acesso da API.

Trade-offs & penalidades

Falsa sensação de isolamento: a porta é distinta, mas a execução assíncrona dos delegates ainda consome worker threads da mesma ThreadPool compartilhada.

Cenário ótimo

Coleta de telemetria em redes internas com Prometheus, sem expor métricas na internet.

Listener dedicado (OS thread)

Contingência / mitigação
Vantagens

Imunidade absoluta à starvation: responde a probes em tempo determinístico (<1ms), eliminando o loop de reinício (CrashLoop) no Kubernetes.

Trade-offs & penalidades críticas

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.

Cenário ótimo

Exclusivamente como Liveness Probe temporário, em monolitos legados durante refatoração de código síncrono.

⚠ Liveness vs Readiness no Kubernetes

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() por await de ponta a ponta elimina a causa raiz; ferramentas como o analyzer VSTHRD002 (Roslyn) sinalizam esses pontos em build.
  • HealthCheckOptions.Timeout maior no MapHealthChecks reduz falso-positivo, mas se o timeoutSeconds/periodSeconds do Kubernetes não acompanhar, o kubelet derruba o Pod primeiro mesmo assim.
  • ASPNETCORE_<n>=/DOTNET_ThreadPool_UsePortableThreadPool e 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.

Passo 1 · Métricas em tempo real dotnet-counters

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.

Passo 2 · Memory dump dotnet-dump / SOS

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
> threadpool // exibe itens pendentes e starvation status
> 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.

ThreadPriority.AboveNormal
// 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.