Blog

Memória em Go: stack, heap, escape analysis e o GC

Go decide por você se um valor vive na stack ou no heap. Veja como a escape analysis toma essa decisão, como observá-la com go build -gcflags=-m, como contar alocações e o que o garbage collector faz depois.

Em Go você nunca escreve “coloque isto no heap”. Você escreve código comum, e o compilador decide onde cada valor vive. Normalmente você não precisa se preocupar com isso. Quando um profile diz que um laço crítico aloca memória, você precisa saber como essa decisão é tomada e como enxergá-la.

Este post cobre a stack, o heap, a escape analysis, a contagem de alocações e o garbage collector que limpa o heap. Todo programa abaixo rodou no Go 1.26, e a saída foi colada da execução. Quando uma afirmação vem do compilador, a saída do próprio compilador também está colada.

Toda chamada ganha um frame na stack

Quando uma função Go é chamada, ela ganha um frame: um bloco de memória com as variáveis locais dela e alguns dados de controle. Quando a função retorna, o frame dela é descartado, e essa memória é reaproveitada pela próxima chamada. Cada goroutine tem a sua própria stack de frames.

A stack de uma goroutine começa pequena e cresce quando precisa. Você pode perguntar ao runtime com que tamanho as stacks novas começam, e depois fazer uma recursão muito mais profunda do que esse tamanho permite:

package main

import (
	"fmt"
	"runtime/metrics"
)

func depth(n int) int {
	if n == 0 {
		return 0
	}
	return 1 + depth(n-1)
}

func main() {
	s := []metrics.Sample{{Name: "/gc/stack/starting-size:bytes"}}
	metrics.Read(s)
	fmt.Println("new goroutine stacks start at", s[0].Value.Uint64(), "bytes")
	fmt.Println(depth(1_000_000))
}

Ele imprime:

new goroutine stacks start at 2048 bytes
1000000

Um milhão de chamadas aninhadas não cabem em 2.048 bytes, então a stack cresceu. O código-fonte do runtime descreve como: quando uma função precisa de mais stack do que sobrou, o runtime aloca uma stack maior, copia a antiga para lá e segue em frente. Ele também pode encolher stacks de novo durante a coleta de lixo. O 2.048 é o que o Linux informa nesta execução. O runtime também ajusta o tamanho inicial com o tempo, conforme quanta stack as goroutines vêm usando, então um programa que roda por muito tempo pode informar um número maior.

O crescimento tem um teto. A documentação de runtime/debug.SetMaxStack diz que a stack de uma única goroutine pode crescer até 1 GB em sistemas de 64 bits. Uma recursão infinita bate nesse limite e quebra com um stack overflow.

O heap guarda o que sobrevive à chamada

Alguns valores precisam continuar vivos depois que a função que os criou retorna. Um frame some quando a função dele retorna, então esses valores não podem morar lá. Eles vão para o heap, uma área de memória compartilhada que não está presa a nenhuma chamada. O garbage collector libera a memória do heap quando nada mais aponta para ela.

Explicado como se você tivesse dez anos

A stack é uma pilha de blocos de anotação na sua mesa. Toda vez que você começa uma tarefa, coloca um bloco novo em cima e rabisca nele. Quando a tarefa termina, você joga esse bloco fora. Uma tarefa dentro de outra tarefa só adiciona mais um bloco. É rápido, e a limpeza é de graça: você só joga fora o bloco de cima.

O heap é um depósito compartilhado no fim do corredor. Se você faz algo de que outra pessoa ainda vai precisar depois que você terminar, não dá para deixar no seu bloco, porque ele vai para o lixo. Então você guarda a coisa numa caixa no depósito e entrega para a pessoa uma etiqueta dizendo em que prateleira ela está.

De vez em quando, um faxineiro passa pelo depósito. Toda caixa para a qual nenhuma etiqueta aponta vai para o lixo.

A versão precisa

Um stack frame é criado quando uma função é chamada e descartado quando ela retorna. Alocar num frame não custa quase nada, e ninguém precisa rastrear essa memória depois.

Uma alocação no heap pede memória ao runtime. Essa memória continua válida enquanto algum ponteiro conseguir alcançá-la. Mais tarde, o garbage collector (GC) encontra os objetos do heap que nenhum ponteiro alcança e reaproveita a memória deles. Então valores no heap custam duas vezes: uma para alocar, e outra como trabalho para o GC.

