O gerenciamento de memória é um dos aspectos mais importantes para escrever programas eficientes e previsíveis em Go. Diferente de linguagens como C ou C++, onde o programador controla manualmente a alocação e liberação de memória, Go utiliza um garbage collector (GC) automático para gerenciar a memória em tempo de execução. Isso reduz a carga cognitiva do desenvolvedor, mas exige um entendimento básico de como o GC funciona para evitar armadilhas comuns e otimizar o desempenho.

Nesta aula, vamos mergulhar no funcionamento interno do GC do Go, entender a diferença entre alocação na stack e no heap, descobrir como o compilador decide onde alocar cada variável através da análise de escape, e aprender a ajustar o comportamento do GC com a variável de ambiente GOGC. Ao final, você terá uma visão clara de como a memória é gerenciada no Go e como escrever código que aproveite ao máximo essas características.

Como o GC do Go funciona

O garbage collector do Go é um coletor de marcação e varredura (mark-and-sweep) concorrente, que roda em paralelo com o programa, minimizando pausas. Ele usa o algoritmo de tricolor mark-and-sweep para rastrear objetos vivos e liberar os mortos. O processo é dividido em três fases: marcação, varredura e liberação. Durante a marcação, o GC percorre as raízes (globais, stacks, registradores) e marca todos os objetos alcançáveis. Na varredura, ele remove os objetos não marcados e libera a memória.

Uma característica importante é que o GC do Go é concorrente, ou seja, ele executa em goroutines separadas, intercalando com a execução do programa. Isso reduz as pausas (STW - Stop The World) a poucos milissegundos, geralmente menores que 100 microssegundos. O GC também é incremental, dividindo o trabalho em pequenos passos para evitar longas pausas.

O GC do Go é não geracional, o que significa que ele não diferencia objetos jovens de antigos. Em vez disso, ele varre todo o heap a cada ciclo. Isso simplifica a implementação, mas pode ser menos eficiente para programas com muitos objetos temporários. No entanto, o Go compensa com uma alocação eficiente e a análise de escape, que veremos a seguir.

Para ilustrar, considere o seguinte programa que aloca muitos objetos:

package main

import (
    "fmt"
    "runtime"
)

func main() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("Antes: HeapAlloc = %v KB\n", m.HeapAlloc/1024)

    // Aloca muitos objetos
    for i := 0; i < 1000000; i++ {
        _ = make([]byte, 1024)
    }

    runtime.ReadMemStats(&m)
    fmt.Printf("Depois: HeapAlloc = %v KB\n", m.HeapAlloc/1024)
    runtime.GC() // força GC
    runtime.ReadMemStats(&m)
    fmt.Printf("Após GC: HeapAlloc = %v KB\n", m.HeapAlloc/1024)
}

Nesse exemplo, a memória alocada no heap aumenta e depois é liberada após o GC. O GC é invocado automaticamente quando a memória alocada cresce, mas podemos forçá-lo com runtime.GC().

Alocação na stack vs heap

Em Go, a memória pode ser alocada em duas regiões distintas: a stack (pilha) e o heap (monte). A stack é uma área de memória de acesso rápido, usada para variáveis locais e chamadas de função. Ela é organizada em LIFO (Last In, First Out) e é gerenciada automaticamente pelo compilador. A stack é eficiente porque a alocação e desalocação são simples: basta mover o ponteiro da pilha.

O heap é uma área de memória mais ampla, usada para objetos que precisam sobreviver à função que os criou, como estruturas de dados dinâmicas. A alocação no heap é mais cara, pois envolve o gerenciamento do GC e pode causar fragmentação.

Em Go, o compilador decide, em tempo de compilação, se uma variável deve ser alocada na stack ou no heap. Isso é feito pela análise de escape (escape analysis). Se uma variável não escapa da função (ou seja, não é referenciada fora dela), ela é alocada na stack. Caso contrário, vai para o heap.

Por exemplo:

package main

type Point struct {
    X, Y int
}

