Go Internals - Tipos Nativos
Introdução
Em Go Internals - Garbage Collector seguimos mark e sweep recuperar slots que o allocator entrega. Antes disso, Memória já avisava que uma variável slice na stack ainda pode apontar para megabytes no heap. Este artigo é onde esses avisos viram layouts concretos.
slice, string e map parecem valores únicos no código Go. No runtime são headers pequenos — às vezes na stack, às vezes com escape — apontando para storage que o GC precisa rastrear. Slices e strings moram em src/runtime/slice.go e string.go. Maps mudaram no Go 1.24: a implementação real está em src/internal/runtime/maps/ (map.go, table.go, group.go), com wrappers finos em runtime/map.go que o compilador ainda chama como mapaccess1, mapassign e afins.
Headers não são o valor inteiro
Uma variável []int não é um array. É um trio que o compilador trata como struct:
// runtime/slice.go — invisível para código de usuário
type slice struct {
array unsafe.Pointer
len int
cap int
}
string descarta a word de capacity:
// runtime/string.go
type stringStruct struct {
str unsafe.Pointer
len int
}
map[K]V é um ponteiro para um maps.Map (abaixo). Copiar slice ou string copia as words do header, não os bytes de backing. Duas variáveis slice podem compartilhar um array; subslicing é como isso costuma acontecer.
flowchart LR
subgraph stack["Stack (frequente)"]
H["header slice: ptr, len, cap"]
end
subgraph heap["Heap (quando backing escapa)"]
A["elementos do backing array"]
end
H -->|"ponteiro array"| A
Escape analysis do artigo de Memória decide se header, backing ou ambos vão para o heap. O GC rastreia words de ponteiro em headers e em backing arrays com ponteiros; size classes noscan do artigo de Alocador continuam valendo para backing stores de []byte.
Slice: indexação e subslicing
s[i] compila para bounds check mais aritmética de endereço: array + i*elemSize. Sem chamada ao runtime no hot path quando o compilador prova índices seguros.
Subslicing s[low:high] monta um header novo no mesmo backing array:
s := make([]int, 0, 8)
t := s[2:5] // len 3, cap 6 — compartilha o array de s a partir do índice 2
len vira high - low; cap vira oldCap - low. Compartilhar é feature até alguém mutar por um slice enquanto outro aliasa a mesma memória — a linguagem não impede; testes e review sim.
Slice nil vs vazio: ambos têm len == 0, mas nil tem array == nil. Range e len se comportam igual; append em nil aloca backing array novo. Slice vazio não-nil com cap > 0 ainda pode reutilizar capacity no próximo append sem growth.
append e growslice
Quando len < cap, append escreve no backing array existente e incrementa len no header. Quando len == cap, o compilador chama growslice em runtime/slice.go.
A política de growth está em nextslicecap:
- abaixo de 256 elementos de capacity: dobra capacity
- acima disso: transição suave para crescimento de ~1,25× por passo
func demo() []int {
s := []int{1, 2, 3} // len 3, cap 3
return append(s, 4) // growslice → novo backing array, copia elementos antigos
}
growslice aloca via mallocgc, copia o prefixo vivo e devolve header novo. Se o backing antigo não tem outras referências, vira lixo no próximo ciclo de GC — fonte comum de "fiz um append e o RSS pulou" quando você subestima cópia + arrays antigos retidos.
append(s, s...) onde s aliasa a si mesmo pode forçar cópia antes da mutação; compilador/runtime detectam esse padrão. Fazer append em slice que ainda compartilha backing pequeno do pai depois de muitos subslices é jeito clássico de reter memória que você achou que soltou.
Tipos de elemento com ponteiros pagam write barrier na cópia quando o GC está em mark; tipos noscan pulam scan da cauda da alocação nova.
Layout de string e conversões
Strings são sequências imutáveis de bytes. O header aponta para dados read-only — literais moram na seção read-only do binário; []byte("hello") aloca cópia mutável no heap.
Substrings compartilham storage:
s := "hello, world"
t := s[7:12] // "world" — mesmos bytes de backing que s, sem cópia
string(b) com b sendo []byte aloca e copia a menos que o compilador prove que b não escapa. []byte(s) sempre copia porque mutar o resultado violaria imutabilidade de string.
Comparação s == t é checagem de length mais memequal nos bytes — sem alocação. Ordenar ou usar strings longas como chave de map move esses bytes pelo mesmo caminho allocator/GC de qualquer outro objeto pequeno; o header é minúsculo, o payload não.
Map: Swiss tables e grupos
O map do Go 1.26 é um Swiss table, baseado nas Swiss tables do Abseil com extensões específicas do Go. O comentário do pacote em internal/runtime/maps/map.go é o mapa:
// The map design is based on Abseil's "Swiss Table" map design
// ...
// - Group: A group of abi.MapGroupSlots (8) slots, plus a control word.
// - Table: A complete "Swiss Table" hash table.
// - Map: The top-level Map type consists of zero or more tables for storage.
O header de topo:
// internal/runtime/maps/map.go (Go 1.26)
type Map struct {
used uint64 // # entradas vivas — len(map) lê isso
seed uintptr
dirPtr unsafe.Pointer // diretório de tables, ou um group (small map)
dirLen int
globalDepth uint8
globalShift uint8
writing uint8
tombstonePossible bool
clearSeq uint64
}
Cada group guarda 8 slots key/elem mais uma control word — um byte por slot codificando vazio, deletado (tombstone) ou ocupado. Bytes ocupados também guardam os 7 bits baixos do hash (H2). Lookup compara os 8 valores H2 em paralelo contra o hash da key, e só então faz igualdade real de key nos candidatos — não slot a slot como numa tabela linear ingênua.
flowchart TB
M["maps.Map"] --> D["directory: 2^globalDepth ponteiros de table"]
D --> T0["table: array de groups"]
T0 --> G0["group: control word + 8 slots"]
T0 --> G1["group ..."]
Otimização de small map: quando um map nunca passa de 8 entradas, dirPtr aponta direto para um único group — sem alocação de table, sem directory. Por isso maps minúsculos são baratos e makemap_small existe em runtime/map.go.
Bits do hash se dividem em H1 (bits altos — escolhem o group inicial e dirigem quadratic probing) e H2 (7 bits baixos — filtram slots dentro do group). Probe percorre groups até achar match ou slot vazio; a table nunca enche 100%, então toda sequência de probe termina.
Lookup, insert e growth
m[k] ainda compila para mapaccess1 / mapaccess2 em runtime/map.go, que delegam para internal/runtime/maps. Keys ausentes devolvem ponteiro para zero value de V (ou false no comma-ok) — mesma semântica de antes.
m[k] = v passa por mapassign. Map nil panica; make ou literal é obrigatório antes de writes. Writes concorrentes sem sincronização continuam undefined — o runtime pode imprimir fatal error: concurrent map writes ou corromper dados em silêncio.
Growth é mais em camadas que o modelo antigo hmap + evacuação em oldbuckets:
- Table única, pequena: capacity dobra no lugar até
maxTableCapacity(1024 entradas por table emtable.go). - Além disso: a table splita em duas; o directory do map cresce (extendible hashing) para bits altos do hash escolherem qual sub-table dona a key.
- Delete: slots em group cheio viram tombstones para sequências de probe não pararem cedo; slots em group parcialmente cheio podem ser limpos direto.
Rehash de uma table inteira ainda acontece de uma vez para aquela table — sequências de probe dependem da contagem de groups — mas um map grande espalha trabalho em várias tables, então um grow é só uma fração das entradas totais. Iteração durante grow é mais pesada que no modelo antigo; o runtime mantém referências às tables sendo percorridas e re-busca keys na estrutura nova para cumprir a semântica da spec (sem retorno duplicado, keys deletadas somem, valores modificados ficam atuais). Veja o comentário longo de iteração em map.go se quiser a máquina de estados completa.
sequenceDiagram
participant W as write do mutator
participant M as maps.Map
participant T as table
W->>M: mapassign atinge limite de load
M->>T: grow — dobra ou splita table
alt directory precisa de mais bits
M->>M: estende directory
end
Note over T: entradas re-probed em groups novos
Fast paths continuam para key types comuns — mapaccess1_faststr, _fast64, _fast32 — em internal/runtime/maps/runtime_fast*.go.
Maps pré-1.24 usavam hmap com buckets de 8 slots e cadeias de overflow em runtime/map.go. Se você diffar contra go1.23, essa é a mudança estrutural principal nesta camada; slices e strings não mudaram.
range: o que de fato roda
for i, v := range s em slice compila para loop sobre índices com bounds checks; sem struct de iterator.
for k, v := range m ainda monta um iterator na stack e percorre a estrutura do map — agora groups e tables no directory em vez de hiter sobre hmap.buckets. Ordem de iteração é randomizada por map. É intencional — código não pode depender de ordem de keys.
Crescer um map durante iteração ainda é comportamento definido, mas a implementação é mais pesada que antes porque iterators precisam rastrear grows através de splits de table. clear(m) reseta um map no lugar; prefira isso a delete-in-range quando quer derrubar tudo.
Map nativo vs sua hash table
Em Estruturas de Dados com Golang - Parte 3 você implementou hash table com chaining explícito, load factor e resize. O map do runtime rima com esse desenho mas faz trade-offs diferentes:
| Tópico | Sua HashTable |
map do runtime (Go 1.26) |
|---|---|---|
| Colisão | Chaining (nós ou slices ligados) | Groups de 8 slots + quadratic probing entre groups |
| Growth | Rehash one-shot frequente | Double ou split por table; directory com extendible hashing |
| Ordem de iteração | Você escolhe (ou documenta não ordenada) | Randomizada por map |
| Tipos de key | Seu interface{} ou generics |
Layouts especializados por map[K]V |
| Concorrência | Você adiciona mutexes | Não seguro — use sync.Map ou lock externo |
| Comportamento nil | Sua API definia | Leitura em nil retorna zero; write panica |
Você controlava toda política de resize. O runtime otimiza para map[K]V genérico em qualquer escala, probing de group amigável a SIMD e integração com compilador. Nenhum dos dois é "o do livro" — miram jobs diferentes.
Padrões práticos
Prealocar capacity de slice quando você sabe n: make([]T, 0, n) evita growslice repetido. Mesma ideia que make(map[K]V, hint) — não pré-preenche entradas, mas pode reduzir grows cedo.
Copiar ao escapar subslices se só precisa de um prefixo pequeno: append([]T(nil), s[:k]...) força backing array compacto independente do pai.
Bytes ↔ string em fronteira quente: converter ida e volta aloca. Mantenha uma representação no hot path; unsafe só com cuidado extremo e consciência de versão do Go — não é ferramenta padrão.
Map como cache com keys sem limite ainda cresce até o GC recuperar entradas inalcançáveis. sync.Pool ou política de eviction explícita continuam responsabilidade da aplicação; o runtime só implementa a tabela.
Conclusão
Slices e strings são headers mais backing storage; maps são ponteiros para tabelas maps.Map feitas de groups de 8 slots, control words e um directory que cresce com extendible hashing. Indexação e subslicing são aritmética barata de header; append e writes em map são onde mallocgc, cópia e growth de table aparecem em profiles.
Próximo na série é Interfaces — iface e eface, itabs e o que dispatch dinâmico custa depois desses layouts concretos.