93/100 Dias de Golang - Stack, Heap, Escape Analysis e Garbage Collector
Table of Contents
#
Stack, Heap, Escape Analysis e Garbage Collector
No post anterior o profiler mostrou que quase um terço do tempo do programa ia para o garbage collector, e que a versão otimizada fazia 21 alocações em vez de 320 mil. Mas o que exatamente é uma “alocação”? Por que algumas variáveis custam mais que outras? E o que o GC faz com elas? Hoje vamos olhar por baixo dos panos.
#
Stack e Heap
Um programa em Go guarda as variáveis em dois lugares da memória:
Stack (pilha): cada goroutine tem a sua. Quando uma função é chamada, ela ganha um pedaço da stack (um frame) para suas variáveis locais; quando a função retorna, esse pedaço é descartado de uma vez só. Alocar na stack é praticamente de graça: é só mover um ponteiro. E não dá trabalho nenhum para o GC, porque a memória some sozinha quando a função termina.
Heap: uma área de memória compartilhada por todo o programa. É usada para dados que precisam sobreviver à função que os criou. Alocar no heap é mais caro (é preciso encontrar um espaço livre) e, principalmente, alguém precisa descobrir depois quando aquela memória pode ser liberada. Esse alguém é o garbage collector.
Em C, o programador escolhe: variável local vai para a stack, malloc vai para o heap. Em Go a gente não escolhe, quem decide é o compilador, usando uma técnica chamada escape analysis.
#
Escape Analysis
A regra é simples: se o compilador consegue provar que uma variável não é usada depois que a função retorna, ela vai para a stack. Se não consegue provar, ela “escapa” para o heap.
Dá para ver as decisões do compilador com a flag -gcflags=-m. Vamos usar este código:
package main
import "fmt"
type Usuario struct {
Nome string
Idade int
}
func criarValor() Usuario {
u := Usuario{Nome: "Victor", Idade: 30}
return u
}
func criarPonteiro() *Usuario {
u := Usuario{Nome: "Victor", Idade: 30}
return &u
}
func somar(nums []int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func sliceFixo() int {
nums := make([]int, 100)
return somar(nums)
}
func sliceGrande() int {
nums := make([]int, 100_000)
return somar(nums)
}
func contador() func() int {
c := 0
return func() int {
c++
return c
}
}
func main() {
u1 := criarValor()
u2 := criarPonteiro()
fmt.Println(u1.Nome, u2.Nome)
_ = sliceFixo()
_ = sliceGrande()
proximo := contador()
proximo()
}
go build -gcflags=-m
A saída tem bastante coisa sobre inlining (quando o compilador copia o corpo de uma função pequena para dentro de quem chamou), filtrando só o que importa:
./main.go:16:2: moved to heap: u
./main.go:20:12: nums does not escape
./main.go:29:14: make([]int, 100) does not escape
./main.go:34:14: make([]int, 100000) escapes to heap
./main.go:39:2: moved to heap: c
./main.go:40:9: func literal escapes to heap
./main.go:49:16: u1.Nome escapes to heap
./main.go:49:25: u2.Nome escapes to heap
Vamos por partes.
Retornando um ponteiro (moved to heap: u): no criarPonteiro retornamos &u, um ponteiro para uma variável local. Se u ficasse na stack, ela seria descartada quando a função retornasse e o ponteiro ficaria apontando para lixo. Então o compilador move u para o heap. Já no criarValor nada aparece: o return u devolve uma cópia da struct, e a original pode morrer junto com a função.
Em C, retornar o endereço de uma variável local é um bug clássico. Em Go é perfeitamente seguro, justamente por causa da escape analysis.
Slices (does not escape / escapes to heap): o make([]int, 100) ficou na stack, porque o slice só é usado dentro da função. O nums does not escape na função somar diz que ela só lê o slice, não guarda ele em nenhum lugar, e é isso que permite ao compilador manter o slice de quem chamou na stack. Já o make([]int, 100_000) escapou mesmo sem sair da função: são 800KB, grande demais para a stack, então o compilador manda para o heap.
Closures (moved to heap: c): a função anônima retornada pelo contador continua usando a variável c depois que o contador termina. Lembra do dia 8 sobre funções? Para isso funcionar, o c precisa morar no heap.
Interfaces (u1.Nome escapes to heap): o fmt.Println recebe ...any. Quando um valor é convertido para uma interface, o compilador muitas vezes não consegue saber o que a função vai fazer com ele, então ele escapa. É por isso que funções como fmt.Sprintf aparecem tanto em profiles de memória.
Resumindo os casos mais comuns de escape:
- Retornar um ponteiro para uma variável local;
- Guardar um ponteiro numa variável global, numa struct que escapa ou num channel;
- Closures que capturam variáveis e sobrevivem à função;
- Converter valores para interfaces (
any,error,fmt.Stringer…); - Variáveis muito grandes.
Para ver o motivo de cada decisão, dá para usar -gcflags='-m -m', que é bem mais verboso.
#
Quanto custa escapar?
Um benchmark comparando as duas formas de criar o Usuario:
package bench
import "testing"
type Usuario struct {
Nome string
Idade int
}
//go:noinline
func criarValor() Usuario {
return Usuario{Nome: "Victor", Idade: 30}
}
//go:noinline
func criarPonteiro() *Usuario {
return &Usuario{Nome: "Victor", Idade: 30}
}
func BenchmarkValor(b *testing.B) {
for b.Loop() {
u := criarValor()
_ = u
}
}
func BenchmarkPonteiro(b *testing.B) {
for b.Loop() {
u := criarPonteiro()
_ = u
}
}
O //go:noinline impede o compilador de fazer inlining dessas funções. Sem ele, o compilador é esperto o suficiente para perceber que o ponteiro nunca sai do benchmark e deixa tudo na stack, e não teríamos o que comparar.
go test -bench=. -benchmem
BenchmarkValor-24 1000000000 1.000 ns/op 0 B/op 0 allocs/op
BenchmarkPonteiro-24 71178562 17.07 ns/op 24 B/op 1 allocs/op
A versão com ponteiro é 17x mais lenta e faz uma alocação de 24 bytes por chamada. E esse custo ainda não inclui o trabalho que o GC vai ter depois para limpar esses objetos.
##
Então devo evitar ponteiros?
Não necessariamente. Isso não quer dizer “nunca use ponteiros”:
- Ponteiros existem para semântica: quando a função precisa modificar o valor, ou quando não pode existir uma cópia (um
sync.Mutex, por exemplo, nunca deve ser copiado); - Copiar uma struct muito grande pode custar mais que uma alocação;
- 10 nanossegundos só importam se a função roda milhões de vezes.
A regra prática é: escreva o código da forma mais clara, e use o pprof e o -gcflags=-m para otimizar onde o profiler mostrar que importa.
#
Garbage Collector
Tudo que vai para o heap precisa ser liberado em algum momento, e é aí que entra o GC. O garbage collector do Go é do tipo mark and sweep concorrente:
- Mark (marcação): partindo das “raízes” (variáveis globais e as stacks de todas as goroutines), o GC segue todos os ponteiros e marca cada objeto que encontra como “vivo”;
- Sweep (varredura): tudo no heap que não foi marcado não é mais alcançável pelo programa, então é lixo e a memória pode ser reaproveitada.
O “concorrente” quer dizer que esse trabalho acontece ao mesmo tempo que o programa roda, em outras goroutines. O programa só é pausado por períodos muito curtos (normalmente menos de 1 milissegundo), o famoso stop the world. O preço é que o GC usa CPU, por padrão cerca de 25% durante a fase de marcação, e é isso que vimos no profile do post anterior.
##
Vendo o GC trabalhar
Este programa aloca 50 blocos de 1MB, mas mantém só os 10 mais recentes, descartando os antigos:
package main
import (
"fmt"
"runtime"
)
func main() {
var dados [][]byte
for i := range 50 {
dados = append(dados, make([]byte, 1<<20)) // 1MB
if len(dados) > 10 {
dados = dados[1:] // descarta o mais antigo
}
_ = i
}
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("memória em uso no heap: %d MB\n", m.HeapAlloc/1024/1024)
fmt.Printf("total já alocado: %d MB\n", m.TotalAlloc/1024/1024)
fmt.Printf("execuções do GC: %d\n", m.NumGC)
}
A variável de ambiente GODEBUG=gctrace=1 faz o runtime imprimir uma linha a cada execução do GC:
go build -o gc .
GODEBUG=gctrace=1 ./gc
Repare que compilamos antes. Se usarmos
GODEBUG=gctrace=1 go run ., veremos também o GC do próprio comandogocompilando o programa.
gc 1 @0.000s 11%: 0.063+0.079+0.010 ms clock, ... 4->4->4 MB, 4 MB goal, ...
gc 2 @0.000s 12%: 0.026+0.061+0.007 ms clock, ... 9->9->9 MB, 9 MB goal, ...
gc 3 @0.000s 11%: 0.011+0.079+0.007 ms clock, ... 19->19->13 MB, 19 MB goal, ...
gc 4 @0.002s 4%: 0.012+0.083+0.006 ms clock, ... 26->26->16 MB, 26 MB goal, ...
gc 5 @0.006s 2%: 0.011+0.047+0.007 ms clock, ... 32->32->12 MB, 32 MB goal, ...
memória em uso no heap: 14 MB
total já alocado: 50 MB
execuções do GC: 5
Os campos mais importantes de cada linha:
gc 3: é a terceira execução do GC;@0.000s: tempo desde o início do programa (o programa é tão rápido que ainda não passou nem 1 milissegundo);11%: porcentagem do tempo de CPU gasto com GC até agora (alto aqui porque o programa é minúsculo e só faz alocar);19->19->13 MB: tamanho do heap quando o GC começou → quando terminou a marcação → o que sobrou vivo;19 MB goal: o tamanho de heap em que o GC decidiu rodar.
Alocamos 50MB no total, mas como só mantemos 10MB vivos, o GC rodou 5 vezes e o heap nunca passou de 32MB.
##
GOGC
Por que o GC decidiu rodar em 4MB, 9MB, 19MB, 27MB…? Por causa do GOGC, a principal configuração do GC. Ele diz quanto o heap pode crescer em relação ao que sobrou vivo na última coleta antes de rodar de novo. O padrão é GOGC=100: se depois de uma coleta sobraram 13MB vivos, a próxima roda quando o heap chegar perto de 26MB (13 + 100%).
Rodando o mesmo programa com valores diferentes:
GOGC=50 ./gc # execuções do GC: 8
GOGC=100 ./gc # execuções do GC: 5 (padrão)
GOGC=200 ./gc # execuções do GC: 2
GOGC=off ./gc # execuções do GC: 0
É uma troca entre CPU e memória:
GOGCmenor: o GC roda mais vezes, usa mais CPU, mas o programa ocupa menos memória;GOGCmaior: o GC roda menos, sobra CPU para o programa, mas ele ocupa mais memória;GOGC=off: desliga o GC. A memória só cresce. Útil para testes ou programas que rodam por poucos segundos, perigoso para o resto.
##
GOMEMLIMIT
O problema do GOGC é que ele é relativo: o programa não sabe quanta memória a máquina tem. Num container com limite de 512MB, um GOGC alto pode fazer o heap passar do limite e o container ser morto por falta de memória.
Desde o Go 1.19 existe o GOMEMLIMIT, que define um limite absoluto. Quando o uso de memória se aproxima dele, o GC passa a rodar com mais frequência, não importa o GOGC. Um exemplo extremo: GC desligado, mas com um limite de 20MB:
GOGC=off GOMEMLIMIT=20MiB ./gc
memória em uso no heap: 14 MB
total já alocado: 50 MB
execuções do GC: 33
Com GOGC=off sozinho o heap chegou a 50MB e o GC nunca rodou; com o limite, o GC ficou desligado até a memória chegar perto dos 20MB e, a partir daí, rodou sempre que precisou para não passar disso. Essa combinação (GOGC alto ou off + GOMEMLIMIT) é comum em containers: o GC só trabalha quando realmente precisa.
O
GOMEMLIMITé um limite “soft”: se o programa realmente tiver mais memória viva do que o limite, o Go não vai travar. Ele vai rodar o GC sem parar tentando cumprir o limite, gastando muita CPU. Por isso, deixe uma margem: num container de 512MB, algo comoGOMEMLIMIT=450MiB.
As duas configurações também podem ser alteradas pelo código, com debug.SetGCPercent e debug.SetMemoryLimit do pacote runtime/debug.
#
Como diminuir o trabalho do GC
A melhor forma de deixar o GC mais rápido é dar menos trabalho para ele, ou seja, alocar menos:
- Pré-alocar slices e maps quando já sabemos o tamanho:
make([]int, 0, n)emake(map[string]int, n), como vimos no dia 33 sobre omake(); - Usar
strings.Builderem vez de concatenar strings em loop (o exemplo do post anterior); - Retornar valores em vez de ponteiros para structs pequenas;
- Reaproveitar objetos que são criados e descartados o tempo todo (buffers, por exemplo) com
sync.Pool.
Mas, de novo: antes de mudar qualquer coisa, meça com benchmark e pprof.