Onde a analogia falha: não é você quem decide quais valores vão para o depósito. Quem decide é o compilador, quando constrói o seu programa, e a próxima seção mostra como. E o faxineiro não lê as etiquetas uma a uma conforme você as entrega. Ele percorre tudo o que dá para alcançar a partir dos blocos que ainda estão nas mesas, e a parte sobre o GC mais abaixo explica como.

Você não escolhe: a escape analysis escolhe

Go não tem nenhuma regra que diga que new ou & significam “heap”. A especificação do Go nem usa as palavras stack ou heap. O FAQ do Go deixa a promessa bem clara: cada variável existe enquanto houver referências a ela. Como cumprir essa promessa fica a cargo do compilador.

O compilador cumpre a promessa com a escape analysis. Para cada valor, ele pergunta se o valor pode ser alcançado depois que a função dele retorna. Se não consegue provar que a resposta é não, o valor escapa, e o compilador o coloca no heap. Caso contrário, ele pode deixá-lo no frame.

É por isso que retornar um ponteiro para uma variável local é seguro em Go. Em C, o mesmo código devolve o endereço de um stack frame que não existe mais.

package main

import "fmt"

type point struct{ x, y int }

func sum() int {
	p := point{1, 2}
	return p.x + p.y
}

func newPoint() *point {
	p := point{3, 4}
	return &p
}

func five() int {
	n := new(int)
	*n = 5
	return *n
}

func main() {
	fmt.Println(sum(), newPoint().y, five())
}

Ele imprime:

3 4 5

Para ver as decisões do compilador, salve isso como main.go num módulo e compile com -gcflags=-m. O -l a mais desliga o inlining, então cada função é analisada exatamente como foi escrita:

$ go build -gcflags='-m -l' .
# example.com/escape
./main.go:13:2: moved to heap: p
./main.go:18:10: new(int) does not escape
./main.go:24:13: ... argument does not escape
./main.go:24:17: sum() escapes to heap
./main.go:24:31: newPoint().y escapes to heap
./main.go:24:39: five() escapes to heap

A linha 13 é o p de newPoint. O endereço dele é retornado, então ele precisa sobreviver à chamada, e o compilador “moved to heap” a variável. A linha 18 é o new(int) de five: ele “does not escape”, porque o ponteiro nunca sai da função. new não forçou uma alocação no heap. O p de sum nem aparece, porque nada pega o endereço dele.

As últimas quatro linhas são sobre a chamada a fmt.Println. Elas são explicadas na seção sobre interfaces mais abaixo.

Contando alocações com testing.AllocsPerRun

A saída de -m diz o que o compilador decidiu. Para conferir o que acontece de fato quando o código roda, conte as alocações. testing.AllocsPerRun chama uma função muitas vezes e retorna a média de alocações no heap por chamada. Você pode importar testing num package main comum:

package main

import (
	"fmt"
	"testing"
)

type point struct{ x, y int }

func newPoint(x, y int) *point {
	return &point{x, y}
}

var saved *point

func main() {
	dropped := testing.AllocsPerRun(1000, func() {
		p := newPoint(3, 4)
		_ = p.x + p.y
	})
	kept := testing.AllocsPerRun(1000, func() {
		saved = newPoint(3, 4)
	})
	fmt.Println("used, then dropped:", dropped)
	fmt.Println("kept in a global:  ", kept)
}

Ele imprime:

used, then dropped: 0
kept in a global:   1

Num código simples como este, a contagem é a mesma em toda execução, ao contrário de uma medição de tempo.

A mesma função alocou uma vez num lugar e nenhuma vez no outro. Compile com -m puro, com o inlining ligado, e procure por point:

$ go build -gcflags=-m . 2>&1 | grep point
./main.go:11:9: &point{...} escapes to heap
./main.go:18:16: &point{...} does not escape
./main.go:22:19: &point{...} escapes to heap

A linha 11 é newPoint sozinha, onde o ponteiro é retornado e escapa. Mas newPoint é pequena, então o compilador fez inlining dela: copiou o corpo da função para dentro de cada chamador e rodou a escape analysis de novo ali. Na linha 18, o chamador só lê p e o descarta, então o point fica na stack. Na linha 22, o chamador o guarda numa variável global, então ele escapa.

Se algo escapa ou não depende do código ao redor, e não só da função em si. É por isso que você mede em vez de adivinhar.

Quatro motivos comuns para um valor escapar

A maioria dos escapes que você vai encontrar vem de poucos padrões. Retornar um ponteiro é o primeiro, e você já viu. Aqui estão os outros três.

Guardar um valor numa interface

