Anatomia de Slices, Arrays e Maps em Go: Estruturas Internas e Armadilhas
Por dentro de arrays, slices e maps em Go: o slice header (ptr, len e cap), como o append realoca memória e por que maps não são thread-safe.
Publicada em · 5 min de leitura
Anatomia de Slices, Arrays e Maps em Go: Estruturas Internas e Armadilhas
As estruturas de dados primárias de coleções em Go — Arrays, Slices e Maps — são otimizadas para alto desempenho, alocação contígua de memória cache e baixa sobrecarga.
No entanto, o fato de o Go esconder ponteiros sob a sintaxe de slices e maps frequentemente induz desenvolvedores a cometerem erros sutis: sobrescrita silenciosa de dados em sub-slices, vazamento de blocos massivos de memória no Heap e pânicos fatais em operações concorrentes.
1. Arrays vs Slices: O Fim da Confusão
Em Go, Arrays e Slices são tipos conceitualmente e operacionalmente distintos:
- Array: É um bloco contíguo de memória com tamanho fixo estático definido em tempo de compilação. O tamanho faz parte da assinatura do tipo:
[5]inte[10]intsão tipos incompatíveis. Além disso, arrays são valores puros: atribuir um array para outra variável ou passá-lo para uma função cria uma cópia integral de todos os seus elementos na memória. - Slice: É uma estrutura dinâmica e flexível de visualização (view) que referencia um Array subjacente (backing array).
2. A Anatomia do Slice Header
Internamente no runtime do Go, um slice não contém os dados diretamente. Ele é uma struct levíssima de 24 bytes (em arquiteturas de 64-bit), chamada Slice Header:
// Representação interna equivalente no runtime do Go
type SliceHeader struct {
Data uintptr // 8 bytes: Ponteiro para o primeiro elemento no backing array
Len int // 8 bytes: Quantidade de elementos atualmente presentes
Cap int // 8 bytes: Capacidade máxima antes de exigir realocação
}
len(s): O número de elementos visíveis e acessíveis pelo slice.cap(s): A distância entre o primeiro elemento do slice e o final do backing array contíguo.
3. A Mecânica do append() e a Estratégia de Crescimento
A função nativa append(slice, elem) possui dois comportamentos completamente distintos dependendo da capacidade disponível:
Cenário 1: len < cap (Escrita in-place rápida)
Se ainda houver espaço no backing array atual, o Go simplesmente incrementa o Len no header e grava o novo elemento diretamente na memória contígua já alocada. Custo: zero alocações de memória.
Cenário 2: len == cap (Estouro de capacidade)
Quando o backing array atinge sua capacidade máxima, o Go:
- Aloca um NOVO backing array maior no Heap;
- Copia todos os elementos do array antigo para o novo array (memcpy);
- Insere o novo elemento;
- Atualiza o ponteiro
Datae oCapno Slice Header retornado.
4. A Armadilha do Reslicing e a Sintaxe de 3 Índices
Quando criamos um sub-slice a partir de um slice pai (child := parent[1:3]), o Go não copia os dados: o filho aponta para o mesmo backing array do pai!
O Perigo da Sobrescrita:
parent := []int{10, 20, 30, 40, 50}
child := parent[1:3] // [20, 30], len=2, mas cap=4!
// Se chamarmos append em child:
child = append(child, 999)
// 💥 PERIGO: Como cap=4, o append sobrescreveu in-place o valor 40 no array parent!
// parent agora é: [10, 20, 30, 999, 50]
A Solução: Full Slice Expression [low:high:max]
Para impedir que um sub-slice sobrescreva dados do pai, limite sua capacidade explicitamente com o 3º índice:
// [inicio : fim : capacidade_maxima]
child := parent[1:3:3] // len=2, cap=2!
// Agora, qualquer append em child é forçado a alocar um novo array,
// mantendo parent[3] intacto!
child = append(child, 999)
5. Vazamento de Memória por Sub-Slices (Memory Leaks)
Se você ler um arquivo ou payload de 100 Megabytes e fatiar apenas os primeiros 10 bytes:
func GetHeader(hugePayload []byte) []byte {
return hugePayload[:10] // ❌ O SliceHeader de 10 bytes segura os 100MB inteiros no Heap!
}
Enquanto essa variável de 10 bytes estiver viva na memória, o Garbage Collector não consegue liberar os 100MB subjacentes, porque o ponteiro Data do slice ainda referencia o início do bloco.
A Solução com copy():
func GetHeaderSafe(hugePayload []byte) []byte {
header := make([]byte, 10)
copy(header, hugePayload[:10]) // Copia estritamente os 10 bytes para um novo array
return header // Os 100MB originais agora podem ser coletados pelo GC!
}
6. A Anatomia Interna do Map (hmap)
Em Go, um Map é uma tabela hash (Hash Table) implementada sob a struct runtime.hmap.
Características Internas:
- Buckets: O map organiza seus dados em baldes (buckets). Cada bucket armazena até 8 pares de chave-valor.
- Iteração Não-Determinística: A ordem em que os itens são retornados em um
for key, val := range meuMapé propositalmente aleatória. A cada loop, o runtime sorteia um bucket inicial aleatório para impedir que desenvolvedores escrevam códigos que dependam acidentalmente de ordem em tabelas hash. - Pré-Alocação com
hint:// ❌ Ruim: o map sofre re-hashing constante à medida que cresce m := make(map[string]User) // ✅ Excelente: pré-aloca buckets suficientes para 10.000 itens sem re-hashing m := make(map[string]User, 10000)
Maps NÃO são Concorrentes!
Se duas goroutines lerem e escreverem no mesmo map simultaneamente sem sincronização com sync.RWMutex, o runtime do Go aborta o programa imediatamente com um pânico que não pode ser capturado nem por recover():
fatal error: concurrent map read and map write.
7. Resumo Conceitual
- Arrays têm tamanho fixo e são copiados por valor.
- Slices são cabeçalhos de 24 bytes (
Data,Len,Cap) que apontam para um array contíguo subjacente. append()reutiliza a memória se houver capacidade; se lotado, aloca um novo array e copia os dados.- Use a sintaxe de 3 índices
slice[low:high:max]para impedir que sub-slices corrompam o array pai. - Evite reter arrays gigantes no Heap utilizando
copy()para extrair apenas fatias necessárias. - Maps são tabelas hash baseadas em buckets de 8 itens; use sempre o idioma coma-ok (
val, ok := m[key]) e pré-aloque commake(map[K]V, hint)para máxima performance.