Destino Dev

Blog

Diagnóstico e redução de tail-latency em Go: pprof, contenção e GOMAXPROCS

Por John Pires · 16 de março de 2026

Diagnóstico e redução de tail-latency em Go: pprof, contenção e GOMAXPROCS

1) Problema real que resolve

O que é tail-latency e por que importa

Tail-latency refere-se à cauda da distribuição de latências (p95,p99,p999). Em sistemas distribuídos, a latência mediana não basta: algumas requisições lentas (p99) degradam a experiência do usuário e a performance do sistema agregado. Em serviços REST/GRPC, uma pequena porcentagem de requisições com latência alta pode aumentar significativamente o tempo total de respostas compostas (fan-out), causar timeouts e reduzir SLA.

Em Go, tail-latency pode vir de várias fontes: contenção de mutex, garbage collection pausas longas (em cenários particulares), syscalls bloqueantes, scheduling de goroutines, ou sobrecarga do runtime por configuração inadequada de GOMAXPROCS. O ponto deste artigo é mostrar como diagnosticar — com pprof e métricas — e aplicar remediações pragmáticas.

Quando esse problema aparece em serviços Go

Geralmente em cargas com alta concorrência (centenas a milhares de req/s) e handlers que fazem trabalho não-homogêneo: alguns caminhos gravam logs, outros acessam um recurso compartilhado, outros executam processamento pesado. Uma hot-spot no código (uma lock global, um pool pequeno, um canal bloqueado) cria filas e spikes na cauda.

Esse artigo assume que você já conhece goroutines, mutex e a API net/http. O foco será diagnóstico prático e padrões de correção. Vou fazer como em code review: mostrar um jeito errado que vemos em produção e depois a solução correta, com ferramentas e testes.

2) Abordagem ingênua vs abordagem otimizada

Abordagem ingênua: um mutex global

Um antipattern comum é proteger acesso a qualquer recurso com um mutex global. Funciona para corrigir condições de corrida, mas serializa trabalho e cria fila. Em baixa carga parece OK; em pico, vira causa de p99 elevados.

Além do mutex, verás usos comuns: sync.Cond, canais com buffer pequeno onde produtores acumulam, ou um singleton com inicialização lenta feita na primeira requisição. Tudo isso fabrica tail-latency.

Abordagem otimizada: técnicas para reduzir cauda

Estratégias que reduzem tail-latency em Go:

  • Evitar lock global: shard locks, use sync.Map quando apropriado, use atomics.
  • Usar sync.Pool para objetos temporários em alta taxa, reduzindo GC overhead.
  • Mudar trabalho bloqueante para workers assíncronos (fila + pool de workers).
  • Instrumentar com pprof: CPU, heap, block e goroutine para ver onde o runtime espera.
  • Avaliar GOMAXPROCS: às vezes aumentar ajuda, às vezes aumenta preempção — medir é obrigatório.

Nos próximos exemplos mostro o jeito errado (mutex global que causa filas) e duas correções: sharding + sync.Pool e um worker pool assíncrono para operações lentas.

3) Implementação com código real

Exemplo ingênuo (errado): handler com mutex global)

Contexto: um handler HTTP que acessa um mapa compartilhado com uma operação de leitura+escrita. Para simplificar, a operação faz trabalho leve, mas protegida por um mutex global que cria contenção.

O código abaixo demonstra o antipattern. Em testes de carga, o mutex vira ponto quente e p99 explode.

package main

import (
    "fmt"
    "log"
    "net/http"
    "sync"
    "time"
)

var (
    store = make(map[string]string)
    mu    sync.Mutex // mutex global: problema em alta concorrência
)

func handler(w http.ResponseWriter, r *http.Request) {
    key := r.URL.Query().Get("k")
    if key == "" {
        http.Error(w, "missing k", http.StatusBadRequest)
        return
    }

    mu.Lock()
    // simula trabalho leve porém protegido pelo mutex
    v := store[key]
    time.Sleep(5 * time.Millisecond) // trabalho simulado
    store[key] = v + "x"
    mu.Unlock()

    fmt.Fprintln(w, "ok")
}

