Pular para o conteúdo principal

victorstein.dev

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:

  1. 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”;
  2. 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 comando go compilando 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:

  • GOGC menor: o GC roda mais vezes, usa mais CPU, mas o programa ocupa menos memória;
  • GOGC maior: 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 como GOMEMLIMIT=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) e make(map[string]int, n), como vimos no dia 33 sobre o make();
  • Usar strings.Builder em 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.

# Referências

  1. A Guide to the Go Garbage Collector
  2. Go FAQ: How do I know whether a variable is allocated on the heap or the stack?
  3. Documentação runtime (GODEBUG, GOGC e GOMEMLIMIT)