Memória e garbage collector
Nesta aula, exploramos o gerenciamento de memória no Go, incluindo o funcionamento do garbage collector, a diferença entre alocação na stack e no heap, a análise de escape e as opções de tuning como GOGC. Ao final, você será capaz de escrever código mais eficiente e entender como o runtime otimiza a memória.
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
GOGCmaior 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
- Guia oficial do GC em Go
- Documentação do pacote runtime
- Documentação do pacote runtime/debug
- Blog do Go: Gerenciamento de memória (palestra)
- Notas da versão Go 1.19 (GOMEMLIMIT)
- Especificação da linguagem Go
Exercícios
- Exercício 1: Explique a diferença entre alocação na stack e no heap. Dê um exemplo de cada.
- Exercício 2: O que é escape analysis e como ela afeta a alocação de memória?
- Exercício 3: Como você pode verificar se uma variável escapa para o heap?
- Exercício 4: O que é GOGC e como você ajusta o comportamento do GC?
- 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.
func f() { x := 1 }. Exemplo de heap: func f() *int { x := 1; return &x }.-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.GOGC=200 para coletar menos frequentemente ou GOGC=50 para coletar mais. Também pode usar debug.SetGCPercent programaticamente.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.