fmt.Println recebe os argumentos como ...any. Colocar um valor numa interface pode fazê-lo escapar, porque a interface pode precisar guardar um ponteiro para uma cópia do valor, e o compilador normalmente não enxerga o que a função chamada faz com ele.

package main

import (
	"fmt"
	"io"
	"testing"
)

func add(total *int, n int) {
	*total += n
}

func show(n int) {
	fmt.Fprintln(io.Discard, n)
}

func main() {
	n, total := 1000, 0

	added := testing.AllocsPerRun(1000, func() {
		n++
		add(&total, n)
	})
	shown := testing.AllocsPerRun(1000, func() {
		n++
		show(n)
	})
	small := testing.AllocsPerRun(1000, func() {
		n++
		show(n % 200)
	})

	fmt.Println("add n to a total:", added)
	fmt.Println("print n:         ", shown)
	fmt.Println("print n % 200:   ", small)
}

Ele imprime:

add n to a total: 0
print n:          1
print n % 200:    0

O compilador concorda com as duas primeiras linhas:

$ go build -gcflags='-m -l' . 2>&1 | head -4
# example.com/boxing
./main.go:9:10: total does not escape
./main.go:14:14: ... argument does not escape
./main.go:14:27: n escapes to heap

add recebe um ponteiro mas nunca o guarda, então total não escapa. Em show, n escapa para dentro da interface, e imprimir custa uma alocação por chamada.

A terceira linha foi a surpresa. show(n % 200) passa pelo mesmo código, mas não aloca. O runtime mantém uma tabela pronta com os inteiros de 0 a 255 (staticuint64s em runtime/iface.go), e uma interface que guarda um deles aponta para essa tabela em vez de alocar. Então “escapes to heap” na saída de -m significa que o compilador não conseguiu descartar uma alocação no heap. Não é uma promessa de que ela acontece toda vez.

Uma closure que captura uma variável

Uma closure que usa uma variável da função que a envolve compartilha essa variável, como a parte sobre funções mostrou. Se a closure sobrevive à chamada, a variável também precisa sobreviver:

package main

import "fmt"

func makeCounter() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

func main() {
	next := makeCounter()
	fmt.Println(next(), next(), next())
	next = nil
	fmt.Println(next == nil)
}

Ele imprime:

1 2 3
true
$ go build -gcflags='-m -l' .
# example.com/counter
./main.go:6:2: moved to heap: n
./main.go:7:9: func literal escapes to heap
./main.go:15:13: ... argument does not escape
./main.go:15:18: next() escapes to heap
./main.go:15:26: next() escapes to heap
./main.go:15:34: next() escapes to heap
./main.go:17:13: ... argument does not escape
./main.go:17:19: next == nil escapes to heap

n vai para o heap, e o próprio valor da função também (o “func literal” da linha 7). O resto são argumentos de fmt.Println indo para interfaces de novo. É assim que isso fica enquanto o programa roda:

stack heap makeCounter n = 0 n = 3 func literal main next next = nil nada aponta aqui agora o GC não os alcança: liberados main chama makeCounter(): frame novo na stack, com n = 0 a func retornada usa n, então n não fica no frame: vai para o heap makeCounter retorna e o frame sai; next aponta para a func no heap next() roda três vezes, e cada chamada soma um ao n no heap next = nil: agora nada na stack aponta para a func ou para n quando o GC rodar de novo, não os alcança e libera a memória

makeCounter, do programa acima. O frame dela guarda n, mas a função que ela retorna usa n, então n vive no heap ao lado dessa função. O frame sai da stack, e o next de main aponta para dentro do heap. Quando next recebe nil, nada mais alcança a função nem n, e o garbage collector os libera.

Aqui estão esses passos em palavras, caso a animação não rode para você:

  1. main chama makeCounter. Um frame novo vai para a stack, e é ali que você esperaria que n = 0 morasse.
  2. A função que makeCounter retorna usa n, então n precisa sobreviver ao frame. Ele vive no heap, ao lado do valor da função.
  3. makeCounter retorna e o frame dela é descartado. next, em main, aponta para a função no heap, e a função aponta para n.
  4. Cada uma das três chamadas a next() soma um a esse mesmo n, que agora vale 3.
  5. next = nil. Nada na stack aponta mais para a função nem para n.
  6. Na próxima vez que o GC rodar, ele não consegue alcançá-los, então a memória deles é liberada.

O desenho mostra n se movendo, mas nada se move de verdade em tempo de execução. O compilador tomou a decisão quando construiu o programa, então makeCounter cria n no heap desde a primeira linha. “Moved to heap” é o jeito do compilador de dizer “esta variável local não ganha lugar no frame”.

