DEV Community

Alex Volnei Galante
Alex Volnei Galante

Posted on

Kernel Linux para Desenvolvedores Backend - Processos & Threads Parte IV

Este artigo é a continuação da Parte III, recomendo começar por lá:
Kernel Linux para Desenvolvedores Backend — Processos & Threads Parte III

Sumário


Sequência de um Context Switch

O context switch ocorre em resposta a diferentes triggers — preempção por timer, bloqueio em I/O, yield voluntário, ou chegada de processo de maior prioridade.

Thread A (executando)                Kernel                          Thread B (pronta)
        │                               │                                    │
        │   ← timer interrupt →         │                                    │
        │──────────────────────────────►│                                    │
        │                               │  1. Salva registradores de A       │
        │                               │     na kernel stack de A           │
        │                               │                                    │
        │                               │  2. Chama schedule()               │
        │                               │     → CFS seleciona B              │
        │                               │     (menor vruntime)               │
        │                               │                                    │
        │                               │  3. Chama context_switch()         │
        │                               │     a) switch_mm() — se processo   │
        │                               │        diferente: troca CR3        │
        │                               │        (page tables)               │
        │                               │     b) switch_to() — troca         │
        │                               │        kernel stack pointer        │
        │                               │        (RSP para stack de B)       │
        │                               │                                    │
        │                               │  4. Restaura registradores de B    │
        │                               │     da kernel stack de B           │
        │                               │                                    │
        │                               │  5. Retorna para userspace         │
        │                               │──────────────────────────────────► │
        │                               │                                    │
        │  (suspenso)                   │      (executando)                  │
Enter fullscreen mode Exit fullscreen mode

No código do kernel Linux, a função central é context_switch() em kernel/sched/core.c:

/*
 * context_switch - troca para o novo contexto de MM e para
 * a nova thread (task_struct do processo que será executado).
 */
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
               struct task_struct *next)
{
    // Troca do espaço de endereçamento (memory descriptor)
    if (!next->mm) {                          // kernel thread
        next->active_mm = prev->active_mm;    // empresta mm do anterior
    } else {                                  // user process
        switch_mm(prev->active_mm, next->mm, next);  // troca page tables
    }

    // Troca do contexto de execução (registradores, stack)
    switch_to(prev, next, prev);

    return finish_task_switch(prev);
}
Enter fullscreen mode Exit fullscreen mode

Overhead de Context Switch: Custos Diretos e Indiretos

O custo de um context switch vai muito além da simples operação de salvar/restaurar registradores.

Custos diretos (tempo gasto no switch em si)

Componente Custo típico Notas
Salvar/restaurar registradores gerais ~100-200ns 16 registradores de 64 bits
Salvar/restaurar FPU/SSE/AVX ~200-500ns Depende do tamanho do estado SIMD
Chamada a schedule() + decisão ~200-500ns Percorrer a red-black tree do CFS
switch_mm() (troca de CR3) ~100-300ns Apenas entre processos diferentes
Overhead de kernel entry/exit ~100-200ns Transição user↔kernel
Total direto (threads mesmo processo) ~0.5-1.5μs Sem troca de address space
Total direto (processos diferentes) ~1-3μs Com troca de address space

Custos indiretos (efeitos colaterais — frequentemente maiores que custos diretos)

Context Switch: Custos Indiretos

┌────────────────────────────────────────────────────────────────┐
│                                                                │
│  ┌──────────────┐     ┌───────────────┐     ┌───────────────┐  │
│  │  TLB Flush   │     │Cache Pollution│     │Pipeline Flush │  │
│  │              │     │               │     │               │  │
│  │ Custo: ~5μs  │     │ Custo: ~10μs  │     │ Custo: ~1μs   │  │
│  │ (warm-up)    │     │ (warm-up)     │     │ (imediato)    │  │
│  └──────────────┘     └───────────────┘     └───────────────┘  │
│         │                    │                    │            │
│         ▼                    ▼                    ▼            │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ Custo TOTAL efetivo: 5-50μs (dependendo do working set) │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                │
└────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Os custos indiretos podem ser 10-50x maiores que os custos diretos, porque refletem o tempo necessário para "aquecer" as caches depois que o novo processo começa a executar.

TLB Flush: Impacto na Performance

O TLB (Translation Lookaside Buffer) é um cache de traduções de endereços virtuais para físicos. Como cada processo tem seu próprio espaço de endereçamento (page tables diferentes), as entradas do TLB de um processo são inválidas para outro.

O problema

Antes do context switch:
TLB (processo A):
┌────────────────────────────────────┐
│ VPN 0x7f0001 → PFN 0x3A2 (hit!)    │  ← acesso rápido (~1 ciclo)
│ VPN 0x7f0002 → PFN 0x1B5 (hit!)    │
│ VPN 0x400000 → PFN 0x089 (hit!)    │
│ ... (centenas de entradas)         │
└────────────────────────────────────┘

Após context switch para processo B (TLB flush):
TLB:
┌────────────────────────────────────┐
│ (vazio)                            │  ← TODOS os acessos são miss
│ (vazio)                            │     cada miss = page table walk
│ (vazio)                            │     (~10-100 ciclos por miss)
│ ...                                │
└────────────────────────────────────┘

Processo B precisa "aquecer" o TLB:
Acesso 1: VPN 0x500000 → TLB miss → page walk → PFN 0x2C1 (lento!)
Acesso 2: VPN 0x500001 → TLB miss → page walk → PFN 0x2C2 (lento!)
...
Após ~100-1000 acessos: TLB aquecido novamente
Enter fullscreen mode Exit fullscreen mode

Impacto quantitativo

  • TLB L1 (dTLB/iTLB): ~64-128 entradas, miss penalty ~7 ciclos (L2 TLB hit)
  • TLB L2 (STLB): ~512-2048 entradas, miss penalty ~20-100 ciclos (page walk)
  • Para um working set de 100MB em páginas de 4KB: 25.600 páginas — impossível caber no TLB
# Medindo TLB misses com perf
$ perf stat -e dTLB-load-misses,dTLB-loads,iTLB-load-misses \
    -p <pid> sleep 5

Performance counter stats for process 'python3':
         1,234,567  dTLB-load-misses    # 0.15% of all dTLB loads
       823,456,789  dTLB-loads
           123,456  iTLB-load-misses

# TLB miss rate alto (>1%) indica impacto significativo de context switches
# ou working set muito grande
Enter fullscreen mode Exit fullscreen mode

Mitigações

  1. PCID (Process-Context Identifiers): Processadores modernos (Haswell+) suportam tags no TLB que identificam a qual processo cada entrada pertence. Isso permite manter entradas de múltiplos processos no TLB simultaneamente, evitando flush completo.
