← Voltar para o blog

sync.Pool em Go: Reutilização de Objetos e Performance em Produção

Aprenda sync.Pool em Go para reutilizar objetos, reduzir alocações e a pressão do GC em serviços de alta vazão. Padrões para buffers, bytes.Buffer, protobuf e armadilhas de produção com benchmark.

Alocações são baratas em Go, mas em serviços de alta vazão elas se acumulam: cada request cria buffers, slices temporários, structs de parse e objetos de protocolo que viram lixo milissegundos depois. O coletor de lixo limpa tudo, mas o custo de varrer o heap escala com o número de objetos vivos. Quando o perfil mostra que boa parte do tempo da CPU está em runtime.gcBgMarkWorker ou em mallocgc, a pergunta certa não é “como alocar mais rápido”, mas “como alocar menos”. É exatamente isso que sync.Pool resolve.

Este guia mostra como usar sync.Pool em serviços reais, quando ele ajuda de verdade e onde ele vira uma otimização prematura que adiciona complexidade sem retorno. Ele complementa o guia de pprof em produção, o guia de PGO (Profile-Guided Optimization), o guia de concorrência e os padrões de concorrência em Go.

O que sync.Pool faz (e o que não faz)

sync.Pool é um conjunto de objetos temporários, seguros para uso concorrente, que podem ser guardados e reutilizados. A interface é mínima:

var bufPool = sync.Pool{
    New: func() any {
        return new(bytes.Buffer)
    },
}

func handler(w http.ResponseWriter, r *http.Request) {
    buf := bufPool.Get().(*bytes.Buffer)
    defer bufPool.Put(buf)
    buf.Reset()

    // ... usa buf para montar a resposta ...
    w.Write(buf.Bytes())
}

Get devolve um objeto do pool (ou chama New se o pool estiver vazio). Put devolve o objeto para reutilização. A regra fundamental é: objetos no pool podem ser descartados a qualquer momento, sem aviso. O runtime esvazia o pool a cada coleta de lixo. Isso tem duas consequências importantes:

  1. sync.Pool não é um cache. Nunca guarde objetos que você não pode reconstruir.
  2. sync.Pool não é uma conexão de banco de dados nem um worker. Para isso use um pool real, como o de pgxpool para PostgreSQL ou um worker pool com fila de jobs.

O caso clássico: buffers em handlers HTTP

O padrão mais comum e mais seguro é reutilizar bytes.Buffer em hot paths. Sem pool, cada serialização de JSON, cada montagem de corpo de resposta e cada encoding gera um buffer novo:

func renderJSON(w http.ResponseWriter, v any) error {
    var buf bytes.Buffer
    if err := json.NewEncoder(&buf).Encode(v); err != nil {
        return err
    }
    w.Header().Set("Content-Type", "application/json")
    _, err = w.Write(buf.Bytes())
    return err
}

Isso é correto, mas em um endpoint que recebe 50 mil requisições por segundo o heap enche de buffers descartáveis. Com pool, a mesma função reutiliza memória:

var jsonBufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

func renderJSON(w http.ResponseWriter, v any) error {
    buf := jsonBufPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        jsonBufPool.Put(buf)
    }()

    if err := json.NewEncoder(buf).Encode(v); err != nil {
        return err
    }
    w.Header().Set("Content-Type", "application/json")
    _, err = w.Write(buf.Bytes())
    return err
}

Duas armadilhas sutis aqui. Primeiro, sempre chame Reset() — tanto no Get quanto no Put. Resetar no Put deixa o objeto pronto para o próximo uso; resetar no Get protege contra o caso (raro, mas possível) de um objeto que entrou no pool sem reset. Segundo, nunca retenha buf.Bytes() depois do Put. O conteúdo do slice subjacente pode ser sobrescrito pelo próximo usuário do objeto.

Quando sync.Pool realmente ajuda

sync.Pool brilha em três cenários mensuráveis. O primeiro são hot paths de curta duração: handlers HTTP, renderização de templates, marshaling de protocolo, logs estruturados com slog. O segundo são objetos caros de construir mas baratos de reutilizar — não “caros” no sentido de tempo de CPU, mas que alocam arrays internos grandes (buffers de 4 KiB, slices de capacidade conhecida). O terceiro são workloads com rajadas: quando a vazão varia muito, o pool absorve o pico sem crescer o heap permanentemente, porque o GC esvazia o pool quando a pressão passa.

O sinal de que vale a pena é sempre o perfil. Antes de adicionar sync.Pool, rode go tool pprof no seu serviço (veja o guia de pprof) e olhe o profile de alocação (-alloc_objects, -alloc_space). Se bytes.Buffer.grow, runtime.makeslice ou encoding/json.(*encodeState).marshal dominarem, o pool vai ajudar. Se o gargalo for CPU em código seu, ou latência de rede, ou locks em banco de dados, o pool é otimização errada — isso é exatamente o que torna PGO e a leitura de flame graphs mais valiosos que palpites.

Benchmark prova o ponto

Benchmark é a única forma honesta de validar a mudança. Compare antes e depois:

func BenchmarkRenderSemPool(b *testing.B) {
    var w io.Discard
    payload := map[string]any{"id": 42, "nome": "Diego", "itens": []int{1, 2, 3, 4, 5}}
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        _ = renderJSONSemPool(w, payload)
    }
}