A flag -l também importa aqui. Com o inlining ligado, makeCounter sofre inlining dentro de main, e o compilador informa que essa cópia da closure não escapa. A análise da animação é a de makeCounter como foi escrita.

Um slice grande demais, ou de tamanho desconhecido

O array subjacente de um slice pode ficar no frame quando o compilador sabe o tamanho dele e esse tamanho é modesto. O quanto é modesto é uma configuração do compilador, não uma regra da linguagem:

package main

import (
	"fmt"
	"testing"
)

func fixed8192() int {
	buf := make([]int, 8192)
	return len(buf)
}

func fixed8193() int {
	buf := make([]int, 8193)
	return len(buf)
}

func sized(n int) int {
	buf := make([]int, n)
	return len(buf)
}

func main() {
	fmt.Println(testing.AllocsPerRun(100, func() { fixed8192() }))
	fmt.Println(testing.AllocsPerRun(100, func() { fixed8193() }))
	for _, n := range []int{4, 5} {
		fmt.Println(testing.AllocsPerRun(100, func() { sized(n) }))
	}
}

Ele imprime:

0
1
0
1
$ go build -gcflags='-m -l' . 2>&1 | grep make
./main.go:9:13: make([]int, 8192) does not escape
./main.go:14:13: make([]int, 8193) escapes to heap
./main.go:19:13: make([]int, n) does not escape

8.192 ints são 64 KiB, que é exatamente o limite para variáveis implícitas na stack no código-fonte do compilador (MaxImplicitStackVarSize em cmd/compile/internal/ir/cfg.go). Um int a mais e o array vai para o heap, mesmo sem nunca sair da função.

make([]int, n) foi a segunda surpresa. O compilador diz que ele “does not escape”, mas sized(5) aloca. O compilador reserva um pequeno buffer fixo no frame e confere n em tempo de execução. Se o slice cabe, ele usa o buffer. Se não cabe, aloca no heap. O buffer padrão tem 32 bytes, definido em cmd/compile/internal/base/flag.go, o que dá espaço para quatro ints. É por isso que 4 cabe e 5 não. Esses limites são detalhes do compilador e podem mudar entre versões.

O garbage collector: marcar, depois varrer

O garbage collector do Go libera a memória do heap que nada consegue alcançar. Segundo o comentário no topo de runtime/mgc.go, ele é um coletor concurrent mark and sweep. Ele não move objetos e não divide o heap em gerações.

Um ciclo tem dois trabalhos principais. A marcação começa pelas raízes, que são a stack de cada goroutine e as variáveis globais. Ela segue cada ponteiro e marca cada objeto que alcança. A varredura percorre então o heap, e todo objeto que não foi marcado tem a memória liberada para reúso. Os dois trabalhos rodam junto com o seu programa. O runtime para todas as goroutines em dois momentos: quando a marcação começa e quando termina.

O Go 1.26 liga por padrão uma implementação reformulada da marcação, chamada Green Tea. Ela muda como o coletor percorre a memória, não a ideia de mark and sweep descrita abaixo.

Explicado como se você tivesse dez anos

Toda caixa do depósito começa com um adesivo branco, que quer dizer “ninguém conferiu esta ainda”.

O faxineiro começa pelas mesas. Toda caixa para a qual um bloco aponta ganha um adesivo cinza: “achei, mas ainda não olhei dentro”.

Depois o faxineiro repete um único movimento. Pega uma caixa cinza, abre, e coloca um adesivo cinza em toda caixa para a qual as etiquetas dela apontam. Aí troca o adesivo dessa caixa por um preto: “achei, e conferi tudo”.

Quando não sobra nenhuma caixa cinza, toda caixa que alguém consegue alcançar está preta. Toda caixa que ainda está com adesivo branco é uma que ninguém consegue alcançar, então vai para o lixo.

A versão precisa

Isso é a marcação tricolor. Objetos brancos não foram alcançados, objetos cinza foram alcançados mas não examinados, e objetos pretos foram examinados. A marcação começa pintando as raízes de cinza. O coletor pega um objeto cinza, pinta de cinza tudo para o que ele aponta, e o deixa preto. Quando não sobra nenhum objeto cinza, os brancos são inalcançáveis, e a fase de varredura os libera.

Como o seu programa continua rodando durante a marcação, ele pode mudar ponteiros enquanto o coletor trabalha. Ele pode guardar um ponteiro para um objeto branco dentro de um preto que o coletor já terminou de examinar. Para evitar que esse objeto seja liberado por engano, o runtime liga um write barrier durante a marcação: toda escrita de ponteiro também pinta os ponteiros envolvidos. Objetos alocados durante a marcação já nascem pretos.

