[ LOW-LEVEL ] Memória: Stack e Heap

Alocação de memória, memória, em geral, no computador, é algo que sempre tive vontade de estudar. Um dos assuntos que eu era ansioso para estudar mas eu sempre procastinava esse estudo.

Segui este link que foi disponibilizado pelo @maniero, e me rendeu (ainda redendendo) vários dias de leitura e anotações. Acho que posso chamar isso de estudo.

Bem, o objetivo aqui é explicar o que eu entendi sobre o assunto e, consequentemente, aprender tanto ensinando, quanto vocês também podem me corrigir em algo que eu errar aqui - inclusive eu peço que deixe as suas conclusões sobre a minha visão deste assunto.

Stack

Traduzinho do inglês, significa pilha. Pilha porque é a forma que os dados são armazenados aqui, usando o método LIFO - last in, first out; último a entrar, primeiro a sair. Ou seja, os dados aqui são sempre colocados no topo e os dados removidos são sempre os do topo.

A Stack é conhecida por sua alocação automática, já que, ela mesma faz alocação e também a desalocação, fazendo com que a única preocupação do programador seja fornecer o dado a ser associado.

O seguinte código é em C:

int x = 1;

Isso é uma simples alocação na Stack, onde é pego um espaço na memória (o topo da Stack) e armazeno o dado.

Pontos a serem destacados:

  1. Stacks estão dentro de threads. Cada thread no seu programa possui uma Stack.
  2. Os dados são empilhados. Logo, podemos dizer que os dados são sequenciais
  3. Não é recomendado armazenar dados de grandes tamanhos, podendo causar o famoso stack overflow
  4. É usado um ponteiro - stack pointer - para alocar e desalocar dados
  5. Os dados que são armazenados na Stack podem ser: retorno de funções, paramêtros de funções e variáveis locais
  6. A desalocação é feita no término do programa, ou seja, de forma automática

Antes de ir pro Heap, quero explicar melhor o 2° ponto, quando disse:

"Os dados são empilhados. Logo, podemos dizer que os dados são sequenciais"

Quis dizer que o seguinte é possível:

int variable_1 = 1;
int variable_2 = 2;

int *pointer = &variable_1; // pega o endereço de 'variable_1'

printf("variable_1 = %d", *pointer); // output: variable_1 = 1
printf("variable_2 = %d", *(pointer + 1)); // output: variable_2 =  2

Heap

Heap, diferente da Stack, não possui um modelo. O que quer dizer que ele não possui bem uma "ordem" de alocar os dados nem de remover/desalocar.

O Heap é flexível. Mas, por não possuir um padrão/modelo de alocação e desalocação, acaba que o Heap perde enficiência.

Também chamado de dinâmico, já que é possível alocar e desalocar durante a excução do programa. Ou seja, é possível pedir para o usuário informar o tamanho do dado e armazenar um espaço na memória a partir desse dado fornecido pelo usuário - mesmo que não seja tão eficiente.

Ficaria assim, em C:

void alocar_com_heap(int tamanho_do_dado) {
    int *num = malloc(sizeof(int) * tamanho_do_dado); // reservando um espaço na memória
    
    *num = 1;
    
    printf("num = %d", *num); // output: num = 1
}

Tá certo. E pra liberar uma memória no Heap, como faz?

Tem as seguintes formas:

  1. Forma manual
  2. Garbage Collector - estudando

Bem, no C, o recomendando é sempre usar free() - que seria a forma manual - para não acontecer nenhum vazamento de memória, ou seja, a memória continua alocada mesmo após o término do programa.

Então, para completar o código de lá de cima:

void alocar_com_heap(int tamanho_do_dado) {
    int *num = malloc(sizeof(int) * tamanho_do_dado); // reservando um espaço na memória
    
    *num = 1;
    
    printf("num = %d", *num); // output: num = 1
    
    free(num); // liberando a reserva no heap
}

Conclusão

A minha visão sobre o assunto é essa.

Faz pouco tempo que eu estou estudando - comecei na segunda - então, a ideia ainda vai "amadurecer" na minha mente.

Também quero que esse post sirva de incentivo para você que está no início da aprendizagem em programação (eu já tenho um bom tempo que tento estudar sobre a área, então não estou tão no início) para que aprenda mais sobre a base, conceitos low-level.

Quero destacar o seguinte: eu aprendi, deixei mais claros alguns conceitos sobre este assunto só de escrever este post. Fica aí a dica.

Assuntos para Estudo:

Tem vários assuntos que não foram abordados aqui, e o porquê disso é que o assunto ainda está sendo processado pela minha mente. Ou, simplesmente, ainda estou estudando. risos.