TLB com PCID:
┌────────────────────────────────────────────┐
│ PCID=1 VPN 0x7f0001 → PFN 0x3A2 (proc A)   │  ← mantido!
│ PCID=1 VPN 0x7f0002 → PFN 0x1B5 (proc A)   │  ← mantido!
│ PCID=2 VPN 0x500000 → PFN 0x2C1 (proc B)   │  ← novo
│ PCID=2 VPN 0x500001 → PFN 0x2C2 (proc B)   │  ← novo
└────────────────────────────────────────────┘
Quando A volta a executar: TLB hits imediatos!
Enter fullscreen mode Exit fullscreen mode
  1. Huge pages (2MB/1GB): Reduzem o número de entradas TLB necessárias. Com páginas de 2MB, 100MB de working set requer apenas 50 entradas TLB (vs 25.600 com 4KB).

  2. Thread affinity: Manter threads no mesmo core reduz TLB pressure — threads do mesmo processo compartilham o address space e portanto as mesmas entradas TLB.

Implicação prática: No Linux com PCID habilitado (default desde kernel 4.14+), o custo de TLB flush em context switches é significativamente reduzido. Porém, KPTI (Kernel Page Table Isolation — mitigação para Meltdown) requer flush parcial de TLB em cada syscall, adicionando overhead (~5-10%) mesmo sem context switch.

Cache Pollution: Efeitos em L1, L2 e L3

O segundo grande custo indireto é a poluição de cache. Quando um processo é escalonado, ele começa a acessar suas regiões de memória — que provavelmente não estão nos caches — expulsando dados do processo anterior.

Hierarquia de cache e impacto

Hierarquia de Cache (servidor típico):
┌─────────────────────────────────────────────────────────────┐
│ L1 Cache (por core)                                         │
│   ├── L1d (dados): 32-48KB, ~4 ciclos latência              │
│   └── L1i (instruções): 32-48KB, ~4 ciclos                  │
│   → Context switch: 100% invalidado (working set diferente) │
├─────────────────────────────────────────────────────────────┤
│ L2 Cache (por core)                                         │
│   └── Unified: 256KB-1.25MB, ~12 ciclos                     │
│   → Context switch: 80-100% invalidado                      │
├─────────────────────────────────────────────────────────────┤
│ L3 Cache (compartilhado entre cores)                        │
│   └── Shared: 16-64MB, ~30-40 ciclos                        │
│   → Context switch no mesmo core: impacto em L3 parcial     │
│   → Migração entre cores: impacto maior                     │
└─────────────────────────────────────────────────────────────┘

Cache miss penalties:
  L1 hit:  ~4 ciclos  (~1.5ns @ 3GHz)
  L2 hit:  ~12 ciclos (~4ns)
  L3 hit:  ~30-40 ciclos (~12ns)
  RAM:     ~200-300 ciclos (~100ns)  ← 60-70x mais lento que L1!
Enter fullscreen mode Exit fullscreen mode

Cenário: API server com context switches frequentes

Servidor com 16 workers competindo por 8 cores:

Worker A (executando query handler):
  - Hot data em L1/L2: connection pool struct, query buffer, hash map
  - Working set: ~200KB em L2

  ← context switch (preempção por timer) →

Worker B começa a executar:
  - Seu working set (~200KB) substitui dados de A no L2
  - Cada acesso de B é um L2 miss inicialmente (~12 ciclos → RAM ~200 ciclos)

  ← context switch (B bloqueia em I/O) →

Worker A retoma:
  - Seus dados NÃO estão mais no L2!
  - Período de "cache warm-up": ~1000-5000 cache misses
  - Overhead efetivo: 1000 × 100ns = ~100μs de penalidade

Impacto em latência da API:
  - Se timer tick = 4ms e handler leva ~2ms
  - ~1 context switch por request em média
  - Cache warm-up adiciona ~50-100μs por request
  - Em p99: múltiplos context switches → +200-500μs
Enter fullscreen mode Exit fullscreen mode

Medindo cache pollution

# Cache misses por context switch
$ perf stat -e cache-misses,cache-references,context-switches \
    -p <pid> sleep 10

Performance counter stats:
        5,234,567  cache-misses        # 3.2% of cache references
      163,580,000  cache-references
           12,456  context-switches

# Cache misses por context switch: 5,234,567 / 12,456 ≈ 420 misses/switch
# Custo estimado: 420 × 100ns = 42μs de warm-up por switch
Enter fullscreen mode Exit fullscreen mode

Reduzindo o Impacto de Context Switches

Para aplicações backend de alta performance, minimizar context switches (ou seu impacto) é uma otimização significativa:

Estratégias de Mitigação:

1. REDUZIR número de context switches:
   ├── Dimensionar workers = cores (evitar oversubscription)
   ├── Usar async I/O (epoll/io_uring) ao invés de thread-per-connection
   ├── Batch processing: processar múltiplos items antes de ceder CPU
   └── Aumentar timeslice para workloads batch (nice, SCHED_BATCH)

2. REDUZIR custo de cada context switch:
   ├── CPU affinity (taskset/sched_setaffinity): manter thread no mesmo core
   ├── NUMA-aware allocation: memória próxima ao core
   ├── Huge pages: menos TLB entries necessárias
   └── Manter working set compacto (cabe no L2/L3)

3. EVITAR migração entre cores:
   ├── cgroups cpuset: pinning de processos a cores específicos
   ├── isolcpus: reservar cores exclusivos para a aplicação
   └── GOMAXPROCS/worker count = cores no cpuset
Enter fullscreen mode Exit fullscreen mode
# Pinning de processo a cores específicos
$ taskset -c 0-3 python3 app.py        # restringe aos cores 0-3

# Isolando cores no boot (grub)
# GRUB_CMDLINE_LINUX="isolcpus=4-7"    # cores 4-7 isolados do scheduler geral

# Verificando context switches de um processo
$ pidstat -w -p <pid> 1
Linux 5.15.0 (server)    05/14/2026
09:00:01 AM   PID   cswch/s nvcswch/s  Command
09:00:02 AM  1350    152.00     12.00  python3
              ↑ voluntary   ↑ involuntary (preempted)
Enter fullscreen mode Exit fullscreen mode

Regra prática para backend:

  • Se nvcswch/s (involuntary) é alto → oversubscription de CPU (mais threads que cores)
  • Se cswch/s (voluntary) é alto → normal para I/O-bound (bloqueia em syscalls)
  • Se ambos são altos → redesenhe a arquitetura (async I/O, menos workers, ou CPU affinity)

Conexão com Desenvolvimento Backend: .NET

O .NET runtime (CoreCLR) no Linux é um dos exemplos mais sofisticados de como um runtime gerenciado interage com o escalonador do kernel. Diferente do Python (limitado pelo GIL) ou do Go (que implementa seu próprio scheduler M:N), o .NET adota um modelo 1:1 onde cada thread gerenciada mapeia diretamente para uma kernel thread — mas adiciona uma camada de abstração poderosa: o ThreadPool e o Task Parallel Library (TPL).

