No nosso dia a dia de programação, raramente precisamos nos preocupar com a performance ou a alocação de memória, afinal de contas as ferramentas do Go como runtime, garbage collector, compilador, são bem eficientes em seu trabalho, nos blindando da carga cognitiva nestas áreas. Porém. nas vezes em que performance é uma preocupação e temos que descer um nível abaixo no código, saber como essas ferramentas funcionam por debaixo dos panos e saber como você pode otimizar seu código é essencial.
No post de hoje quero discutir um pouco sobre alocação de memória e o garbage collector, além de deixar umas dicas de como diminuir o trabalho do garbage collector.
heap e stack
Para as pessoas com um background mais de Ciência / Engenharia da Computação, esses termos devem despertar alguma memória (seja ela dolorosa ou não hehe) das aulas de sistemas operacionais, arquitetura de computadores, etc. Para nivelar o entendimento, vou introduzir brevemente alguns conceitos que julgo serem importantes para continuarmos com a discussão do garbage collector.
heap e stack são duas áreas distintas de um memory layout de algum processo do sistema operacional, ou seja, dando um resumo bem por cima e bem impreciso (como diria os disclaimers do mestre Akita rsrs), são duas “áreas” diferentes da memória do seu computador, onde cada uma tem uma função específica e armazena um tipo diferente de dado.
Assim como processos do SO possuem memory layout e áreas da memória reservada somente para eles, as threads desses processos também o tem, e, no caso do Go, como não lidamos diretamente com threads do SO, as goroutines também possuem seu próprio memory layout, com stack, heap, etc, gerenciadas pelo próprio runtime do Go (essa é uma grande vantagem das goroutines quando comparadas com threads comuns do SO, mas isso é tema para outro post).
Como funcionam a stack e a heap?
stack
A stack (velha conhecida dos amantes de leetcode) é basicamente um bloco consecutivo de memória. Cada chamada de função dentro de uma thread em execução compartilha a mesma stack (AHA moment, dai o termo stack overflow, quando tentamos usar mais memória do que a alocada para aquela stack especifica), logo, alocar memória na stack é rápido e simples.
Uma “variável” (mais precisamente um endereço especifico de memória) guarda o stack pointer. O stack pointer serve para rastrearmos o endereço de memória da última alocação do stack.
A cada chamada de função estamos adicionando um stack frame na stack, que nada mais é que uma área delimitada na stack somente para aquela função, nessa carregamos as variáveis da função e seus argumentos, variáveis criadas dentro da função são guardadas na stack também. Assim que a função retorna, recalculamos o stack pointer e é desalocado a memória que aquela função estava ocupando, seguindo assim consecutivamente para todas as chamadas de funções.
heap
O heap é uma área da memória que é dedicada a alocação de dados dinâmicos e é administrada pelo garbage collector. Outro ponto importante do heap é que ele é um espaço compartilhado entre threads / goroutines. Em termos de implementação, o heap é muito mais complexo, além de estar focado em dados de mais longo prazo, ou seja, enquanto tivermos um ponteiro apontado pra aquele dado ele vai continuar existindo no heap. É aqui que devemos ajudar o garbage collector também, seja não mandando muitos dados para ca, seja otimizando o que é mandado, quando necessário.
Para um detalhamento mais baixo nível, tanto de heap quanto outras áreas de memória administradas pelo runtime do Go, indico este post.
Beneficiando-se da stack e heap para performance
Em termos de performance, queremos usar a stack o máximo possível, por ser mais eficiente e não onerar o garbage collector, quando utilizarmos o heap, queremos também utilizar de maneira mínima e inteligente, como por exemplo, utilizando buffers.
Para alocar algo na stack, o compilador do Go precisa saber o tamanho exato do dado em tempo de compilação, quando olhamos para os tipos de valores em Go (primitivos, arrays e structs) todos eles tem uma coisa em comum: Sabemos exatamente quanto de memória é necessário para armazena-los (este é um dos motivos do porque o tamanho de um array em Go é considerado parte do seu tipo). Devemos abusar desses tipos para melhorar a performance. Um adendo é que ponteiros também são alocados na stack, porém os dados para os quais eles apontam podem estar na heap, então não se enquadram nesse grupo anterior.
Já no caso do heap, ele é administrado pelo garbage collector. Quando um dado não tem mais um ponteiro apontando para ele, se torna “garbage” e pode ser desalocado na próxima varredura do garbage collector.
Porque é ruim utilizarmos o heap quando o assunto é performance?
Primeiramente, o garbage collector precisa de um tempo precioso de CPU para ser executado, então paramos a execução do nosso programa temporariamente para que o garbage collector possa rodar. Este processo do garbage collector não é trivial, diversos algoritmos foram desenvolvidos ao longo do tempo, alguns focados em maior throughput (achar o maior número de garbage possível, desalocando o máximo de memória), outros em menor latência (acabar o processo de garbage collection o mais rápido possível, ao custo de ser menos eficiente em cada varredura). No caso específico do Go, seu garbage collector favorece mais latência baixa, então cada sweep (varredura) tende a rodar bem rápido, porém, se seu programa gera muito garbage, obviamente o garbage collector vai levar alguns sweeps para conseguir desalocar toda a memória ocupada por garbage, sendo assim bem menos eficiente. Para quem se interessar em saber mais desse processo do garbage collector, deixo aqui esse post do blog do Go contando a historia do desenvolvimento do mesmo.
O segundo problema tem mais a ver com detalhes de implementação do hardware, a RAM (random access memory) foi arquitetada para suportar acessos randômicos de áreas de memória, porém, a maneira mais rápida de acessar seus dados ainda é o sequencial. Um slice de inteiros possui todos os seus dados sequencias (um do lado do outro), tornando rápido a sua leitura, já um slice de ponteiros tem seus dados todos espalhados por toda a RAM, o que torna seu acesso bem mais devagar.
E como reduzimos o fardo do garbage collector?
Com base nos conceitos apresentados anteriormente, podemos deduzir que devemos usar ponteiros com cuidado, uma vez que seus dados são armazenados no heap e ficarão espalhados na RAM de maneira randômica, então use ponteiros só quando for extremamente necessário, quando o foco for performance.
Além do uso consciente de ponteiros, devemos focar ao máximo utilizar a stack, abuse de dados primitivos (números, booleanos, strings e runes), alocando assim o mínimo possível no heap, quanto menos garbage for pro heap mais eficiente os sweeps do garbage collector serão, diminuindo assim o seu fardo.
BONUS TIP: Ferramentas para identificar o uso do heap
A primeira ferramenta é o próprio build
do compilador do Go. Ao passarmos as gcflags -m
e -l
conseguimos fazer a escape analysis do nosso código. Quando rodamos o build com essas flags teremos um output do compilador dando dicas de quando ele moveu algo pro heap, como no exemplo abaixo:
./main.go:10:2: **moved to heap: res**
Uma outra ferramenta que ajuda muito é o benchmark. Quando um programa está bem otimizado (não utilizando nada do heap), teremos o seguinte output:
$ go test -bench . -benchmem
BenchmarkStackIt-8 680439016 1.52 ns/op 0 B/op 0 allocs/op
A coluna interessante é a de 0 allocs/op
que indica que nenhuma alocação no heap foi executada. Se parar pra ler os benchmarks das libs famosas em Go vai começar a perceber que diversas delas possuem esse stat de 0 allocs (o que é bem impressionante 🤯🤯🤯).
Conclusão
Entender a diferença entre heap e stack, assim como a maneira que o Go gerencia a memória, é crucial para otimizar a performance de suas aplicações. Utilizar a stack sempre que possível e ser cuidadoso com o uso de ponteiros pode reduzir significativamente a carga do garbage collector, resultando em programas mais eficientes e rápidos. Ferramentas como escape analysis e benchmarks ajudam a identificar e solucionar problemas de alocação de memória. Com essas práticas, você pode aproveitar ao máximo o desempenho que o Go tem a oferecer, criando aplicações robustas e eficientes. Lembre-se, pequenas otimizações no gerenciamento de memória podem fazer uma grande diferença na performance geral do seu sistema.
Top comments (0)