Alguns deles:

  1. Garbage Collector
  2. Escopo e Tempo de Vida
  3. Ponteiros e Referências
  4. Closures

Estude todo dia um pouco. Isso funciona de verdade.

Sabe o que é engraçado? Programadores que vão manipular a pilha no baixo nível sabem que elas não são bem pilhas. Eu sempre omito isso, mas é o contrário. Vai de cima para baixo, é uma pilha morcegando :D

A Stack é conhecida por sua alocação automática, já que, ela mesma faz alocação e também a desalocação

Eu posso ter dado a entender isso. Mas na verdade a alocação em si é fixa da pilha toda. Ela deixa você usar um espaço dela e volta atrás quando não precisa mais daquilo. De fato é automaticamente que isso acontece. Quando inicia um escopo que tem um frame um ponteiro (stack pointer) "sobe" (de verdade, desce, mas é mais fácil enxergar como subindo) na pilha, e quando não precisa mais esse ponteiro volta para o lugar original, marcando onde é o topo da pilha naquele momento (o tipo lógico, o físico é fixo - em tese também, mas naõ vamos complicar).

O primeiro código no exemplo entra na pulha se estiver em uma função. É raro usar for,a mas se for a alocação não é na pilha.

A liberação do espaço em cada frame é no fum da função. A desalocação da puilha como um todo é no fim do programa. Isso acontece em C e quase todas as outras linguagens. Alguém pdoe inventar alguma maluquice.

Após o térmido do programa a memória é sempre liberada pelo sistema operacional, não tem risco. Em tese se você sabe que o programa vai rodar por pouco tempo e aloca pouco dinamicamente, pode até nmão usar o free() que estará tudo bem. Mas pra que fazer isso? Até tem caso que faz sentio, mas não faça por preguiça, acostume-se fazer o mais certo.

O problema é que sem o free() enquanto executa vai acumulando e vai estourar a a RAM, vai ficar muito lento (vai para a memória virtual em disco (SSD)), até quebrar de vez em casos extremos.

O exemplo mostrado está ok, mas é óbvio que fazendo assim não tem como se enrolar, o problema que a alocação assim costuma ser na pilha mesmo, se usa a forma dinâmica onde aloca em um lugar e libera em outro, aí começa ficar difícil, por isso o GC é uma técnica mais fácil, apesar de conbrar um preço.

Saber essas coisas ajuda programar melhor qualquer coisa, mesm oque nunca use direrentamente. Desde que a pessoa estude do jeito certo, tem gente que não consegue fazer associações depois.

Ajudei? Era o meu desejo.


Farei algo que muitos pedem para aprender a programar corretamente, gratuitamente (não vendo nada, é retribuição na minha aposentadoria) (links aqui).

Obrigado pelo crédito.

Ajudei? Era o meu desejo.


Farei algo que muitos pedem para aprender a programar corretamente, gratuitamente (não vendo nada, é retribuição na minha aposentadoria) (links aqui).