Thread Pool do .NET e Escalonamento do Kernel

O ThreadPool do .NET é o coração da execução assíncrona em aplicações ASP.NET Core. Ele gerencia um conjunto de kernel threads que executam work items enfileirados — incluindo continuações de async/await, timers, e I/O completion callbacks.

Arquitetura do ThreadPool

Aplicação ASP.NET Core
┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│   Request 1 ──┐     Request 2 ──┐     Request 3 ──┐             │
│               ▼                  ▼                  ▼           │
│   ┌─────────────────────────────────────────────────────┐       │
│   │              Global Work Queue                      │       │
│   │  [Task A] → [Task B] → [Task C] → [Task D] → ...    │       │
│   └────────────────────────┬────────────────────────────┘       │
│                            │                                    │
│   ┌────────────────────────┼────────────────────────────┐       │
│   │         ThreadPool     │                            │       │
│   │  ┌──────────┐  ┌──────┴─────┐  ┌──────────┐         │       │
│   │  │ Worker 1 │  │  Worker 2  │  │ Worker 3 │  ...    │       │
│   │  │(stealing)│  │(executing) │  │(waiting) │         │       │
│   │  └────┬─────┘  └─────┬──────┘  └────┬─────┘         │       │
│   │       │Local Q        │Local Q       │Local Q       │       │
│   └───────┼───────────────┼──────────────┼──────────────┘       │
│           │               │              │                      │
├───────────┼───────────────┼──────────────┼──────────────────────┤
│   Kernel  ▼               ▼              ▼                      │
│   ┌──────────┐     ┌──────────┐   ┌──────────┐                  │
│   │  KThread │     │  KThread │   │  KThread │                  │
│   │  (core 0)│     │  (core 1)│   │  (core 2)│                  │
│   └──────────┘     └──────────┘   └──────────┘                  │
│              CFS Scheduler (kernel)                             │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Hill Climbing Algorithm

O ThreadPool do .NET usa um algoritmo de hill climbing para ajustar dinamicamente o número de threads. Ao contrário de pools estáticos (como Gunicorn workers), o .NET ThreadPool monitora o throughput e adiciona/remove threads para maximizá-lo:

Hill Climbing: Ajuste dinâmico de threads

Throughput
    ▲
    │         ╭──── ponto ótimo
    │        ╱│╲
    │       ╱ │ ╲
    │      ╱  │  ╲         ← mais threads = mais context switches
    │     ╱   │   ╲            = menos throughput
    │    ╱    │    ╲
    │   ╱     │     ╲
    │──╱──────┼──────╲────────►
    │         │              Número de threads
    │    under-    over-
    │  subscribed  subscribed

Comportamento:
1. Começa com Environment.ProcessorCount threads
2. Adiciona 1 thread a cada 500ms se work items estão enfileirados
3. Mede throughput (work items completed/sec)
4. Se throughput subiu → continua adicionando
5. Se throughput caiu → remove thread (oversubscription detectada)
Enter fullscreen mode Exit fullscreen mode

Interação com o Kernel Scheduler

Cada worker thread do ThreadPool é uma kernel thread real (clone() com CLONE_VM | CLONE_FILES | CLONE_SIGHAND). Isso significa que:

  1. O CFS escalona cada worker independentemente — se você tem 8 workers em 4 cores, o CFS garante distribuição justa
  2. Context switches entre workers são reais — com custo de ~1-2μs (threads do mesmo processo, sem TLB flush)
  3. Involuntary preemption ocorre — se um handler de request é CPU-bound, será preemptado após seu timeslice
# Monitorando ThreadPool via dotnet-counters
$ dotnet-counters monitor --process-id <pid> System.Runtime

[System.Runtime]
    # of Active Timers                          12
    ThreadPool Completed Work Item Count    1,847,293
    ThreadPool Queue Length                       0     ← 0 = saudável
    ThreadPool Thread Count                     16     ← threads ativas
    Monitor Lock Contention Count              234

# Se Queue Length > 0 persistentemente:
# → ThreadPool está saturado
# → Requests estão esperando por thread disponível
# → Considere: mais threads, async I/O, ou otimizar handlers
Enter fullscreen mode Exit fullscreen mode

Implicação prática: O ThreadPool Queue Length é o equivalente .NET ao "load average" da aplicação. Se consistentemente > 0, sua aplicação está thread-starved. Possíveis causas: sync-over-async (bloqueando threads com .Result ou .Wait()), thread pool exhaustion por I/O bloqueante, ou handlers CPU-bound longos.

Task Parallel Library (TPL) e Cooperação com o Scheduler

O TPL (System.Threading.Tasks) é a abstração de alto nível que o .NET oferece sobre o ThreadPool. Quando você escreve Task.Run(...) ou usa async/await, o TPL decide quando e onde executar o código.

Work Stealing e Localidade de Cache

O ThreadPool do .NET implementa work stealing — cada worker thread tem uma fila local (lock-free deque). Quando sua fila esvazia, a thread "rouba" trabalho da fila de outra thread:

Work Stealing:

Worker 1 (core 0)        Worker 2 (core 1)        Worker 3 (core 2)
┌──────────────┐         ┌──────────────┐         ┌──────────────┐
│ Local Queue  │         │ Local Queue  │         │ Local Queue  │
│ [T1][T2][T3] │         │ [T4][T5]     │         │ (vazia)      │
└──────────────┘         └──────────────┘         └──────┬───────┘
                                                         │
                                                    steal│from Worker 1
                                                         │
                                                         ▼
                                                    executa T3
Enter fullscreen mode Exit fullscreen mode

Impacto no kernel scheduler:

  • Work stealing mantém todas as threads ocupadas → menos idle time → melhor utilização de CPU
  • Porém, roubar trabalho de outra thread pode significar processar dados que estão no cache de outro core → cache misses
  • O .NET tenta minimizar isso mantendo continuações (await) na mesma thread que iniciou a operação

Parallel.ForEach e Partitioning

// Processamento paralelo de batch
await Parallel.ForEachAsync(items, 
    new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
    async (item, ct) => 
    {
        await ProcessItemAsync(item, ct);
    });
Enter fullscreen mode Exit fullscreen mode

O MaxDegreeOfParallelism = Environment.ProcessorCount é a configuração ideal para workloads CPU-bound — evita oversubscription. Para I/O-bound, pode ser maior (as threads bloqueiam em I/O e o kernel escalona outras).

Async/Await e SynchronizationContext no Linux

O modelo async/await do .NET é fundamentalmente diferente de threads — é concorrência cooperativa sobre o ThreadPool:

async Task<Response> HandleRequest(Request req)
{
    var data = await db.QueryAsync(sql);    // ← libera a thread!
    var result = Transform(data);            // ← pode executar em OUTRA thread
    await cache.SetAsync(key, result);       // ← libera novamente
    return new Response(result);
}

Timeline de uma thread do ThreadPool:

