trilha

Tecnologias/01 - Linguagens/01 - Go8 min

Memória, Slices e Maps por Dentro

Conceito

Go esconde a gestão de memória e não esconde as estruturas de dados. Slice, map e string têm representações concretas, e conhecê-las evita uma classe inteira de bugs que o compilador não pega.

Escape analysis: quem decide stack ou heap

Você nunca escreve new para decidir isso. O compilador decide, na compilação:

Se o compilador prova que o valor não escapa do escopo da função, ele vive na stack — alocação gratuita, liberação gratuita, invisível para o GC. Se escapa, vai para o heap.

func naoEscapa() int {
	x := 42          // stack
	return x         // copia o valor
}

func escapa() *int {
	x := 42          // HEAP: o ponteiro sobrevive à função
	return &x
}

func tambemEscapa(v any) { fmt.Println(v) }  // interface{} + reflexão → heap
go build -gcflags='-m' ./... 2>&1 | grep escapes
# ./main.go:12:2: moved to heap: x
# ./main.go:20:13: ... argument does not escape

Isso conecta com 2. Stack vs Heap: em Go, a diferença não é a sintaxe (new vs literal), é o que o compilador consegue provar. Três coisas que costumam causar escape:

  1. Retornar ponteiro para valor local.
  2. Guardar num any/interface{} (e daí em fmt.Println).
  3. Capturar variável numa closure que sobrevive à função (ex.: go func(){...}()).

Slice: três campos e uma pegadinha

type slice struct {
	ptr *T   // ponteiro para o array subjacente
	len int  // quantos elementos são visíveis
	cap int  // quantos cabem antes de precisar realocar
}

Slice é um cabeçalho passado por valor que aponta para um array compartilhado. As duas consequências disso são a fonte dos bugs mais comuns de Go.

Pegadinha 1: sub-slices compartilham o array

a := []int{1, 2, 3, 4, 5}
b := a[1:3]        // len=2, cap=4  →  MESMO array de a
b[0] = 99
fmt.Println(a)     // [1 99 3 4 5]  ← 'a' mudou!

b = append(b, 42)  // cap ainda cabe: escreve em a[3]
fmt.Println(a)     // [1 99 3 42 5] ← 'a' mudou nesta posição também

A defesa é a full slice expression, que limita a capacidade:

b := a[1:3:3]      // len=2, cap=2 → o próximo append REALOCA e desacopla

Pegadinha 2: append pode ou não realocar

func addRuim(s []int) { s = append(s, 1) }   // sem efeito visível no chamador
                                             // (ou COM efeito, se cap sobrava!)
func addBom(s []int) []int { return append(s, 1) }  // sempre devolva

O crescimento é amortizado (aproximadamente dobra em slices pequenos e cresce ~25% em slices grandes). Cada realocação é alocar + copiar — daí a importância de make([]T, 0, n) quando você sabe o tamanho (6. Runtime, Scheduler e GC).

Pegadinha 3: sub-slice segura o array inteiro na memória

func primeirosBytes(dados []byte) []byte {
	return dados[:100]   // ⚠️ os 100 MB de `dados` continuam VIVOS para o GC
}
// correto quando o resto é descartável:
func primeirosBytesCopia(dados []byte) []byte {
	out := make([]byte, 100)
	copy(out, dados)
	return out           // agora o array grande pode ser coletado
}

É um vazamento de memória que não aparece como vazamento — só como "por que o heap não baixa?".

String vs []byte

type stringHeader struct { ptr *byte; len int }   // IMUTÁVEL
  • string é imutável; []byte é mutável. A conversão entre eles copia (o compilador otimiza alguns casos: for i := range []byte(s), chaves de map).
  • Indexar uma string dá bytes, não caracteres: s[0] de "ção" devolve meio caractere.
  • for i, r := range s itera runas (code points UTF-8), e i salta conforme o tamanho de cada runa.
s := "ção"
fmt.Println(len(s))                 // 4 (bytes!)
fmt.Println(len([]rune(s)))         // 3 (runas)
fmt.Println(utf8.RuneCountInString(s)) // 3, sem alocar

Isso liga com 1. Tipos e Sistema de Tipos: em Go, "texto" é explicitamente uma sequência de bytes com uma convenção de codificação — nada de tipo char mágico.

Map: hash table, e o que ela não permite

m := map[string]int{}
m["a"] = 1
v, ok := m["b"]     // v = 0, ok = false — o "comma ok" distingue ausente de zero
delete(m, "a")

Três propriedades que precisam ser sabidas:

  1. A ordem de iteração é aleatória, de propósito. O runtime começa de um bucket aleatório para impedir que alguém dependa da ordem. Para saída determinística, colete as chaves e ordene (slices.Sorted(maps.Keys(m))).
  2. Não se pode tomar o endereço de um elemento. &m["a"] não compila, porque o crescimento do map move as entradas. Para mutação parcial, guarde ponteiros (map[string]*Struct) ou reescreva o valor inteiro.
  3. Map não é seguro para concorrência. Duas goroutines escrevendo simultaneamente causam fatal error: concurrent map writes — e esse erro mata o processo, sem recover possível. Proteja com sync.RWMutex ou use sync.Map (que é otimizado para "escreve pouco, lê muito", não para uso geral).

A implementação interna mudou: o Go 1.24 substituiu a hash table clássica por Swiss Tables, com ganho de desempenho e menos memória — sem mudar uma linha da semântica acima. É um bom exemplo da promessa de compatibilidade (4. Versões, Módulos e Compatibilidade) permitindo trocar o motor por baixo.

