Voltar ao início

Go Internals - Tipos Nativos

10 min de leitura
Cover Image for Go Internals - Tipos Nativos
Lucas LemosLucas Lemos

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:

  1. Table única, pequena: capacity dobra no lugar até maxTableCapacity (1024 entradas por table em table.go).
  2. 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.
  3. 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 é Interfacesiface e eface, itabs e o que dispatch dinâmico custa depois desses layouts concretos.