Thread 1: |handle req A|await|handle req B|await|continue A|await|...
                                                        
                              └── thread devolvida ──────┘
                                  ao pool (kernel thread
                                  disponível para outro trabalho)
Enter fullscreen mode Exit fullscreen mode

SynchronizationContext no Linux

No ASP.NET Core (ao contrário do WPF/WinForms), não há SynchronizationContext. Isso significa:

  • Continuações após await podem executar em qualquer thread do pool
  • Não há overhead de marshaling para uma thread específica
  • Não há risco de deadlock por contexto de sincronização
ASP.NET Core (sem SynchronizationContext):

Thread 1: |── req A: antes do await ──|   |── req B: continuação ──|
Thread 2: |── req B: antes do await ──|   |── req A: continuação ──|
                                              
                                     continuação de A executa em Thread 2
                                     (qualquer thread disponível)

vs WPF/WinForms (com SynchronizationContext):

Thread 1 (UI): |── antes await ──|── continuação ──|   SEMPRE na UI thread
                                        
                                   marshaled de volta (overhead + possível deadlock)
Enter fullscreen mode Exit fullscreen mode

Implicação para o kernel: Sem SynchronizationContext, as continuações de await são enfileiradas no ThreadPool global. O kernel vê apenas threads do pool pegando trabalho — não há affinity forçada. Isso é bom para throughput (qualquer core pode executar qualquer continuação), mas pode causar mais cache misses (dados de um request processados em cores diferentes).

Async I/O no Linux: epoll sob o capô

Quando você faz await httpClient.GetAsync(url) no Linux, o .NET usa epoll para I/O assíncrono:

Sequência de async I/O no .NET no Linux:

1. Código C#: await socket.ReadAsync(buffer)
2. CoreCLR: registra fd no epoll_ctl(EPOLL_CTL_ADD)
3. Thread devolvida ao ThreadPool
4. Kernel: dados chegam no socket
5. epoll_wait() retorna (na I/O completion thread)
6. CoreCLR: enfileira continuação no ThreadPool
7. Worker thread pega a continuação e executa o código após await

┌─────────────────────────────────────────────────┐
 .NET I/O Thread (dedicada)                      
                                                 
   loop {                                        
     events = epoll_wait(epfd, ...)              
     for event in events {                       
       queue_continuation(event.callback)        
     }                                           
   }                                             
                                                 
└─────────────────────────────────────────────────┘
         
          enfileira no ThreadPool
┌─────────────────────────────────────────────────┐
 Worker Threads (executam continuações)          
  Thread 1: executa callback de socket read      
  Thread 2: executa callback de DB query         
  Thread 3: executa callback de HTTP response    
└─────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

O .NET mantém 1-2 threads dedicadas chamando epoll_wait() — essas são as I/O completion threads (diferentes das worker threads). Elas nunca executam código do usuário diretamente — apenas enfileiram continuações.

Exemplo Prático: Otimizando Aplicações ASP.NET Core em Containers

Cenário: API em Kubernetes com latência alta no p99

Ambiente:
- Kubernetes pod com limits: 4 CPU, 8GB RAM
- ASP.NET Core 8.0 API
- ~2000 req/s
- p50: 15ms, p95: 45ms, p99: 350ms (!) ← problema

Diagnóstico:
$ dotnet-counters monitor --process-id 1 System.Runtime
    ThreadPool Thread Count:     87        ← muito alto para 4 cores!
    ThreadPool Queue Length:     12        ← work items esperando
    Monitor Lock Contention Count: 4521   ← contenção de locks

$ pidstat -w -p 1 1
    cswch/s: 8500    nvcswch/s: 2100      ← muitos involuntary switches!
Enter fullscreen mode Exit fullscreen mode

Problema identificado: Thread pool cresceu demais (87 threads para 4 cores) → oversubscription severa → context switches excessivos → cache pollution → latência alta no p99.

Causas comuns:

  1. Chamadas síncronas bloqueantes (sync-over-async)
  2. Lock contention forçando threads a bloquear
  3. ThreadPool adicionando threads porque as existentes estão bloqueadas

Solução 1: Eliminar sync-over-async

// ❌ ERRADO: bloqueia thread do pool esperando resultado
public Response GetData()
{
    var result = _httpClient.GetAsync(url).Result;  // BLOQUEIA a thread!
    return Process(result);
}

// ✅ CORRETO: libera thread durante I/O
public async Task<Response> GetDataAsync()
{
    var result = await _httpClient.GetAsync(url);   // libera thread
    return Process(result);
}
Enter fullscreen mode Exit fullscreen mode

Cada .Result ou .Wait() bloqueia uma thread do pool. O hill climbing detecta threads bloqueadas e injeta novas → mais threads → mais context switches → degradação exponencial.

Solução 2: Limitar ThreadPool em containers

// Program.cs — configuração para containers
// Limita threads ao número de cores disponíveis no cgroup

// Para workloads I/O-bound (maioria das APIs):
ThreadPool.SetMinThreads(
    workerThreads: Environment.ProcessorCount * 2,
    completionPortThreads: Environment.ProcessorCount);

ThreadPool.SetMaxThreads(
    workerThreads: Environment.ProcessorCount * 4,  // cap em 4x cores
    completionPortThreads: Environment.ProcessorCount * 2);
Enter fullscreen mode Exit fullscreen mode
# Variáveis de ambiente para tuning em containers
DOTNET_ThreadPool_UnfairSemaphoreSpinLimit=0  # reduz spin-wait (bom para containers)
DOTNET_SYSTEM_NET_SOCKETS_INLINE_COMPLETIONS=1  # reduz context switches para I/O
Enter fullscreen mode Exit fullscreen mode

Solução 3: CPU affinity via cgroups

# Kubernetes pod spec com CPU pinning
apiVersion: v1
kind: Pod
spec:
  containers:
  - name: api
    resources:
      requests:
        cpu: "4"          # garante 4 cores dedicados
        memory: "8Gi"
      limits:
        cpu: "4"          # mesmo valor = guaranteed QoS = CPU pinning
        memory: "8Gi"
Enter fullscreen mode Exit fullscreen mode

Quando requests.cpu == limits.cpu no Kubernetes, o kubelet configura o cgroup com cpuset — cores exclusivos. Isso elimina migração entre cores e reduz cache pollution.

Resultado após otimização

Antes:                          Depois:
ThreadPool Threads: 87          ThreadPool Threads: 12
Queue Length: 12                Queue Length: 0
Context switches: 8500/s        Context switches: 1200/s
p50: 15ms                       p50: 12ms
p95: 45ms                       p95: 25ms
p99: 350ms                      p99: 55ms  ← 6.4x melhor!
Enter fullscreen mode Exit fullscreen mode

CoreCLR e Interação com o Scheduler do Linux

O CoreCLR (runtime do .NET no Linux) interage com o kernel scheduler de várias formas:

GC (Garbage Collector) e Escalonamento

