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

Exercícios

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Analise o seguinte código e identifique possíveis alocações desnecessárias. Reescreva-o para reduzir alocações, justificando cada mudança.

✓ Resposta:

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.

✓ Resposta:

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.

✓ Resposta:

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.

✓ Resposta:

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.

✓ Resposta:

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.