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:
sync.Poolnão é um cache. Nunca guarde objetos que você não pode reconstruir.sync.Poolnã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.Poolapenas em hot paths identificados por profile de alocação, nunca por intuição. - Sempre
Reset()o objeto noPute, por segurança, noGettambém. - Guarde ponteiros (
*bytes.Buffer,*[]byte) para evitar escape para o heap. - Nunca retenha slices derivados do objeto (
buf.Bytes(), sub-slices) depois doPut. - Dimensione o objeto no
Newcommake(..., 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.