O GC do .NET pode causar stop-the-world pauses que afetam o escalonamento:

Server GC (recomendado para APIs):
- 1 GC thread por core (dedicadas)
- Durante GC: TODAS as threads da aplicação são suspensas
- Duração típica: 1-50ms (Gen2 full GC pode ser > 100ms)

Timeline durante GC:
Core 0: |── app ──|── GC ──|── app ──|
Core 1: |── app ──|── GC ──|── app ──|
Core 2: |── app ──|── GC ──|── app ──|
Core 3: |── app ──|── GC ──|── app ──|
                   ↑ todas as cores param
                     (visible no perf como pause)

Workstation GC (recomendado para containers com 1-2 cores):
- 1 GC thread compartilhada
- Menos overhead de memória
- Pausas maiores mas menos impacto em poucos cores
Enter fullscreen mode Exit fullscreen mode
# Configuração de GC para containers
DOTNET_gcServer=1                    # Server GC (se >= 2 cores)
DOTNET_GCHeapCount=4                 # Limitar GC heaps (match com cores)
DOTNET_GCConserveMemory=5            # 1-9: trade-off memória vs throughput
Enter fullscreen mode Exit fullscreen mode

Thread Suspension e Sinais

O CoreCLR usa sinais POSIX (SIGUSR1, SIGUSR2) para suspender threads durante GC:

  1. GC thread envia SIGUSR2 para todas as threads gerenciadas
  2. Signal handler em cada thread salva seu estado e sinaliza "safe point"
  3. GC executa (coleta, compacta)
  4. Threads são resumidas

Esse mecanismo interage com o kernel scheduler — se uma thread está em TASK_INTERRUPTIBLE (esperando I/O), o sinal a acorda imediatamente para que o GC possa prosseguir.

NUMA Awareness em Aplicações .NET

Em servidores multi-socket (2+ CPUs físicas), a arquitetura NUMA (Non-Uniform Memory Access) significa que acessar memória "local" (no mesmo nó) é significativamente mais rápido que memória "remota" (em outro nó):

Servidor dual-socket NUMA:

┌─────────────────────────┐    ┌─────────────────────────┐
│       NUMA Node 0       │    │       NUMA Node 1       │
│                         │    │                         │
│  CPU 0-7 (8 cores)      │    │  CPU 8-15 (8 cores)     │
│  RAM local: 64GB        │    │  RAM local: 64GB        │
│  Latência local: ~100ns │    │  Latência local: ~100ns │
│                         │    │                         │
└────────────┬────────────┘    └────────────┬────────────┘
             │                              │
             └────── QPI/UPI link ──────────┘
                   Latência remota: ~150-300ns
                   (1.5-3x mais lento!)
Enter fullscreen mode Exit fullscreen mode

.NET e NUMA

O CoreCLR tem awareness básico de NUMA:

  • Server GC cria um GC heap por NUMA node (não por core)
  • ThreadPool distribui threads entre nodes
  • Alocações são feitas preferencialmente na memória local ao core que está executando
// Verificando topologia NUMA em .NET
Console.WriteLine($"Processor Count: {Environment.ProcessorCount}");
// Em NUMA: retorna total de cores em todos os nodes

// Para workloads NUMA-sensitive, use CPU affinity:
// Exemplo: restringir processo a um único NUMA node
Enter fullscreen mode Exit fullscreen mode
# Executando aplicação .NET em NUMA node específico
$ numactl --cpunodebind=0 --membind=0 dotnet MyApi.dll

# Verificando distribuição de memória NUMA
$ numastat -p <pid>
Per-node process memory usage (in MBs)
                 Node 0   Node 1    Total
                 ------   ------   ------
Heap               512       48      560    ← idealmente tudo em Node 0
Stack               16        2       18
Private            128       12      140

# Se há memória significativa no node "errado":
# → alocação ocorreu em thread executando no outro node
# → threadpool scheduling está causando acesso remoto
Enter fullscreen mode Exit fullscreen mode

Configuração NUMA para .NET em produção

# Opção 1: Processos separados por NUMA node
# (Melhor isolamento, mais simples)
$ numactl --cpunodebind=0 --membind=0 dotnet MyApi.dll --urls http://+:5000
$ numactl --cpunodebind=1 --membind=1 dotnet MyApi.dll --urls http://+:5001
# Load balancer distribui entre as duas instâncias

# Opção 2: Kubernetes com topology-aware scheduling
# topology.kubernetes.io/zone anotações para NUMA-aware placement

# Opção 3: Configuração de GC NUMA-aware
DOTNET_gcServer=1
DOTNET_GCHeapCount=8        # heaps = cores por NUMA node
DOTNET_GCNoAffinitize=0     # permitir GC affinitizar threads
DOTNET_GCHeapAffinitizeMask=0xFF  # cores 0-7 (Node 0)
Enter fullscreen mode Exit fullscreen mode

Regra prática para .NET e NUMA:

  • Servidores single-socket (maioria na cloud): NUMA não é preocupação
  • Servidores dual-socket (bare metal, databases): Configure numactl ou use instâncias separadas por node
  • Containers em Kubernetes: Use topologySpreadConstraints e resource limits que se alinham com NUMA boundaries
  • Monitore com numastat e perf stat -e node-load-misses — se acesso remoto > 10%, otimize

Conexão com Desenvolvimento Backend: Golang

O Go é único entre as linguagens backend mainstream por implementar um verdadeiro modelo M:N de threading — goroutines (user-level threads) são multiplexadas sobre um número menor de kernel threads pelo runtime scheduler. Essa arquitetura permite criar milhões de unidades de concorrência com overhead mínimo, mas a interação com o kernel scheduler do Linux introduz nuances que todo desenvolvedor Go precisa compreender.

Goroutines vs Kernel Threads (M:N Threading Model)

Uma goroutine não é uma thread do sistema operacional. É uma unidade de execução gerenciada pelo Go runtime, com custo de criação e memória drasticamente menor:

Característica Goroutine Kernel Thread (OS Thread)
Stack inicial ~2KB (cresce dinamicamente até 1GB) ~2-8MB (fixa, definida por ulimit -s)
Custo de criação ~0.3μs ~10-50μs
Context switch ~0.1-0.2μs (userspace) ~1-3μs (kernel)
Quantidade típica Milhares a milhões Centenas a baixos milhares
Escalonamento Go runtime scheduler Kernel CFS
Preempção Cooperativa + async (Go 1.14+) Preemptiva (timer interrupt)
Modelo M:N do Go:

                    Userspace (Go runtime)
┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│   G1  G2  G3  G4  G5  G6  G7  G8 ... G100000                    │
│   │   │   │   │   │   │   │   │                                 │
│   └─┬─┘   │   └─┬─┘   │   └─┬─┘                                 │
│     │     │     │     │     │      ← goroutines (user threads)  │
│     ▼     ▼     ▼     ▼     ▼                                   │
│   ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐                                 │
│   │P0 │ │P1 │ │P2 │ │P3 │ │P4 │   ← P (Logical Processors)      │
│   │LRQ│ │LRQ│ │LRQ│ │LRQ│ │LRQ│     cada P tem local run queue  │
│   └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘                                 │
│     │     │     │     │     │                                   │
│     ▼     ▼     ▼     ▼     ▼                                   │
│   ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐                                 │
│   │M0 │ │M1 │ │M2 │ │M3 │ │M4 │   ← M (OS Threads/Machines)     │
│   └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘     mapeiam para task_struct    │
├─────┼─────┼─────┼─────┼─────┼───────────────────────────────────┤
│     ▼     ▼     ▼     ▼     ▼         Kernel                    │
│   KT0   KT1   KT2   KT3   KT4        (kernel threads)           │
│   ┌─────────────────────────────┐                               │
│   │     CFS Scheduler           │                               │
│   └─────────────────────────────┘                               │
└─────────────────────────────────────────────────────────────────┘

Terminologia:
  G = Goroutine (unidade de execução leve)
  P = Processor (contexto lógico, contém run queue local)
  M = Machine (kernel thread real, escalonada pelo CFS)

Relação: muitos G → poucos P → poucos M → cores do hardware
Enter fullscreen mode Exit fullscreen mode

A chave do modelo é: G (goroutines) >> P (processors) >= M (OS threads) ≈ cores

O Scheduler do Go Runtime e sua Relação com o Kernel

O Go scheduler opera em userspace, executando dentro de cada M (OS thread). Ele toma decisões de escalonamento sem envolver o kernel — o que elimina o overhead de syscalls para context switches entre goroutines.

Componentes do scheduler (GMP model)

Anatomia de um P (Logical Processor):

P (Processor)
├── Local Run Queue (LRQ)
│   └── [G5] → [G12] → [G31] → ...    ← fila FIFO de goroutines prontas
├── Current G                            ← goroutine em execução
├── mcache                               ← cache de memória por-P (performance)
├── Timer heap                           ← goroutines dormindo (time.Sleep, etc.)
└── Runnext                              ← próxima G a executar (fast path)

Global Run Queue (GRQ):
└── [G99] → [G200] → [G345] → ...      ← overflow das LRQs, acessada com lock

Idle M list:
└── M5 → M6 → M7 → ...                 ← threads do kernel ociosas
Enter fullscreen mode Exit fullscreen mode

Quando o Go scheduler roda (scheduling points)

O scheduler é invocado (em userspace) em pontos específicos:

Pontos de escalonamento do Go:

1. Chamada a função (function prologue)
   ├── Verifica se stack precisa crescer
   └── Verifica preemption flag (Go 1.14+: sinal SIGURG)

2. Channel operations
   ├── ch <- value (send bloqueante)
   └── <-ch (receive bloqueante)

3. Blocking syscalls
   ├── file I/O, network I/O (em raw syscall)
   └── M é liberado, P migra para outro M

4. runtime.Gosched()
   └── yield explícito (raro em código moderno)

5. Garbage Collection
   ├── STW (stop-the-world) phases
   └── GC assist (goroutine ajuda no marking)

6. time.Sleep / timer expiration
   └── Goroutine vai para timer heap do P

7. sync primitives
   ├── sync.Mutex.Lock() (quando contended)
   └── sync.WaitGroup.Wait()
Enter fullscreen mode Exit fullscreen mode

Interação com o kernel: blocking syscalls

O aspecto mais importante da relação Go runtime ↔ kernel é o tratamento de syscalls bloqueantes:

Cenário: goroutine G1 faz syscall bloqueante (ex: file read)

ANTES da syscall:
  P0 ←→ M0: executando G1
  P0.LRQ: [G2, G3, G4]   ← goroutines esperando

DURANTE a syscall:
  1. Go runtime detecta que G1 vai bloquear
  2. P0 se DESACOPLA de M0
  3. P0 se ACOPLA a M1 (um M idle, ou cria novo M)
  4. M1 começa a executar G2 da LRQ de P0
  5. M0 continua bloqueado no kernel com G1

  P0 ←→ M1: executando G2       ← P continua produtivo!
  M0: bloqueado em read() com G1 ← kernel thread bloqueada

APÓS a syscall retornar:
  1. M0 acorda com G1
  2. Tenta re-adquirir P0 (ou qualquer P idle)
  3. Se consegue: G1 volta a executar
  4. Se não: G1 vai para Global Run Queue
  5. M0 vai para idle list

Timeline:
M0: |─── G1 ───|── read() bloqueante ──|── G1 retoma ──|
M1:             |── G2 ──|── G3 ──|── G4 ──|
                ↑
         P migra para M1 (latência ~μs)
Enter fullscreen mode Exit fullscreen mode

Esse mecanismo é o que permite ao Go ter I/O "assíncrono" sem async/await — do ponto de vista do programador, o código é síncrono e sequencial, mas o runtime garante que outras goroutines continuam executando.

Network poller (netpoller)

Para I/O de rede, o Go usa um mecanismo diferente — o netpoller, baseado em epoll no Linux:

Network I/O (não bloqueia M):

1. goroutine chama conn.Read()
2. Runtime: setsockopt(fd, O_NONBLOCK)
3. Runtime: tenta read()  EAGAIN (nada disponível)
4. Runtime: registra fd em epoll + parks goroutine
5. Goroutine sai do P (não ocupa M!)
6. P executa outras goroutines

... dados chegam no socket ...

7. Sysmon thread (ou outro M): epoll_wait() detecta fd ready
8. Goroutine é colocada de volta na run queue
9. Goroutine retoma conn.Read()  dados disponíveis

Diferença crucial:
  - File I/O: BLOQUEIA o M (kernel thread fica presa)
  - Network I/O: NÃO bloqueia M (epoll + park/unpark)

Implicação:
  - 100k goroutines fazendo network I/O  ~GOMAXPROCS OS threads
  - 100k goroutines fazendo file I/O  pode criar 100k OS threads!
Enter fullscreen mode Exit fullscreen mode

GOMAXPROCS e CPU Affinity

GOMAXPROCS controla o número de P's (processors lógicos) — efetivamente o paralelismo máximo de execução de goroutines em Go code (não syscalls).

GOMAXPROCS e sua relação com o hardware:

GOMAXPROCS=1:
  Core 0: |G1|G2|G1|G3|G2|    sem paralelismo Go
  Core 1: (idle para Go)                   pode ter M's em syscall
   Útil para: debugging, eliminação de race conditions

GOMAXPROCS=4 (em máquina com 4 cores):
  Core 0: |G1|G5|G1|
  Core 1: |G2|G6|G2|               paralelismo total
  Core 2: |G3|G7|G3|
  Core 3: |G4|G8|G4|
   Default desde Go 1.5: runtime.NumCPU()

