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:
- Retornar ponteiro para valor local.
- Guardar num
any/interface{}(e daí emfmt.Println). - 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 sitera runas (code points UTF-8), eisalta 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:
- 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))). - 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. - Map não é seguro para concorrência. Duas goroutines escrevendo simultaneamente causam
fatal error: concurrent map writes— e esse erro mata o processo, semrecoverpossível. Proteja comsync.RWMutexou usesync.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
- 6. Runtime, Scheduler e GC
- 2. Goroutines e Channels
- 1. Go
- 2. Stack vs Heap
- 3. Passagem por Valor vs por Referência
- 4. Mutabilidade
- 2. Array
- 6. Hash Map
Parte de MOC Go · Slices: usage and internals · Go Maps in Action