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
- Overhead de Context Switch: Custos Diretos e Indiretos
- TLB Flush: Impacto na Performance
- Cache Pollution: Efeitos em L1, L2 e L3
- Reduzindo o Impacto de Context Switches
- Conexão com Desenvolvimento Backend: .NET
- Conexão com Desenvolvimento Backend: Golang
- Referências Bibliográficas
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) │
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);
}
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) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
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
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
Mitigações
- 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!
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).
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!
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
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
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
# 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)
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) │
└─────────────────────────────────────────────────────────────────┘
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)
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:
- O CFS escalona cada worker independentemente — se você tem 8 workers em 4 cores, o CFS garante distribuição justa
- Context switches entre workers são reais — com custo de ~1-2μs (threads do mesmo processo, sem TLB flush)
- 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
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.Resultou.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
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);
});
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)
SynchronizationContext no Linux
No ASP.NET Core (ao contrário do WPF/WinForms), não há SynchronizationContext. Isso significa:
- Continuações após
awaitpodem 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)
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 │
└─────────────────────────────────────────────────┘
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!
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:
- Chamadas síncronas bloqueantes (sync-over-async)
- Lock contention forçando threads a bloquear
- 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);
}
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);
# 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
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"
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!
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
# 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
Thread Suspension e Sinais
O CoreCLR usa sinais POSIX (SIGUSR1, SIGUSR2) para suspender threads durante GC:
- GC thread envia
SIGUSR2para todas as threads gerenciadas - Signal handler em cada thread salva seu estado e sinaliza "safe point"
- GC executa (coleta, compacta)
- 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!)
.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
# 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
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)
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
numactlou use instâncias separadas por node- Containers em Kubernetes: Use
topologySpreadConstraintse resource limits que se alinham com NUMA boundaries- Monitore com
numastateperf 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
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
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()
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)
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!
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
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
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)))
}
}
# 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
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
// 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
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
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)
}
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()
}
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
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)
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
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
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
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,
}
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
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)
}
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!
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)
},
))
}
# 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
Regras práticas para Go em produção:
GOMAXPROCS= CPU limit do container (useautomaxprocs)go_threadsdeve ser <GOMAXPROCS * 5— mais indica blocking syscalls- Use pure Go DNS resolver (
GODEBUG=netdns=goou-tags netgo)- Configure
MaxIdleConnsPerHostno HTTP client (default 2 é muito baixo!)- Limite concorrência de file I/O e CGO com semaphores
- Monitore com
GODEBUG=schedtraceem staging, pprof em produção
Referências Bibliográficas
Livros
- Desenvolvimento do Kernel do Linux — Robert Love (David Cram, trad.)
- Linux Bible — Christopher Negus
- Sistemas Operacionais Modernos — Andrew Tanenbaum
- Systems Performance, 2nd Edition — Brendan Gregg (fonte dos valores de benchmark de fork/clone/context switch)
Documentação Oficial
- Linux Kernel Documentation — Threads Topology (x86)
- Linux Kernel: An Introduction — IBM Developer
- Linux kernel source — context_switch() em kernel/sched/core.c
- Linux kernel source — CFS Scheduler (kernel/sched/fair.c)
- The Go Programming Language Specification — Goroutines
- Go runtime scheduler design document (GMP model)
- .NET ThreadPool documentation
- ASP.NET Core performance best practices
- KPTI — Kernel Page-Table Isolation (Meltdown mitigation)
- PCID support in the Linux kernel
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 pacotesysstat) (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
-
go.uber.org/automaxprocs— detecta automaticamente CPU limits de cgroups e ajustaGOMAXPROCS(Uber) -
golang.org/x/sync/semaphore— semáforo com peso para controle de concorrência em Go -
github.com/prometheus/client_golang— cliente Prometheus para Go (exposição de métricas de runtime)
Top comments (0)