func main() {
    http.HandleFunc("/write", handler)
    log.Println("listening :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Após rodar carga concorrente, pprof mostra muitas goroutines bloqueadas no mutex. A latência mediana pode ser aceitável, p99 dispara.

Correção 1: sharding de estado (reduz contenção)

Contexto: separar o mapa em N shards com mutex por shard. Reduz a probabilidade de duas requisições baterem no mesmo lock. É simples, eficaz e compatível com operações por chave.

O código abaixo substitui o mapa global por shards e escolhe o shard por hash de chave.

package main

import (
    "fmt"
    "hash/fnv"
    "log"
    "net/http"
    "sync"
    "time"
)

type shard struct {
    mu sync.Mutex
    m  map[string]string
}

const shards = 32

var storeShards [shards]shard

func init() {
    for i := 0; i < shards; i++ {
        storeShards[i].m = make(map[string]string)
    }
}

func shardFor(key string) *shard {
    h := fnv.New32a()
    h.Write([]byte(key))
    return &storeShards[int(h.Sum32())%shards]
}

func handler(w http.ResponseWriter, r *http.Request) {
    key := r.URL.Query().Get("k")
    if key == "" {
        http.Error(w, "missing k", http.StatusBadRequest)
        return
    }

    s := shardFor(key)
    s.mu.Lock()
    v := s.m[key]
    time.Sleep(5 * time.Millisecond) // mesmo trabalho simulado
    s.m[key] = v + "x"
    s.mu.Unlock()

    fmt.Fprintln(w, "ok")
}

func main() {
    http.HandleFunc("/write", handler)
    log.Println("listening :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Depois dessa mudança, contenção cai: p99 melhora porque requests com chaves diferentes não competem pelo mesmo lock. Contudo, se acesso for concentrado em poucas chaves (hot keys), o problema persiste.

Correção 2: offload para worker pool (tratamento assíncrono de trabalho pesado)

Contexto: quando parte do trabalho é CPU-bound ou I/O que pode ser desacoplado da resposta HTTP rápida, enviar para um pool de workers evita que o handler bloqueie e provoque fila.

O código abaixo enfileira tarefas e responde imediatamente com accepted (ou pode bloquear até completar dependendo do requisito). Use com cautela: aumenta complexidade de consistência.

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "time"
)

type task struct {
    Key string
}

var tasks = make(chan task, 1000)

func worker(id int) {
    for t := range tasks {
        // simula trabalho pesado ou I/O
        time.Sleep(20 * time.Millisecond)
        log.Printf("worker %d processed %s\n", id, t.Key)
    }
}

func enqueueHandler(w http.ResponseWriter, r *http.Request) {
    var t task
    if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
        http.Error(w, "bad payload", http.StatusBadRequest)
        return
    }
    select {
    case tasks <- t:
        w.WriteHeader(http.StatusAccepted)
        w.Write([]byte("queued"))
    default:
        // fila cheia: rejeitar agressivamente para evitar piores filas
        http.Error(w, "busy", http.StatusServiceUnavailable)
    }
}

func main() {
    for i := 0; i < 8; i++ { // pool de workers configurável
        go worker(i)
    }
    http.HandleFunc("/enqueue", enqueueHandler)
    log.Println("listening :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Essa abordagem reduz latência do path crítico (request/response) movendo trabalho. Importante: quando usar, garantir idempotência, retry e observabilidade do processamento assíncrono.

4) Testes e validação

Como coletar profiles úteis (CPU, block, goroutine)

Contexto: usar net/http/pprof no processo e go tool pprof para analisar. Alternativamente, coletar programaticamente CPU/heap/block profile para pipelines CI. Abaixo, um utilitário que ativa profiles programaticamente e executa uma carga de teste simples.

O exemplo cria profiles em disco: cpu.pprof e block.pprof.

package main

import (
    "log"
    "os"
    "runtime"
    "runtime/pprof"
    "time"
)

func main() {
    f, err := os.Create("cpu.pprof")
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()

    if err := pprof.StartCPUProfile(f); err != nil {
        log.Fatal(err)
    }
    defer pprof.StopCPUProfile()

    // aumenta amostragem para block profile
    runtime.SetBlockProfileRate(1)

    // workload sintético: simula servidor em carga
    runWorkload()

    // escreve block profile
    bf, _ := os.Create("block.pprof")
    defer bf.Close()
    if err := pprof.Lookup("block").WriteTo(bf, 0); err != nil {
        log.Print("write block profile: ", err)
    }
}

func runWorkload() {
    // simula tempo suficiente para coletar profile
    time.Sleep(10 * time.Second)
}

Depois de gerar cpu.pprof, rode localmente: go tool pprof cpu.pprof e use comandos like top, web, list. A block profile revela pontos onde goroutines ficam bloqueadas (mutexes, canais).

Interpretação prática das saídas do pprof

Regras rápidas:

  • Se pprof CPU mostra muita percentagem em runtime.schedule ou runtime.gopark, há contenção/scheduling — olhe block/goroutine profiles.
  • Block profile com muitas amostras em mutex ou syscall indica locks ou syscalls longos (I/O síncrono).
  • Heap profile com muitas alocações no mesmo tipo sugere usar sync.Pool ou reduzir temporários.

Com estes sinais, volte ao código: localize locks, veja contadores de espera, e aplique sharding ou pool.

Medição de latência e percentis (snippet)

Contexto: coletar latência por requisição e calcular percentis localmente para validar ganho. Exemplo abaixo registra latências em um slice e calcula percentis simples (adequado para validação, não para produção em larga escala).

package main

import (
    "fmt"
    "net/http"
    "sort"
    "time"
)

var latencies []int64

func recordLatency(d time.Duration) {
    latencies = append(latencies, int64(d.Microseconds()))
}

func stats() {
    if len(latencies) == 0 {
        fmt.Println("no samples")
        return
    }
    s := make([]int64, len(latencies))
    copy(s, latencies)
    sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
    p50 := s[len(s)*50/100]
    p90 := s[len(s)*90/100]
    p99 := s[len(s)*99/100]
    fmt.Printf("samples=%d p50=%dµs p90=%dµs p99=%dµs\n", len(s), p50, p90, p99)
}

func main() {
    http.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        time.Sleep(3 * time.Millisecond)
        recordLatency(time.Since(start))
        w.Write([]byte("ok"))
    })
    go func() {
        for {
            time.Sleep(5 * time.Second)
            stats()
        }
    }()
    http.ListenAndServe(":8080", nil)
}

Use esse tipo de medição para comparar antes/depois das mudanças. Para produção, use histogramas rotativos (prometheus histograms, hdrhistogram) para não acumular todas as amostras em memória.

5) Considerações de produção

