O artigo do blog oficial do Go discute otimizações recentes no compilador Go para reduzir alocações na heap, substituindo-as por alocações na stack. Alocações na stack são significativamente mais rápidas e não sobrecarregam o garbage collector, resultando em programas mais eficientes em termos de tempo de execução e uso de memória. O artigo explora diferentes cenários e as melhorias introduzidas em versões recentes do Go (1.25 e 1.26) para otimizar a alocação de slices.
O Problema das Alocações na Heap
Alocar memória na heap é uma operação relativamente custosa. Cada alocação envolve a execução de um código considerável e impõe uma carga adicional ao garbage collector (GC). Mesmo com otimizações recentes no GC, como o Green Tea, o overhead ainda é significativo. Alocações na stack, por outro lado, são muito mais baratas (às vezes até “gratuitas”), não afetam o GC e permitem um reuso rápido da memória, o que é benéfico para o cache.
Alocação na Stack de Slices com Tamanho Constante
Considere o seguinte exemplo de código que processa tarefas recebidas de um channel:
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
Nesse código, a cada iteração do loop, a função append adiciona uma nova tarefa ao slice tasks. Inicialmente, o slice não possui um backing store (a área de memória onde os elementos do slice são armazenados). Assim, a função append precisa alocar um. Como não sabe o tamanho final do slice, ela começa com um tamanho pequeno (1). Nas iterações seguintes, se o backing store estiver cheio, append aloca um novo com o dobro do tamanho anterior, descartando o antigo. Esse processo gera várias alocações na heap e lixo para o GC, especialmente no início, quando o slice é pequeno.
Uma otimização comum é pré-alocar o slice com um tamanho estimado:
func process2(c chan task) {
tasks := make([]task, 0, 10) // provavelmente no máximo 10 tarefas
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
Essa abordagem reduz o número de alocações, mas o compilador Go pode ir além. Se o tamanho máximo do backing store for conhecido em tempo de compilação (neste caso, 10 vezes o tamanho de task), ele pode alocar o backing store na stack do frame da função process2, eliminando completamente a necessidade de alocação na heap. Essa otimização depende do backing store não “escapar” para a heap dentro de processAll.
Alocação na Stack de Slices com Tamanho Variável
E se o tamanho do slice não for conhecido em tempo de compilação? Considere o seguinte:
func process3(c chan task, lengthGuess int) {
tasks := make([]task, 0, lengthGuess)
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
Em versões anteriores ao Go 1.25, o tamanho variável do backing store impedia a alocação na stack. No entanto, o Go 1.25 introduziu uma otimização inteligente: para certas alocações de slice, o compilador aloca automaticamente um pequeno backing store (atualmente, 32 bytes) na stack. Se o tamanho solicitado para o slice for pequeno o suficiente para caber nesse backing store, ele é usado. Caso contrário, a alocação ocorre na heap como de costume.
Essa otimização permite que process3 execute sem alocações na heap se lengthGuess for pequeno o suficiente e corresponder ao número real de elementos no channel c.
Alocação na Stack de Slices Alocados por Append
O Go 1.26 vai ainda mais longe, permitindo a alocação na stack mesmo quando o tamanho do slice é desconhecido e o append é usado sem pré-alocação.
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
Nesse caso, o compilador aloca um pequeno backing store especulativo na stack. Na primeira iteração do loop, append usa esse backing store. Se o backing store da stack for grande o suficiente para acomodar os elementos adicionados, as primeiras iterações não exigirão alocações na heap. Quando o backing store da stack estiver cheio, append alocará um novo na heap, mas as alocações iniciais na heap (de tamanhos 1, 2, 4, etc.) são evitadas, reduzindo o overhead e o lixo para o GC.
Slices que “Escapam”
Se um slice “escapar” da função (por exemplo, sendo retornado), seu backing store não pode ser alocado na stack, pois o frame da stack da função desaparece quando ela retorna.
func extract(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
return tasks
}
Nesse caso, o backing store do slice retornado por extract deve ser alocado na heap. No entanto, o compilador ainda pode otimizar a alocação dos slices intermediários usados dentro do loop, alocando-os na stack. Uma abordagem comum é criar uma copia do slice dentro da função:
func extract2(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
tasks2 := make([]task, len(tasks))
copy(tasks2, tasks)
return tasks2
}
Implicações Práticas
As otimizações de alocação na stack introduzidas nas versões recentes do Go têm um impacto significativo no desempenho e na eficiência de memória dos programas Go. Ao reduzir o número de alocações na heap e a carga sobre o garbage collector, essas otimizações resultam em programas mais rápidos e com menor consumo de memória.
Os desenvolvedores podem se beneficiar dessas otimizações simplesmente atualizando para as versões mais recentes do Go. Em muitos casos, nenhuma alteração no código é necessária para aproveitar as melhorias. Em outros casos, pequenas modificações, como pré-alocar slices com um tamanho estimado, podem aumentar ainda mais o desempenho.
As otimizações de alocação na stack são um exemplo do compromisso contínuo da equipe Go em melhorar o desempenho da linguagem e fornecer aos desenvolvedores ferramentas para escrever programas eficientes e escaláveis.
Artigo Original
Este e um resumo em português do artigo original publicado no blog oficial do Go.
Titulo original: Allocating on the Stack
Leia o artigo completo em ingles no Go Blog
Autor original: Keith Randall