func BenchmarkRenderComPool(b *testing.B) {
    var w io.Discard
    payload := map[string]any{"id": 42, "nome": "Diego", "itens": []int{1, 2, 3, 4, 5}}
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        _ = renderJSONComPool(w, payload)
    }
}

Um resultado típico mostra o pool cortando alocações por operação de ~12 para ~3 e reduzindo B/op de ~1,5 KiB para ~150 bytes. A diferença em nanossegundos por operação pode parecer pequena isoladamente, mas multiplicada por milhões de requests vira dezenas de por cento de CPU devolvida. Sempre meça no seu workload real — números de benchmark de blog alheio não valem nada.

Padrões avançados: pré-alocação no New

O construtor New pode devolver um objeto já dimensionado para o tamanho típico do seu uso, eliminando o primeiro grow:

var headerPool = sync.Pool{
    New: func() any {
        b := make([]byte, 0, 256)
        return &b
    },
}

func writeHeader(dst io.Writer, headers map[string]string) error {
    bp := headerPool.Get().(*[]byte)
    defer func() {
        *bp = (*bp)[:0]
        headerPool.Put(bp)
    }()

    buf := *bp
    for k, v := range headers {
        buf = append(buf, k...)
        buf = append(buf, ": "...)
        buf = append(buf, v...)
        buf = append(buf, '\r', '\n')
    }
    buf = append(buf, '\r', '\n')
    _, err := dst.Write(buf)
    return err
}

Note três detalhes que separam código production-grade de tutorial. Guardamos *[]byte (ponteiro para slice) e não []byte, porque Put recebe any e devolver um slice diretamente faz o escape para o heap. Resetamos com *bp = (*bp)[:0] para preservar a capacidade. E nunca passamos buf para nenhuma goroutine que sobreviva ao defer — após o Put, o slice pode ser reusado por outra goroutine e seus dados sobrescritos. Esses detalhes são a diferença entre um pool que acelera o sistema e um pool que introduz data races silenciosos.

Armadilhas que invalidam o pool

A primeira armadilha é guardar estado no objeto. Se o objeto tem campos que carregam informação entre operações (IDs, contadores, referências a conexões), reusá-lo via pool vira bug intermitente. A segunda é esquecer o Reset e acabar acumulando dados de requisições anteriores — um vazamento sutil de informação entre usuários, grave em APIs multi-tenant. A terceira é usar pool para objetos grandes e raros: o GC vai coletar antes da próxima reutilização e você só adicionou overhead. A quarta, a pior, é tratar o pool como solução para um bug de vazamento de memória — se o heap cresce, o problema é provavelmente uma referência retida, não a falta de pool. Rode pprof no modo -inuse_space antes de otimizar.

Pool em pipelines com concorrência

Em pipelines que combinam errgroup ou worker pools, o pool precisa respeitar o cancelamento de contexto. O padrão seguro é obter o objeto por unidade de trabalho e devolvê-lo no fim de cada unidade, nunca no fim do pipeline inteiro:

func processar(ctx context.Context, in <-chan Registro) error {
    g, ctx := errgroup.WithContext(ctx)
    for w := 0; w < runtime.NumCPU(); w++ {
        g.Go(func() error {
            for reg := range in {
                if err := ctx.Err(); err != nil {
                    return err
                }
                if err := transformar(ctx, reg); err != nil {
                    return fmt.Errorf("registro %d: %w", reg.ID, err)
                }
            }
            return nil
        })
    }
    return g.Wait()
}

func transformar(ctx context.Context, reg Registro) error {
    buf := bufPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        bufPool.Put(buf)
    }()
    // ... serializa, valida, envia ...
    return nil
}

O detalhe decisivo é o defer dentro de transformar: cada registro pega e devolve seu buffer. Se você pegasse o buffer fora do loop do worker e só devolvesse no fim, um único objeto acumularia o trabalho de milhares de registros e nunca seria reusado — exatamente o oposto do objetivo. Em pipelines com cancelamento por contexto, o defer também garante que o buffer volte ao pool mesmo quando a função retorna cedo por erro.

Boas práticas resumidas

  • Use sync.Pool apenas em hot paths identificados por profile de alocação, nunca por intuição.
  • Sempre Reset() o objeto no Put e, por segurança, no Get também.
  • Guarde ponteiros (*bytes.Buffer, *[]byte) para evitar escape para o heap.
  • Nunca retenha slices derivados do objeto (buf.Bytes(), sub-slices) depois do Put.
  • Dimensione o objeto no New com make(..., 0, n) quando o tamanho típico é conhecido.
  • Não use pool para objetos com estado lógico, conexões, ou para “consertar” vazamentos.
  • Re-meça com benchmark antes e depois; sem número, é otimização por fé.

sync.Pool é uma das ferramentas mais baratas da biblioteca padrão para cortar alocações em serviços de alta vazão, mas só paga quando o profile confirma que alocações são o problema. Ele não substitui arquitetura, não salva código mal escrito e não compensa um pool de conexões mal configurado. O que ele faz é devolver CPU e memória que estavam sendo desperdiçadas com objetos efêmeros — e em escala, isso é diferença entre um serviço que aguenta pico e um serviço que degrada. Frameworks de pooling variam entre stacks, mas o conceito de reutilizar buffers em hot paths é universal — vale comparar com a abordagem do Python Dev Brasil sobre performance em backends ao planejar otimização entre linguagens.