Otimização de performance
LidoNesta aula, você aprenderá técnicas essenciais para otimizar a performance de programas Go, focando na redução de alocações de memória, no uso de sync.Pool para reutilizar objetos, na pré-alocação de slices e mapas, e na importância de criar benchmarks antes de otimizar. Ao final, você terá um guia prático para identificar gargalos e aplicar melhorias com segurança.
Bem-vindo à aula 73 do nosso curso de Go! Nesta aula, vamos mergulhar em um dos aspectos mais importantes do desenvolvimento de software em Go: a otimização de performance. Go é conhecido por sua eficiência, mas para extrair o máximo de desempenho, você precisa entender como gerenciar memória e CPU de forma inteligente. Vamos explorar técnicas que vão desde a redução de alocações desnecessárias até o uso de pools de objetos para reutilização. Estas são habilidades práticas que diferenciam um desenvolvedor Go mediano de um especialista.
Antes de começarmos, é fundamental internalizar um princípio: otimização prematura é a raiz de todo o mal (como disse Donald Knuth). Portanto, vamos abordar a otimização de forma metódica, começando com a medição (benchmarks) e depois aplicando técnicas específicas. Ao final desta aula, você será capaz de identificar oportunidades de melhoria e implementá-las com confiança.
Reduzindo alocações
Alocações de memória são caras porque envolvem o gerenciamento do heap e, eventualmente, a coleta de lixo. Em Go, cada alocação no heap pode causar pressão no garbage collector (GC), que pausa a execução do programa para varrer a memória. Portanto, reduzir o número de alocações é uma das formas mais eficazes de melhorar a performance, especialmente em programas com alta taxa de operações.
Uma das principais fontes de alocações é o uso de interfaces, concatenação de strings, e criação de slices ou mapas sem capacidade pré-definida. Vamos analisar cada caso.
1. Interfaces: Quando você usa uma interface, o compilador pode precisar alocar uma caixa (boxing) para armazenar o valor concreto. Se você passar um valor por interface e ele for grande, isso pode causar alocação. Em loops de alta performance, prefira tipos concretos sempre que possível.
2. Concatenação de strings: O operador `+` cria uma nova string a cada concatenação, resultando em múltiplas alocações. Use `strings.Builder` para concatenar muitas strings de forma eficiente.
3. Slices e mapas: Quando você cria um slice com `make([]T, 0)` e depois usa `append`, o Go pode alocar várias vezes até atingir a capacidade final. Se você souber o tamanho aproximado, use `make([]T, 0, capacidade)` para pré-alocar. O mesmo vale para mapas.
Vamos ver um exemplo prático de como reduzir alocações em um loop:
package main
import (
"fmt"
"strings"
)
func main() {
// Ineficiente: concatenação com + em loop
var s string
for i := 0; i < 1000; i++ {
s += "a" // cria uma nova string a cada iteração
}
fmt.Println(len(s))
// Eficiente: strings.Builder
var builder strings.Builder
builder.Grow(1000) // pré-aloca capacidade
for i := 0; i < 1000; i++ {
builder.WriteByte('a')
}
s = builder.String()
fmt.Println(len(s))
}
No primeiro loop, cada iteração aloca uma nova string e copia o conteúdo anterior, resultando em O(n²) de custo. No segundo, usamos `strings.Builder` que mantém um buffer interno e cresce de forma amortizada, evitando cópias desnecessárias.
sync.Pool
O pacote `sync.Pool` é uma ferramenta poderosa para reutilizar objetos temporários, reduzindo alocações e aliviando a pressão no GC. Ele mantém uma coleção de objetos que podem ser recuperados e devolvidos, evitando que sejam descartados e recriados constantemente.
Quando você tem objetos que são caros para criar (por exemplo, buffers grandes, conexões, objetos com muitos campos), usar um pool pode melhorar drasticamente a performance. O `sync.Pool` é especialmente útil em cenários com concorrência, como servidores web que lidam com muitas requisições simultâneas.
Exemplo básico de uso:
package main
import (
"fmt"
"sync"
)
type Buffer struct {
data []byte
}
var bufferPool = sync.Pool{
New: func() interface{} {
return &Buffer{data: make([]byte, 0, 1024)}
},
}
func getBuffer() *Buffer {
return bufferPool.Get().(*Buffer)
}
func putBuffer(b *Buffer) {
b.data = b.data[:0] // limpa o slice mantendo a capacidade
bufferPool.Put(b)
}
func main() {
b := getBuffer()
// usa b
putBuffer(b)
fmt.Println("Pool funcionando")
}
No exemplo acima, o pool cria novos buffers quando necessário, mas depois de usar, você devolve o buffer ao pool para reutilização. Isso evita alocações repetidas. Note que `sync.Pool` não garante que os objetos sejam mantidos; o GC pode limpá-los a qualquer momento, então não é adequado para objetos que precisam persistir por muito tempo.
É importante lembrar que `sync.Pool` é projetado para objetos temporários e não deve ser usado como cache permanente. Além disso, o custo de gerenciar o pool pode superar os benefícios em casos simples, então use com moderação.
Pré-alocação
Pré-alocação é a técnica de reservar capacidade para slices, mapas e outros tipos de coleção antes de preenchê-los. Isso evita que o Go precise realocar e copiar os elementos várias vezes durante o crescimento dinâmico.
Quando você usa `append` em um slice sem capacidade suficiente, o Go aloca um novo array maior e copia os elementos existentes. Isso é caro, especialmente para slices grandes. Ao pré-alocar com `make([]T, 0, capacidade)`, você informa ao Go o tamanho esperado, e ele aloca tudo de uma vez, evitando cópias desnecessárias.
O mesmo se aplica a mapas: `make(map[K]V, capacidade)` pré-aloca espaço para o número esperado de entradas, reduzindo o número de realocações internas.
Vamos comparar:
package main
import "fmt"
func main() {
// Sem pré-alocação
var s []int
for i := 0; i < 10000; i++ {
s = append(s, i)
}
fmt.Println(len(s))
// Com pré-alocação
s = make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
s = append(s, i)
}
fmt.Println(len(s))
}
No primeiro caso, o slice pode realocar várias vezes (tipicamente dobrando a capacidade), causando cópias. No segundo, alocamos exatamente a capacidade necessária, e o append apenas preenche os índices, sem realocações.
Pré-alocação também é útil em mapas quando você sabe o número aproximado de chaves. Isso reduz o overhead de rehash.
Benchmark antes
Antes de otimizar qualquer código, é crucial medir o desempenho atual. Sem benchmarks, você está adivinhando onde estão os gargalos, e pode perder tempo otimizando partes que não são críticas. O Go fornece uma ferramenta excelente para isso: o pacote `testing` com suporte a benchmarks.
Para criar um benchmark, você escreve uma função que começa com `Benchmark` e recebe um parâmetro `*testing.B`. Dentro dela, você executa o código que deseja medir, e o framework repete a execução automaticamente para obter estatísticas confiáveis.
Exemplo de benchmark:
package main
import (
"testing"
)
func BenchmarkConcat(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = "a" + "b" + "c"
}
}
func BenchmarkConcatBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
sb.WriteString("a")
sb.WriteString("b")
sb.WriteString("c")
_ = sb.String()
}
}
Para rodar os benchmarks, use o comando:
go test -bench=. -benchmem
A flag `-benchmem` mostra alocações por operação, o que é essencial para avaliar o impacto das otimizações.
Os resultados mostrarão o tempo por operação e o número de alocações. Por exemplo:
BenchmarkConcat-8 100000000 0.25 ns/op 0 B/op 0 allocs/op
BenchmarkConcatBuilder-8 20000000 7.5 ns/op 24 B/op 2 allocs/op
Nesse caso, a concatenação simples é mais rápida, mas isso é porque as strings são constantes e o compilador otimiza. Em cenários reais com variáveis, o builder pode ser melhor. O ponto é: sempre meça.
Boas práticas e observações finais
Ao otimizar, lembre-se:
- **Otimize somente quando necessário**: use um profiler (como `pprof`) para identificar os pontos quentes.
- **Escreva código claro primeiro**: a otimização pode tornar o código mais complexo. Mantenha um equilíbrio.
- **Use `go test -bench` e `-benchmem`** para acompanhar mudanças.
- **Considere o custo-benefício**: às vezes, a otimização não vale a pena se o ganho for mínimo.
Referências
- Effective Go
- Documentação do sync.Pool
- Documentação de benchmarks em testing
- Blog da Go: Profiling Go Programs
- Blog oficial da Go (diversos artigos sobre performance)
- High Performance Go Workshop (Dave Cheney)
Exercícios
- Escreva uma função que soma todos os números de 1 a N e use `strings.Builder` para construir uma string com o resultado e mais algumas palavras. Compare com a concatenação usando `+` em um benchmark.
- Crie um programa que simule um servidor que processa requisições. Use `sync.Pool` para reutilizar buffers de bytes e meça a redução de alocações.
- Pré-aloque um slice com capacidade 1000 e preencha com os números de 0 a 999. Compare com um slice sem pré-alocação usando `append` em um benchmark.
- Escreva um benchmark que meça a performance de um mapa com pré-alocação de capacidade versus sem. Use um número fixo de inserções.
- Analise o seguinte código e identifique possíveis alocações desnecessárias. Reescreva-o para reduzir alocações, justificando cada mudança.
Exemplo de benchmark comparando concatenação com `+` e `strings.Builder`:
func BenchmarkConcatPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
s := ""
for j := 0; j < 100; j++ {
s += "a"
}
_ = s
}
}
func BenchmarkConcatBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for j := 0; j < 100; j++ {
sb.WriteString("a")
}
_ = sb.String()
}
}
Rode com `go test -bench=. -benchmem` e observe que o builder tem menos alocações e é mais rápido.
Exemplo de uso de sync.Pool:
var pool = sync.Pool{
New: func() interface{} {
return make([]byte, 0, 1024)
},
}
func handleRequest() {
buf := pool.Get().([]byte)
defer pool.Put(buf[:0])
// usa buf
}
Em um benchmark, meça as alocações com e sem pool. O pool deve reduzir drasticamente as alocações.
Benchmark comparando pré-alocação:
func BenchmarkNoPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
var s []int
for j := 0; j < 1000; j++ {
s = append(s, j)
}
}
}
func BenchmarkPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
s := make([]int, 0, 1000)
for j := 0; j < 1000; j++ {
s = append(s, j)
}
}
}
O pré-alocado terá menos alocações e será mais rápido.
Benchmark de mapas:
func BenchmarkMapNoPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
m := make(map[int]int)
for j := 0; j < 1000; j++ {
m[j] = j
}
}
}
func BenchmarkMapPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
m := make(map[int]int, 1000)
for j := 0; j < 1000; j++ {
m[j] = j
}
}
}
O mapa com pré-alocação reduz o número de realocações internas.
Código original:
func process(data []string) string {
result := ""
for _, s := range data {
result += s
}
return result
}
Problema: concatenação com `+` causa muitas alocações. Melhoria:
func process(data []string) string {
var builder strings.Builder
builder.Grow(len(data) * 10) // estimativa
for _, s := range data {
builder.WriteString(s)
}
return builder.String()
}
Isso reduz alocações e melhora a performance.