// Aloca na stack (não escapa)
func createPointStack() Point {
    p := Point{X: 1, Y: 2}
    return p // retorna por valor, cópia
}

// Aloca no heap (escapa)
func createPointHeap() *Point {
    p := Point{X: 1, Y: 2}
    return &p // retorna ponteiro, escapa
}

func main() {
    _ = createPointStack()
    _ = createPointHeap()
}

No primeiro caso, o valor é retornado por cópia, então o compilador pode alocar na stack. No segundo, retornamos um ponteiro para a variável local, o que força a alocação no heap, pois o objeto precisa sobreviver após a função retornar.

Entender a diferença é crucial para otimizar o desempenho. Alocar na stack é praticamente gratuito, enquanto alocar no heap pode gerar pressão no GC. Portanto, em código de alto desempenho, tente minimizar alocações no heap retornando valores por cópia sempre que possível.

Escape analysis

A análise de escape é uma técnica de otimização do compilador Go que determina se uma variável pode escapar do escopo da função em que é declarada. Se a variável não escapa, ela é alocada na stack; caso contrário, é alocada no heap. Essa análise é feita em tempo de compilação e é fundamental para reduzir a pressão no GC.

O compilador Go usa uma análise de escape conservadora, ou seja, se não puder provar que a variável não escapa, ele a aloca no heap. Isso garante segurança, mas pode levar a alocações desnecessárias em alguns casos.

Vamos ver alguns exemplos de escape:

package main

import "fmt"

var global *int

func f() {
    x := 42
    global = &x // escapa: referência atribuída a variável global
}

func g() *int {
    y := 10
    return &y // escapa: retorna ponteiro
}

func h() {
    z := 5
    fmt.Println(&z) // escapa: endereço passado para função externa
}

func main() {
    f()
    g()
    h()
}

Todos esses casos fazem com que as variáveis escapem para o heap. O compilador pode exibir relatórios de escape analysis usando a flag -gcflags=-m:

go build -gcflags='-m' main.go

Isso imprime mensagens como moved to heap: x para indicar alocações no heap.

Para evitar escapes desnecessários, você pode:

  • Retornar valores por cópia em vez de ponteiros.
  • Evitar armazenar ponteiros em variáveis globais.
  • Usar tipos de valor (structs) em vez de ponteiros quando possível.
  • Inicializar slices com capacidade pré-definida para evitar realocações.

Compreender a análise de escape permite escrever código mais eficiente, reduzindo a carga no GC e melhorando o desempenho geral.

Tuning (GOGC)

O GC do Go é ajustável através da variável de ambiente GOGC, que controla a frequência das coletas. O valor de GOGC representa o percentual de crescimento do heap que deve ocorrer antes de uma coleta ser disparada. O padrão é 100, o que significa que o GC roda quando o heap dobra de tamanho em relação ao tamanho após a última coleta.

Um valor mais alto (por exemplo, GOGC=200) faz com que o GC rode menos frequentemente, permitindo que o heap cresça mais antes de coletar. Isso reduz a frequência de pausas, mas aumenta o uso de memória. Um valor mais baixo (por exemplo, GOGC=50) faz o GC rodar mais frequentemente, reduzindo o pico de memória, mas aumentando a sobrecarga de CPU.

Exemplo de uso:

GOGC=200 go run main.go

Também é possível ajustar o GC programaticamente usando debug.SetGCPercent:

package main

import (
    "fmt"
    "runtime/debug"
)

func main() {
    // Define GOGC para 200
    old := debug.SetGCPercent(200)
    fmt.Println("Valor anterior:", old)
    // ... executa o programa ...
    // Restaura para o valor padrão
    debug.SetGCPercent(old)
}

A escolha do valor ideal de GOGC depende do seu programa e dos seus requisitos. Se você precisa de baixa latência, pode aumentar GOGC para reduzir pausas. Se você tem restrições de memória, diminua GOGC para manter o heap sob controle.