GOMAXPROCS=8 (em máquina com 4 cores):
  Core 0: |P0|P4|P0|P4|                   oversubscription!
  Core 1: |P1|P5|P1|P5|                   context switches do kernel
  Core 2: |P2|P6|P2|P6|                   entre os M's dos 8 P's
  Core 3: |P3|P7|P3|P7|
   Geralmente prejudicial para workloads CPU-bound
   Pode ajudar para workloads com muitas syscalls bloqueantes
Enter fullscreen mode Exit fullscreen mode

GOMAXPROCS em containers

Problema crítico: Em containers com CPU limits, runtime.NumCPU() retorna o número de cores do host, não do container!

// Em um container com cpu.max = "200000 100000" (2 cores):
fmt.Println(runtime.NumCPU())      // Pode imprimir 64! (cores do host)
fmt.Println(runtime.GOMAXPROCS(0)) // GOMAXPROCS = 64 por default!

// Resultado: 64 P's competindo por 2 cores de CPU quota
// → Excessive context switches no kernel
// → Throttling pelo cgroup CPU controller
// → Latência imprevisível
Enter fullscreen mode Exit fullscreen mode

Solução: Use automaxprocs (library da Uber) ou configure manualmente:

import _ "go.uber.org/automaxprocs" // Detecta cgroup limits automaticamente

// Ou manualmente:
func init() {
    if quota := getCGroupCPUQuota(); quota > 0 {
        runtime.GOMAXPROCS(int(math.Ceil(quota)))
    }
}
Enter fullscreen mode Exit fullscreen mode
# Verificar se GOMAXPROCS está correto
$ GODEBUG=schedtrace=1000 ./myservice 2>&1 | head -3
SCHED 0ms: gomaxprocs=2 idleprocs=1 threads=4 idlethreads=1
SCHED 1000ms: gomaxprocs=2 idleprocs=0 threads=5 idlethreads=0
SCHED 2000ms: gomaxprocs=2 idleprocs=1 threads=5 idlethreads=1

# gomaxprocs=2 ← deve corresponder ao CPU limit do container
Enter fullscreen mode Exit fullscreen mode

CPU Affinity e Go

O Go runtime não configura CPU affinity por default — os M's (OS threads) podem migrar entre cores livremente. Para workloads latency-sensitive:

# Pinning do processo Go a cores específicos
$ taskset -c 0-3 ./myservice

# Ou via cgroups (Kubernetes):
# resources.requests.cpu == resources.limits.cpu → cpuset pinning
Enter fullscreen mode Exit fullscreen mode
// Dentro do Go, para pin goroutine a OS thread:
runtime.LockOSThread()  // Esta goroutine fica presa neste M
defer runtime.UnlockOSThread()

// Use cases:
// - CGO com thread-local state
// - OpenGL/GPU contexts
// - Real-time goroutines que precisam de CPU dedicada
Enter fullscreen mode Exit fullscreen mode

Análise de Performance: Blocking Syscalls e Goroutines

A principal armadilha de performance em Go é o excesso de OS threads criados por syscalls bloqueantes — cada goroutine que bloqueia em file I/O, CGO, ou certain syscalls consome um M inteiro.

Diagnóstico: threads demais

# Monitorando OS threads do processo Go
$ cat /proc/<pid>/status | grep Threads
Threads: 847    ← se muito maior que GOMAXPROCS, há goroutines em syscalls

# Trace detalhado do scheduler
$ GODEBUG=schedtrace=1000,scheddetail=1 ./myservice 2>&1 | grep -E "^SCHED|threads"
SCHED 1000ms: gomaxprocs=4 idleprocs=0 threads=847 idlethreads=2
              runqueue=12 [45 38 52 41]
#                         ↑ LRQs dos P's (goroutines esperando)
# threads=847: muitas goroutines bloqueadas em syscalls!
# runqueue=12 + [45+38+52+41] = 188 goroutines ready mas sem P livre
Enter fullscreen mode Exit fullscreen mode

Cenários problemáticos e soluções

Problema 1: File I/O massivo

// ❌ Cada goroutine bloqueia um M em file read
for _, file := range files {
    go func(f string) {
        data, _ := os.ReadFile(f)  // bloqueia M!
        process(data)
    }(file)
}
// Com 10000 files: pode criar 10000 OS threads!

// ✅ Limitar concorrência com semaphore
sem := make(chan struct{}, 64)  // max 64 file I/O simultâneos
for _, file := range files {
    sem <- struct{}{}
    go func(f string) {
        defer func() { <-sem }()
        data, _ := os.ReadFile(f)
        process(data)
    }(file)
}
Enter fullscreen mode Exit fullscreen mode
Problema 2: CGO calls bloqueantes

// CGO: TODA chamada C bloqueia o M
// O runtime NÃO pode preemptar código C

/*
#include <unistd.h>
void slow_c_function() {
    sleep(5);  // bloqueia M por 5 segundos!
}
*/
import "C"

// ✅ Limitar goroutines que chamam CGO
var cgoSem = make(chan struct{}, runtime.GOMAXPROCS(0))

func callCGO() {
    cgoSem <- struct{}{}
    defer func() { <-cgoSem }()
    C.slow_c_function()
}
Enter fullscreen mode Exit fullscreen mode
Problema 3: DNS resolution (usa CGO por default no Linux)

// net.LookupHost usa CGO → bloqueia M
// Sob carga alta, pode criar centenas de threads

// ✅ Solução: usar pure Go resolver
// export GODEBUG=netdns=go
// ou no código:
import _ "net" // com build tag: -tags netgo
Enter fullscreen mode Exit fullscreen mode

Ferramentas de diagnóstico

# 1. Runtime trace (visualização gráfica)
$ curl http://localhost:6060/debug/pprof/trace?seconds=5 > trace.out
$ go tool trace trace.out
# Mostra: goroutine scheduling, syscalls, network I/O, GC

# 2. Goroutine profile
$ curl http://localhost:6060/debug/pprof/goroutine?debug=2
# Lista TODAS goroutines com stack traces
# Procure por: "syscall" no stack = goroutine bloqueando M

# 3. Thread create profile
$ curl http://localhost:6060/debug/pprof/threadcreate?debug=1
# Mostra onde threads foram criadas (indica syscalls bloqueantes)

# 4. perf (kernel-level view)
$ perf stat -e context-switches,cpu-migrations -p <pid> sleep 10
# context-switches alto + muitos threads = problema de blocking syscalls

# 5. Scheduler latency
$ GODEBUG=schedtrace=1000 ./myservice
# Campos importantes:
# - runqueue: goroutines na global queue (> 0 = P's saturados)
# - [n n n n]: goroutines por P na LRQ (desbalanceado = work stealing falhou)
# - idleprocs: P's ociosos (> 0 com runqueue > 0 = bug ou lock contention)
Enter fullscreen mode Exit fullscreen mode

Exemplo Prático: Microserviços Go e Tuning de Concorrência