O zero value e o nil que funciona

var s []int              // nil, mas len(s)==0 e append(s, 1) FUNCIONA
var m map[string]int     // nil: LER funciona (devolve zero), ESCREVER causa panic
var mu sync.Mutex        // pronto para usar, sem inicialização
var wg sync.WaitGroup    // idem
var b bytes.Buffer       // idem

"Make the zero value useful" é um dos poucos slogans de design do Go que se traduz diretamente em API: tipos bem desenhados funcionam sem construtor. O map é a exceção que todo mundo esbarra uma vez.

Trade-offs

Slice compartilhado é eficiente e perigoso. Passar um slice não copia dados (ótimo) e significa que o receptor pode alterar o array do chamador (péssimo, quando inesperado). Não existe const nem imutabilidade na linguagem: "não modifique este slice" é comentário, não garantia. Ver 4. Mutabilidade.

Escape analysis é poderoso e opaco. Ela elimina a maior parte da pressão de GC sem esforço, e você só descobre que algo escapou rodando -gcflags=-m. Uma refatoração inocente (guardar um valor numa interface para logar) pode mover uma alocação da stack para o heap num caminho quente.

sync.Map não é o map concorrente que parece. Ele é otimizado para chaves estáveis com leitura predominante. Para uso geral, map + RWMutex é mais rápido e mais simples de entender.

O trade-off central: Go expõe as estruturas de dados e esconde a memória. Você não gerencia malloc/free, mas precisa saber que slice é uma janela sobre um array compartilhado — porque nenhum dos bugs decorrentes disso é pego pelo compilador, e vários deles se manifestam como corrupção de dados, não como crash.

Exemplo prático

O bug de slice que aparece em code review

// ❌ Reaproveitar o buffer entre iterações: todos os resultados apontam para o mesmo array
func lerTodos(r io.Reader) [][]byte {
	var out [][]byte
	buf := make([]byte, 1024)
	for {
		n, err := r.Read(buf)
		if n > 0 { out = append(out, buf[:n]) }   // ⚠️ MESMO buf, sempre!
		if err != nil { return out }
	}
}
// resultado: todas as entradas de `out` mostram o conteúdo da ÚLTIMA leitura
// ✅ copie o que você guarda
func lerTodos(r io.Reader) [][]byte {
	var out [][]byte
	buf := make([]byte, 1024)
	for {
		n, err := r.Read(buf)
		if n > 0 {
			pedaco := make([]byte, n)
			copy(pedaco, buf[:n])
			out = append(out, pedaco)
		}
		if err != nil { return out }
	}
}

Map protegido, do jeito simples

type Cache struct {
	mu sync.RWMutex
	m  map[string]*Item     // ponteiro: permite mutar o Item sem reescrever a entrada
}

func NovoCache() *Cache { return &Cache{m: make(map[string]*Item)} }

func (c *Cache) Get(k string) (*Item, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	it, ok := c.m[k]        // leitura concorrente: RLock permite várias
	return it, ok
}

func (c *Cache) Set(k string, it *Item) {
	c.mu.Lock()             // escrita: exclusiva
	defer c.mu.Unlock()
	c.m[k] = it
}

E a verificação que precisa estar na CI:

go test -race ./...    # o detector de race não é opcional em código concorrente

Diagnosticar alocação de verdade

# quem escapa para o heap, arquivo por arquivo
go build -gcflags='-m -m' ./internal/api 2>&1 | grep -E 'escapes|moved to heap'

# benchmark comparativo (a métrica é allocs/op)
go test -bench=Parse -benchmem -count=5 ./... | tee novo.txt
benchstat antigo.txt novo.txt

Como isso falha em produção

Sintoma Causa
Dados "misturados" entre itens de uma lista slice de buffer reaproveitado, sem copy
assignment to entry in nil map var m map[k]v sem make
fatal error: concurrent map writes map compartilhado sem lock — mata o processo
Heap não baixa após processar arquivo grande sub-slice segurando o array inteiro vivo
Saída de teste instável dependência da ordem de iteração de map
Alocação alta num caminho "simples" escape por interface{}/fmt; confirme com -gcflags=-m
String cortada no meio de um acento fatiou por bytes onde precisava de runas
append "não funcionou" retorno de append ignorado numa função

Aprofundamento aplicado

Limitar capacidade não é copiar dados

base := []int{10, 20, 30, 40}
a := base[:2:2]
b := append(a, 99)
a[0] = 7
fmt.Println(base) // [7 20 30 40]
fmt.Println(b)    // [10 20 99]

A expressão com três índices limita a capacidade de a; o append precisa obter outro array neste exemplo. Entretanto, antes disso, a ainda aponta para base, e alterar um elemento compartilhado modifica o original. O código pressupõe import fmt. Referência: expressões de slice.

Uma cópia de []*Pedido copia ponteiros, mantendo os pedidos compartilhados. Para definir posse, pergunte tanto “quem pode alterar a coleção?” quanto “quem pode alterar os objetos referenciados?”. A profundidade da cópia depende desse contrato.

Exercício: troque a limitação de capacidade por a := base[:2] e refaça o append. Resultado esperado: o terceiro elemento de base passa a 99; explique por que a capacidade disponível alterou o efeito colateral sem mudar a sintaxe do append.

Relacionado


Parte de MOC Go · Slices: usage and internals · Go Maps in Action

Buscar

Busca por título, seção e texto das notas