GOMAXPROCS: ajuste e armadilhas

GOMAXPROCS controla quantos threads do sistema podem executar goroutines simultaneamente. Valor padrão moderno ajusta-se ao número de CPUs (runtime.GOMAXPROCS(0)). Ajustar GOMAXPROCS pode melhorar throughput mas também causar mais preempções do runtime e maiores interferências com GC se for exagerado em máquinas com hyperthreading.

Regras práticas:

  • Não mude GOMAXPROCS sem medir: experimente valores em staging com perf e pprof.
  • Para workloads I/O-bound, aumentar GOMAXPROCS não necessariamente melhora latência; para CPU-bound, geralmente ajuda até o número de cores físicos.
  • Use runtime.GOMAXPROCS(n) para testar, mas mantenha configuração via variáveis de ambiente ou container limits em produção.
Tip: quando usar containers, verifique que o runtime vê os CPU quotas corretamente; em alguns casos você precisa configurar GOMAXPROCS manualmente se o kernel não expor quotas.

Erros comuns, armadilhas e dicas práticas

Erros que vejo em code reviews:

  1. Proteger todo acesso com um mutex único por conveniência (serialização desnecessária).
  2. Ignorar sync.Pool quando aloca muitos objetos temporários na hot path.
  3. Assumir que aumentar GOMAXPROCS sempre ajuda: pode aumentar preempções e latência tail. Medir é obrigatório.
  4. Não coletar block profile: você não sabe se o runtime está esperando lock ou realizando syscall.

Dicas práticas:

  • Instrumente caminhos críticos com timers e histograms (Prometheus ou similar).
  • Priorize reduzir variância (p99) antes do throughput bruto.
  • Rejeição rápida (backpressure) é preferível a filas indefinidas que aumentam latência globalmente.

Conclusão prática

Tail-latency em Go quase sempre tem causa identificável: contenção (locks, pools pequenos), trabalho síncrono e configurações incorretas do runtime. A combinação de pprof (CPU, block, goroutine, heap) com medições de percentis e mudanças iterativas (sharding, worker pools, sync.Pool, ajuste de GOMAXPROCS) resolve a maioria dos casos.

Workflow recomendado:

  1. Reproduza o pico em staging com carga semelhante.
  2. Colete cpu/heap/block/goroutine profiles e métricas de percentis.
  3. Localize hotspots: locks, alocações, syscalls.
  4. Experimente remediações simples (sharding, pool, offload) e meça novamente.
  5. Se ajustar GOMAXPROCS, faça benchmark com valores próximos ao número de cores físicas e verifique p99.

Em code review: seja crítico com locks globais e caminhos que fazem trabalho heterogêneo. Prefira soluções que reduzem variância e a probabilidade de filas. Medir antes e depois é essencial — sem dados, qualquer mudança é chute.

Este artigo forneceu exemplos práticos, código funcional para testar e comandos/ideias para interpretar pprof. As técnicas apresentadas são imediatamente aplicáveis a APIs REST em Go e a serviços internos que sofrem com tail-latency.

Assuntos: Go, pprof, Performance, concurrency, gomaxprocs

Leia também