Onde a analogia falha: o faxineiro da história trabalha sozinho enquanto todo mundo espera. O coletor de verdade roda ao mesmo tempo que o seu programa, em várias threads, e o próprio programa faz parte da marcação quando aloca. E não há adesivos nas caixas: as marcas são bits que o runtime guarda ao lado do heap.

Dois ajustes: GOGC e GOMEMLIMIT

O runtime do Go tem duas configurações que controlam com que frequência o GC roda. As duas são variáveis de ambiente, e as duas têm uma função em runtime/debug que as muda enquanto o programa roda:

package main

import (
	"fmt"
	"math"
	"runtime/debug"
)

func main() {
	oldPercent := debug.SetGCPercent(100)
	oldLimit := debug.SetMemoryLimit(-1)
	fmt.Println("GOGC:", oldPercent)
	fmt.Println("GOMEMLIMIT is off:", oldLimit == math.MaxInt64)
}

Ele imprime:

GOGC: 100
GOMEMLIMIT is off: true

Cada setter retorna a configuração anterior, e um limite de memória negativo lê o limite sem mudá-lo. Sem nenhuma das duas variáveis definida, o programa informa os padrões. Rode com GOGC=50 GOMEMLIMIT=1GiB no ambiente e as duas linhas mudam.

GOGC é uma porcentagem. A documentação do runtime diz que uma coleta começa quando a memória alocada desde a última chega a essa porcentagem dos dados vivos que sobraram depois dela. No padrão de 100, o heap pode crescer até mais ou menos o dobro do tamanho vivo antes do próximo ciclo. Um valor maior significa menos coletas e mais memória. Um menor significa mais coletas e menos memória. GOGC=off desliga o coletor.

GOMEMLIMIT é um limite flexível para a memória total que o runtime do Go gerencia, escrito em bytes com um sufixo opcional como MiB ou GiB. Conforme o programa se aproxima do limite, o GC roda com mais frequência para ficar abaixo dele. Ele vem desligado por padrão, e é isso que math.MaxInt64 significa. A documentação de SetMemoryLimit avisa que um limite abaixo do que o programa realmente precisa pode fazer o GC rodar quase sem parar. É um limite flexível, não um teto rígido.

Você também pode ler estatísticas de memória com runtime.ReadMemStats. Campos como HeapAlloc (bytes no heap agora), TotalAlloc (bytes já alocados no total) e NumGC (ciclos concluídos) são úteis numa sessão de depuração. Não há saída aqui, porque os números mudam de uma execução para outra.

O que fazer com tudo isso

A maior parte do código Go nunca precisa de nada disso. Aqui estão os conselhos que se sustentam.

Não otimize sem um profile. Uma alocação num código que roda uma vez na inicialização não custa nada que importe. Meça primeiro, com um benchmark rodado como go test -bench . -benchmem ou com AllocsPerRun como acima, e só mude o código onde os números mostram um problema.

Pré-aloque slices quando você sabe o tamanho. Como a parte sobre slices mostrou, make([]T, 0, n) evita fazer o array subjacente crescer de novo e de novo.

Passe structs pequenas por valor. Um ponteiro não é automaticamente mais barato. Copiar uma struct pequena é barato e nunca a faz escapar, enquanto pegar o endereço dela pode fazer, se o compilador não conseguir provar que o ponteiro fica local. Use um ponteiro quando a função precisa mudar o valor ou quando a struct é grande, como a parte sobre structs explicou.

O que lembrar

  • Cada chamada ganha um stack frame, descartado quando ela retorna. As stacks das goroutines começam pequenas e crescem por cópia.
  • Valores que precisam sobreviver à própria função vão para o heap, e o garbage collector os libera quando nada mais os alcança.
  • Você não escolhe entre stack e heap. Quem escolhe é a escape analysis, e retornar um ponteiro para uma variável local é seguro.
  • go build -gcflags=-m mostra as decisões do compilador, e testing.AllocsPerRun conta o que realmente aloca. O inlining pode mudar a resposta.
  • Ponteiros retornados ou guardados, interfaces, closures e slices grandes ou de tamanho desconhecido são as causas comuns de escape.
  • O GC é um mark and sweep tricolor e concorrente. GOGC troca memória por CPU, e GOMEMLIMIT define um teto flexível.

Escreva código claro primeiro, e deixe uma medição dizer qual alocação remover.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.