Além do GOGC, existem outras opções de tuning, como o limite de memória do heap via GOMEMLIMIT (disponível a partir do Go 1.19), que define um teto de memória para o heap, e as flags GOGC=off para desligar o GC completamente (não recomendado em produção).

Para monitorar o comportamento do GC, você pode usar as estatísticas de memória em runtime.MemStats ou ativar o log de GC com a flag GODEBUG=gctrace=1:

GODEBUG=gctrace=1 go run main.go

Isso imprime linhas de log mostrando o número de coletas, o tempo de pausa, a memória liberada, etc.

Ajustar o GC é uma arte. Comece com o padrão e monitore o desempenho. Use pprof e as ferramentas de profiling para identificar gargalos e então ajuste GOGC conforme necessário.

Boas práticas e observações finais

Para aproveitar ao máximo o gerenciamento de memória do Go, siga estas boas práticas:

  • Evite alocações desnecessárias: Reutilize buffers e estruturas sempre que possível.
  • Prefira retornar valores por cópia em vez de ponteiros, para reduzir escapes.
  • Use goroutines com cautela: Muitas goroutines podem aumentar a pressão na stack, mas cada uma tem uma stack pequena que cresce conforme necessário.
  • Monitore o desempenho com pprof e as estatísticas do runtime para tomar decisões informadas.
  • Entenda o seu workload: Para programas com muitos objetos de curta duração, um GOGC maior pode ser benéfico; para programas com memória limitada, um menor.

O gerenciamento de memória em Go é automático, mas não é mágico. Com o conhecimento desta aula, você pode escrever código que minimize a pressão no GC e maximize o desempenho.

Referências

Exercícios

  1. Exercício 1: Explique a diferença entre alocação na stack e no heap. Dê um exemplo de cada.
  2. ✓ Resposta: A stack é usada para variáveis locais e tem alocação rápida, mas é limitada. O heap é usado para objetos que precisam sobreviver à função, mas sua alocação é mais lenta e gerenciada pelo GC. Exemplo de stack: func f() { x := 1 }. Exemplo de heap: func f() *int { x := 1; return &x }.
  3. Exercício 2: O que é escape analysis e como ela afeta a alocação de memória?
  4. ✓ Resposta: Escape analysis é uma técnica do compilador que determina se uma variável pode ser referenciada após a função retornar. Se não escapa, é alocada na stack; se escapa, vai para o heap. Isso reduz alocações desnecessárias.
  5. Exercício 3: Como você pode verificar se uma variável escapa para o heap?
  6. ✓ Resposta: Use a flag -gcflags=-m ao compilar ou executar o código. Por exemplo: go build -gcflags='-m' main.go. O compilador imprimirá mensagens como moved to heap: x.
  7. Exercício 4: O que é GOGC e como você ajusta o comportamento do GC?
  8. ✓ Resposta: GOGC é uma variável de ambiente que controla a frequência do GC. O valor padrão é 100, indicando que o GC roda quando o heap dobra de tamanho. Você pode definir GOGC=200 para coletar menos frequentemente ou GOGC=50 para coletar mais. Também pode usar debug.SetGCPercent programaticamente.
  9. Exercício 5: Escreva um programa que aloca um grande número de objetos e meça o HeapAlloc antes e depois de forçar o GC. Explique o resultado.
  10. ✓ Resposta: Exemplo de programa:
    package main
    
    import (
        "fmt"
        "runtime"
    )
    
    func main() {
        var m runtime.MemStats
        runtime.ReadMemStats(&m)
        fmt.Println("HeapAlloc inicial:", m.HeapAlloc)
    
        for i := 0; i < 100000; i++ {
            _ = make([]byte, 1024)
        }
    
        runtime.ReadMemStats(&m)
        fmt.Println("HeapAlloc após alocações:", m.HeapAlloc)
    
        runtime.GC()
        runtime.ReadMemStats(&m)
        fmt.Println("HeapAlloc após GC:", m.HeapAlloc)
    }
    
    O HeapAlloc aumenta após as alocações e diminui após o GC, mostrando que o GC libera memória não utilizada.