Na verdade, a stack crescer para cima ou para baixo depende da ABI da arquitetura que está sendo usada. Nos nossos PCs usamos x86 que cresce para baixo, porém em um microcontrolador piccolo ela cresce para cima, por exemplo.
Tem isso mesmo, é que estou acostumado com isso e todo mundo meio que só fala assim na maioria dos círculos, mas é uma boa ressalva.
O que seria "cima" e "baixo"? Eu entendi que crescer para baixo significa que a stack começa em um endereço alto de memória e desce em direção a um endereço mais baixo. Por exemplo, *usando números totalmente fictícios apenas para entendimento*: vamos supor que seja decrescente de **255 (0xFF) à 128 (0x80)**. É isso?
Isso mesmo. E aí visualmente seria representado assim: ![Pilha para baixo](https://i.stack.imgur.com/I5NQc.png) Clar oque na memória não tem esse visual, é só para enteerdemos melhor. Mas se representar para vcima dá na mesma. Se naõ tem enedereços de memória, não muda nada, e se tem é só colocar os endereço corretos, então se estiver crecendo para baixo em cima ser 2555 e embaixo será 128, mas se estiver crewcendo para cima, o topo será 128 e lá embaixo será 255.

Muito bom esse conteúdo, hoje em dia com linguagem que não usam tipagem de dados, ninguém tem noção desses escopos de memória, simplesmente vão e fazem. Eu venho do java e posso dizer que para se ter performance você tem que entender essas partes e até um fine tunning com flags pro garbage collector é um assunto extenso. Na minha opinião a evolução do Hardware, as linguagens começaram abstrair muito esse assunto, e dai surgiram a escalabilidade pra contornar esse tipo de problema. Acredito muito que se tivessemos melhores programadores desse nível, não precisariamos ter tanto de escalibidade etc. Da uma olhada em rust tbm, é uma filosofia legal mas também tem uns conceitos estranhos kkk. Obrigado por compartilhar, vou acompanhar.

Os computadores foram evoluindo e junto com essa evolução se foram várias dificuldades. Com essas dificuldades foram também vários conceitos que eram estudados para resolve-las. Ou seja, com o tempo, a necessidade de um programador ter que realmente saber programar foi meio que se "diluindo". Mesmo eu sendo novo e ainda no início da minha carreira, hoje qualquer um consegue escrever um código e fazer ele funcionar - não importando se é útil. As pessoas sempre estão criando algo para que a vida delas fique mais fácil. Mas isso acaba destruindo várias coisas que são essenciais. O conforto faz com que elas não queiram aprender mais a fundo algo - já que elas conseguem usar este algo sem ir mais a fundo - porque é chato. Enfim, me alongei nessa parte 😆 Eu ando dando voltas em linguagens. Acho que isso se dá pelas ideias que vem na minha mente e eu passa-las pro código em alguma linguagem. Eu já dei uma olhada, bem por cima, no Rust. Achei uma linguagem bem legal e com a melhor documentação que eu já vi até hoje. Mas voltei pro C depois que eu li que "Rust só é um C/C++ maquiado", algo desse tipo. Então eu tô tentando estudar os conceitos básicos para ter noção do que eu quero. Estendi o pouco. risos.
Concordo que deve-se estudar C antes de ir para Rust ou Go, C é mais direto ao ponto. Se puder, estude Assembly x86 também. No entanto: Não acredito que "Rust só é um C/C++ maquiado". Rust e Go são linguagens compiladas, inclusive o compilador de Rust é escrito em Rust e o compilador de Go é escrito em Go. Dizem que se uma linguagem compilada não é capaz de compilar seu própŕio compilador escrito nela mesma, então ela não está completa. Ambas possuem o mesmo nível de "poder" do C/C++, talvez não em Go pois você não consegue gerenciar a memória como no C sem alguns hacks pois ele tem Garbage Collector.
Todas as linguagens usam tipagem. Algumas fazem isso dinamicamente e costumam ter só um tipo, por isso fica escondido. O gerenciamente de memória é ortogonal à tipagam (no geral, tem uns casos que é mais relacionado). Linguagens de tipagem estática pode esconder isso que ele falou também. E existem algumas de tipagem dinâmica que você precisa lidar com isso, mas é bem raro e inpopular. Não é só dominar o GC que ajuda. É dominar a tipagem e outras coisa. E a linguagem ajudar. C# ajuda muito mais que Java. Java está prometendo uma revolução nisso para começar ficar próximo de C# nisso, mas era para ter saído na 10, até agora naa, mas não desistiram oficialmente. É difícil mudar com o carro andando, C# nasceu assim e só melhorou depois. Hardware tem límite e custa caro. E tem casos que você pode deixar milagres de vezes mais rápido com pequenos tdetalhes, haja hardware para compensar isso. Precisar escalabilidade precisamos, mas seria mais vertical e menos horizontal que é mais cara e mais difícil, ironicamente :D Esses dias saiu esa que a Microsoft vai substituir um tiquinho de C# por Rust, justamente porque é um caso que faz sentido ter ganhos que nem C# deu para entregar.

Ótimo post!

Se não me engano a heap é acessível em todos os threads e tem escopo global. E ela pode estar tanto na ram quanto no hd/ssd.

Cara muito bacana a explicação!

Quero adicionar aqui como isso é um pouco diferente em rust. Em rust a desalocação ocorre quando, por exemplo, uma variável sai de escopo.

fn exemplo(x: i32) {
    // usa o x para qualquer coisa
}

fn main() {
    let num = 42;
    
    exemplo(num);
    
    println("exemplo = {}", num); // Erro!
}

O código acima na maioria das linguagens não teria problemas. Em rust, por conta de algumas regras no compilador, nem compila esse código pois a variavel 'num' foi entregada para a função 'exemplo' e isso faz com que essa função agora seja 'dona' da variável. Então assim que a função termina a variável foge de escopo e ela é automáticamente desalocada, por isso não é possível fazer o print depois.

Só isso já evita muitos erros que poderiam acontecer envolvendo alocação e acesso a memória, e acho rust bem bacana por se preocupar com essas coisas.