Cenário: API Gateway em Go com latência degradada sob carga

Ambiente:
- Kubernetes: 4 CPU limit, 4GB RAM
- Go 1.22, ~50k req/s
- Cada request faz: 2-3 chamadas HTTP a backends + 1 Redis lookup
- p50: 8ms, p95: 25ms, p99: 180ms (!) ← degradação no p99

Observações iniciais:
$ cat /proc/<pid>/status
Threads: 312      ← alto para GOMAXPROCS=4 (deveria ser ~10-20)

$ GODEBUG=schedtrace=5000 ./gateway 2>&1 | tail -1
SCHED 5000ms: gomaxprocs=4 idleprocs=0 threads=312 idlethreads=280
              runqueue=0 [2 1 3 0]

Análise:
- threads=312 mas idlethreads=280 → 32 threads ativas em algum momento
- 280 threads idle = foram criadas para syscalls e não foram recicladas
- runqueue baixo = não é falta de P's
- O problema é CRIAÇÃO EXCESSIVA de threads por syscalls bloqueantes
Enter fullscreen mode Exit fullscreen mode

Diagnóstico profundo

# Goroutine dump
$ curl localhost:6060/debug/pprof/goroutine?debug=2 | grep -c "syscall"
28    ← 28 goroutines bloqueadas em syscalls neste instante

# Stack traces das goroutines em syscall:
$ curl localhost:6060/debug/pprof/goroutine?debug=2 | grep -B5 "syscall"
# Revela: net/http.(*Transport).dialConn → net.(*Resolver).lookupHost → CGO!

# perf para confirmar context switches
$ perf stat -p <pid> sleep 10
    45,230  context-switches    ← ~4500/s, alto para 4 cores
     2,890  cpu-migrations      ← threads migrando entre cores
Enter fullscreen mode Exit fullscreen mode

Causa raiz: DNS resolution via CGO criando threads excessivas + HTTP client sem connection pooling adequado.

Solução 1: Pure Go DNS resolver

// main.go — forçar resolver Go puro
import _ "net" // build com: go build -tags netgo

// Ou via variável de ambiente:
// GODEBUG=netdns=go ./gateway
Enter fullscreen mode Exit fullscreen mode

Solução 2: HTTP client com connection pooling otimizado

// ❌ Default: limites conservadores
client := &http.Client{}  // MaxIdleConnsPerHost = 2 (!)

// ✅ Otimizado para alta concorrência
client := &http.Client{
    Transport: &http.Transport{
        MaxIdleConns:        200,
        MaxIdleConnsPerHost: 100,   // match com concorrência esperada
        MaxConnsPerHost:     100,   // cap total de conexões por host
        IdleConnTimeout:     90 * time.Second,

        // Tuning TCP-level
        DialContext: (&net.Dialer{
            Timeout:   5 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,

        // Disable HTTP/2 se backends não suportam multiplexing
        ForceAttemptHTTP2: true,
    },
    Timeout: 10 * time.Second,
}
Enter fullscreen mode Exit fullscreen mode

Solução 3: GOMAXPROCS correto + automaxprocs

import _ "go.uber.org/automaxprocs"

// Resultado: GOMAXPROCS=4 (correto para o container)
// Sem automaxprocs em container com 4 CPU limit em host de 64 cores:
// GOMAXPROCS=64 → 64 P's criando work que 4 cores não conseguem executar
Enter fullscreen mode Exit fullscreen mode

Solução 4: Limitar concorrência de operações bloqueantes

// Semaphore para limitar file/disk I/O concurrent
var diskSem = semaphore.NewWeighted(int64(runtime.GOMAXPROCS(0) * 4))

func readConfig(ctx context.Context, path string) ([]byte, error) {
    if err := diskSem.Acquire(ctx, 1); err != nil {
        return nil, err
    }
    defer diskSem.Release(1)
    return os.ReadFile(path)
}
Enter fullscreen mode Exit fullscreen mode

Resultado após otimização

Antes:                          Depois:
Threads: 312                    Threads: 18
Context switches: 4500/s        Context switches: 800/s
CPU migrations: 290/s           CPU migrations: 45/s
p50: 8ms                        p50: 6ms
p95: 25ms                       p95: 15ms
p99: 180ms                      p99: 35ms  ← 5x melhor!
Enter fullscreen mode Exit fullscreen mode

Monitoramento contínuo em produção

// Expor métricas do runtime para Prometheus
import "github.com/prometheus/client_golang/prometheus"

func init() {
    // Goroutines ativas
    prometheus.MustRegister(prometheus.NewGaugeFunc(
        prometheus.GaugeOpts{Name: "go_goroutines"},
        func() float64 { return float64(runtime.NumGoroutine()) },
    ))

    // OS threads
    prometheus.MustRegister(prometheus.NewGaugeFunc(
        prometheus.GaugeOpts{Name: "go_threads"},
        func() float64 {
            n, _ := runtime.ThreadCreateProfile(nil)
            return float64(n)
        },
    ))
}
Enter fullscreen mode Exit fullscreen mode
# Alertas recomendados (Prometheus):
# - go_threads > GOMAXPROCS * 10 → muitas blocking syscalls
# - go_goroutines > 100000 → possível goroutine leak
# - rate(go_sched_latencies_seconds_sum[5m]) > 0.001 → scheduler saturado
Enter fullscreen mode Exit fullscreen mode

Regras práticas para Go em produção:

  1. GOMAXPROCS = CPU limit do container (use automaxprocs)
  2. go_threads deve ser < GOMAXPROCS * 5 — mais indica blocking syscalls
  3. Use pure Go DNS resolver (GODEBUG=netdns=go ou -tags netgo)
  4. Configure MaxIdleConnsPerHost no HTTP client (default 2 é muito baixo!)
  5. Limite concorrência de file I/O e CGO com semaphores
  6. Monitore com GODEBUG=schedtrace em staging, pprof em produção

Referências Bibliográficas

Livros

Documentação Oficial

Ferramentas de Observabilidade

  • perf — profiler e ferramenta de performance do kernel Linux (usado para medir TLB misses, cache misses e context switches)
  • pidstat — estatísticas de processos e threads (parte do pacote sysstat) (usado para monitorar voluntary/involuntary context switches)
  • dotnet-counters — ferramenta de monitoramento de runtime .NET (ThreadPool Thread Count, Queue Length, etc.)
  • dotnet-trace — coleta de traces do runtime .NET
  • Go pprof — profiler de CPU, memória e goroutines do Go (goroutine dump, threadcreate profile)
  • go tool trace — visualizador gráfico de traces do Go runtime
  • GODEBUG=schedtrace — variável de ambiente para debug do scheduler Go
  • taskset — configuração de CPU affinity para processos
  • numactl — controle de política de memória e CPU NUMA
  • numastat — estatísticas de alocação de memória por NUMA node

Bibliotecas e Pacotes

Top comments (0)