Arquitetura do NodeJS III: Gerenciamento de memória e Garbage Collection

21 min de leitura
NodeJSJavaScriptInternalsV8Garbage CollectionMemory
Disponível também em:English

Na postagem anterior, entendemos quais são os principais componentes do V8 e a responsabilidade de cada um. Agora iremos explorar uma parte mais remota para entender como o V8 faz o gerenciamento de memória e o garbage collection no NodeJS.

Cada variável, função, array ou valor que geramos em nossa aplicação, precisa ser armazenada em um local temporário até que a execução do nosso software se encerre. Este lugar é chamado de memória. Imagine este espaço como um armário de colégio, onde cada aluno tem o seu espaço para guardar seus livros e materiais. Todos os espaços são endereçados, de forma que os alunos consigam se localizar e encontrar qual é o espaço de cada um. Podemos dizer, de forma superficial, que a memória que utilizamos em nossos softwares funcionam da mesma forma. Armazenamos um valor em um determinado espaço e conseguimos obtê-lo posteriormente através de seu endereço.

A memória mencionada acima como um componente que integra nosso software é a memória RAM, ou Random Access Memory. Podemos dividir essa memória em duas grandes partes: a Stack e a Heap. Para entendermos o por que essa divisão existe, primeiro precisamos entender as características de cada uma e como elas são usadas no processo de compilação.

Antes de nos aprofundarmos na Stack da memória, vamos primeiro entender o que a estrutura de dados que ela implementa representa. Se você não é familiarizado com estruturas de dados, talvez você não conheça algumas estruturas comuns que são observadas em diversas ferramentas e soluções que temos por baixo dos panos nas nossas aplicações.

Imagine que você está arrumando sua gaveta de camisetas. Você dobra e coloca uma em cima da outra, ou seja, você empilha suas roupas. Como você é uma pessoa muito preguiçosa, quando você precisa pegar uma camiseta, você sempre escolhe a mais fácil, nesse caso, sempre a que está por cima. Logo, você só vai vestir a camiseta que está no fundo da pilha de roupas, que também foi a primeira camiseta que você colocou na pilha, quando todas as outras já estiverem sujas. Essa é a principal regra da pilha (Stack): o primeiro que entra será o último a sair, o que chamamos de LIFO (last in, first out).

Movimentação de dados em Stacks - Imagem própria
Movimentação de dados em Stacks - Imagem própria

Por outro lado, quando falamos de Heap, podemos pensar em uma estrutura mais diversa e sem muitas regras. Imagine que você é um fã de sapatos e você tem vários modelos diferentes, afinal, você precisa andar combinando com suas camisetas empilhadas. Para guardar essa montanha de sapatos você comprou um armário gigante para sua casa. Esse armário tem várias divisórias horizontais e verticais para você colocar os seus sapatos, mas as divisórias são tantas, que você teve que enumerar cada uma delas para saber onde determinado sapato está. Você pode guardar o sapato em qualquer divisória, desde que anote a posição em que ele foi guardado, como também, pode usar qualquer sapato a qualquer momento, apenas sabendo onde ele está, ou procurando um por um. Essa é a estrutura de Heap.

Dados na Heap - Imagem própria
Dados na Heap - Imagem própria

Supondo uma distribuição como a realizada na imagem acima, sendo nossos sapatos os quadrados verdes, podemos dizer que hoje queremos nosso tênis para uma longa caminhada que está no I2, ou nossa pantufa para o frio que está F8. Conseguimos pegar qualquer sapato, assim como guardar qualquer um.

Essa separação na memória permite que cada região armazene o tipo de dado que consegue trabalhar de forma mais eficiente. Além disso, a Stack ainda é responsável por montar a Call Stack do software, onde cada escopo ou camada nova que adentramos no código, empilha um novo pacote de variáveis e dados em memória. Vamos acompanhar o exemplo para o seguinte trecho de código abaixo:

Cálculo de média para exemplo de Call Stack - Imagem própria
Cálculo de média para exemplo de Call Stack - Imagem própria

Neste código, fazemos o cálculo da média de dois números, sendo 10 e 20 utilizados como exemplo. No início, a Call Stack está vazia. Ao realizarmos a chamada da função average, a execução dessa função é adicionada à pilha junto com a declaração de duas variáveis internas a e b. Aqui, é chamada a função sum para encontrar o numerador da divisão, então um novo item é adicionado à pilha. Contudo, este item é breve, pois ele apenas realiza a soma dos dois números e devolve o resultado para a função average, retirando novamente o item do topo da pilha. Agora, com este resultado, a função division está pronta para ser chamada, reinserindo um novo item na pilha. Com o resultado da divisão retornando para average e retirando sua execução da Call Stack, a função de cálculo de média também já está pronta para retornar, removendo o último item da estrutura e encerrando a execução do programa. Observe esse fluxo na imagem seguinte com cada passo da execução:

Alocação da Stack - Imagem própria
Alocação da Stack - Imagem própria

Observando a estrutura, vemos que existe uma sequência lógica de empilhamento ao decorrer da execução, além disso, temos várias vantagens de performance quando armazenamos os dados na Stack, por exemplo, o fato do tamanho da Stack ser fixo permite que ela seja pré-alocada na inicialização do software, não sendo necessário realizar chamadas ao sistema operacional para realizar alocações em tempo de execução. A Stack também tem estrutura bem definida para o fluxo de dados, onde manipulamos apenas os itens no topo da pilha, isso nos permite ter o endereçamento exato de alocação e consulta, fazendo com que escritas e leituras sejam mais rápidas em relação a Heap. Outro benefício da Stack é o fato de armazenarmos os dados de forma sequencial, logo, há um aproveitamento completo do espaço na memória, o que não ocorre na Heap como veremos posteriormente. Mas o questionamento que fica sobre todos esses pontos é, se a Stack é tão maravilhosa, então por que não armazenamos tudo lá?

A Stack possui algumas limitações que está até mesmo atrelada aos seus próprios pontos positivos, como o seu tamanho fixo, que garante o pré-alocamento, mas também a torna inflexível. Imagine novamente o exemplo da gaveta de camisetas e pense que você comprou sua gaveta para o tamanho exato de 10 camisetas. Porém, você ganhou uma camiseta nova de aniversário e não tem mais onde colocar. Sua gaveta está agora com camisetas demais, além de sua capacidade, ou seja, ela está sobrecarregada. Quando sua Stack está sobrecarregada, tentando receber mais dados do que ela comporta, ela para de funcionar devido a um erro muito característico e famoso, o Stack Overflow, ou sobrecarga de pilha. Isso pode acontecer facilmente, pois o tamanho reservado para stack é relativamente baixo, e além disso, nos softwares que construímos, utilizamos estruturas de dados muito mais complexas do que apenas tipos primitivos. Muitas vezes, precisamos também de variáveis, funções e dados que podem ser utilizados por toda a aplicação, e em contraponto, a Stack oferece um nível de isolamento por escopo, sendo assim, necessário duplicar dados em diferentes itens da Call Stack para ser possível acessá-los, e ainda assim, operações de escrita seriam extremamente dificultadas, pois seria necessário replicar a alteração em todos os lugares da Call Stack onde essa variável estivesse presente.

Dessa forma, introduzimos a aplicação da Heap para tratar a escrita em espaços livres, a leitura guiada por endereços, o armazenamento de estrutura complexas, a utilização desses dados em escopo global, etc. Considerando que o alocamento na Heap é mais lento, pois como já falamos anteriormente, depende de chamadas ao sistema operacional para reservar espaço em memória, e além disso, é necessário identificar quais os espaços livres para isso, pois a estrutura não é sequencial como a Stack, conseguimos consolidar uma visão mais definida da divisão entre o que cada região da memória armazena: Stack é responsável por Call Stack com tipos primitivos e ponteiros, e a Heap gerencia estruturas complexas, como Array, Objetos, etc. 

Temos também o cenário onde executamos diversas Worker Threads do NodeJS e operações paralelas. Quando isso ocorre, cada Worker roda seu próprio V8 isolado, sendo assim, cada um possui seu próprio Heap e seu próprio Stack. Além disso, podemos citar um fenômeno que, provavelmente, todo desenvolvedor já teve que enfrentar alguma vez na vida: o memory leak, ou vazamento de memória. Ele ocorre quando alocamos espaço na Heap, porém, antes do nosso software encerrar, não o liberamos, sobrando assim um espaço ocupado na memória que não é utilizado por ninguém. Em linguagens de baixo nível, como C, isso seria o equivalente a alocar um espaço com malloc e sobrescrever o ponteiro antes de livrar a memória com a função free.

Provavelmente você notou a presença de um termo novo nos parágrafos acima: ponteiros. Para entender a necessidade de ponteiros, primeiro vamos ver rapidamente sobre Arrays.

Array é uma estrutura de dados que permite armazenar múltiplos valores de forma sequencial em memória. A palavra sequencial aqui é de extrema importância. Olhando para o nosso armário de sapatos com várias divisórias, imagine agora que você queira guardar sapatos de mesma marca próximos uns dos outros. Você busca colocá-los todos na mesma linha, como mostra a imagem abaixo:

Array simples na Heap - Imagem própria
Array simples na Heap - Imagem própria

Quando você declara um novo Array no seu código, você está fazendo essa separação da Heap que vemos acima, porém ao mesmo tempo, estamos criando na Stack uma forma de referenciar esse endereço alocado. Na nossa analogia, você guardou os seus sapatos ordenados por marca no seu enorme armário e anotou em um pequeno pedaço de papel onde cada marca começa, exemplo: marca A começa no A1 e marca B começa no A4. Tecnicamente, o que está acontecendo é que você está armazenando os valores do Array na Heap, e um ponteiro que nos mostra onde esse Array começa na Stack. Você não precisa anotar no seu papel que os itens A1, A2 e A3 são da marca A, pois uma premissa dos Arrays é que eles sejam armazenados de forma sequencial em memória, logo, se você sabe que tem 3 sapatos da marca A e que o primeiro está em A1, você consegue prever onde todos os outros estão.

Essa mesma organização acontece para Objetos, porém, o V8 gerencia isso com nomenclaturas diferentes. Cada chave do Array é chamada de index e devem ser valores numéricos, assim como os Arrays que lidamos nas linguagens de programação, onde cada chave aponta para um element. Já as chaves dos Objetos são chamadas de property, elas podem ser alfanuméricas e apontam para os values. Dessa forma, temos a seguinte organização para esses elementos em memória:

Ponteiros e Arrays na memória - Imagem própria
Ponteiros e Arrays na memória - Imagem própria

Contudo, você é uma pessoa de muita sorte, e como se já não bastasse ganhar uma camiseta de aniversário, você venceu um sorteio da marca A e ganhou mais 2 sapatos novos. Agora, você precisa reorganizar os seus sapatos no armário. Isso revela um problema na estrutura que temos atualmente: seria necessário mover os Objetos na memória, seja posicionando o item da marca B em outro espaço, ou movendo todo o Array da marca A, e isso resultaria na alteração de todos os ponteiros dessas variáveis para apontar para o novo endereço. Para adicionar uma camada de abstração, garantir performance e fácil manipulação dessas informações, a estrutura armazenada na Heap não é diretamente um Array (ou Objeto), mas sim, estruturas implementadas pelo V8 que possuem um mecanismo ligeiramente diferente.

O ponteiro da Stack na verdade aponta para um JSArray, uma estrutura intermediária que o V8 utiliza para gerenciar os ponteiros e valores em memória dos Arrays. Ele funciona como um header que mantém informações como length, o comprimento do array, elements, já mencionado anteriormente como os valores armazenados pelo Array, properties, como os valores vinculados a chaves não numéricas, e Map (ou hidden class), uma estrutura que mantém o formato daquele Array ou Objeto. Os elements, ao invés de armazenar diretamente os valores, possui um ponteiro que aponta para uma outra estrutura de dados chamada de FixedArray, que por sua vez, tem os valores armazenados.

Estrutura do JSArray - Imagem própria
Estrutura do JSArray - Imagem própria

Os Objetos, por sua vez, possuem sua estrutura equivalente, o JSObject. Ambos são considerados HeapObjects. Analisar o JSObject é uma boa oportunidade para entendermos a estrutura do Map e como ela é utilizada. Essa estrutura nos auxilia a agrupar Objetos de mesmo formato, ou seja, imagine que você cria 3 instâncias de sapatos e todas elas possuem as mesmas chaves (properties): brand, size e color. Todos os 3 compartilharão o mesmo Map. Para esse processo de gestão, os Maps possuem algumas propriedades, como instance_size, que representa o tamanho que uma instância daquele Objeto ocupa, elements_kind, que veremos em seguida, e descriptors, que relacionam a propriedade ao offset em relação à posição inicial do ponteiro da entidade a que ele pertence.

Os elements_kind variam de acordo com o tipo de dado e forma de preenchimento do Array ou do Objeto. Temos diversos tipos, os quais não vamos aprofundar em cada um, mas podemos exemplificar, como os kinds de prefixo PACKED, que representam Arrays e Objetos preenchidos por completo, enquanto os de prefixo HOLEY representam aqueles com elementos faltantes. Além disso, temos divisão por tipo de dados, como PACKED_SMI_ELEMENTS para Small Integers, PACKED_DOUBLE_ELEMENTS para números reais e PACKED_ELEMENTS para os demais tipos de dados.

Estrutura geral de um Objeto na memória - Imagem própria
Estrutura geral de um Objeto na memória - Imagem própria

Repare que toda essa estrutura permite que agora, quando fazemos um push em um Array ou modificamos um objeto de forma a forçar a movimentação deles na memória, graças a essa estrutura, não precisamos alterar os ponteiros de nenhuma variável, apenas os ponteiros internos do JSObject ou JSArray para os seus elements.

Contudo, mesmo com esse nível de abstração, a Heap ainda apresenta alguns problemas, como a fragmentação do espaço da memória e a retenção de variáveis não mais utilizadas.

Como falamos anteriormente, o que seria feito no nosso armário para adicionar mais 2 sapatos aos 3 que já tínhamos lá anteriormente, seria mover todos os sapatos da marca A para um novo espaço onde todos coubessem lado a lado, alocando eles agora em A5.

Array realocado na Heap - Imagem própria
Array realocado na Heap - Imagem própria

Imagine isso acontecendo com uma frequência e quantidade de dados maior. Poderíamos dizer que a organização dos espaços na Heap seria algo parecido com a imagem abaixo:

Memória fragmentada - Imagem própria
Memória fragmentada - Imagem própria

Todos os espaços vazios representam espaços não ocupados da Heap. Supondo que cada elemento de um Array ocupe um desses espaços, observando a imagem, não seríamos mais capazes de adicionar um novo Array de 5 itens, mesmo existindo 5 espaços vazios na Heap, contudo, não sendo eles sequenciais. A fragmentação é essa representação de como a memória pode ficar após a manipulação da memória, seja adicionando, alterando ou removendo valores dela.

Anteriormente falamos de vazamento de memória, onde não liberamos o espaço alocado para as variáveis utilizadas. Hoje, na maioria das linguagens modernas já temos um mecanismo implementado que é responsável por encontrar esses itens que não são mais utilizados e descartá-los. Esse mecanismo é chamado de Garbage Collector.

No V8, a ferramenta de garbage collection foi chamada de Orinoco. Ele segue uma sequência de passos para garantir que a memória continue sendo reaproveitada, reorganizada e limpa periodicamente, sendo eles: identificar itens ativos e inativos na memória, reciclar a memória ocupada por itens inativos, e compactar ou desfragmentar a memória.

Para entendermos como ele funciona mais profundamente, precisamos antes entender que ele separa a Heap em duas partes virtuais. Uma chamada de Young Generation e outra chamada de Old Generation.

A Young Generation é baseada na hipótese geracional, onde é presumido que a maioria dos objetos armazenados em memória é de curta duração, ou seja, morre jovem, seja uma variável interna de uma função ou uma string qualquer. Por isso, o V8 reserva apenas um pequeno espaço para esse gerenciamento. Esse espaço é subdividido em mais duas partes, o from-space e o to-space. Quando um novo item armazenado na Heap é declarado, ele entra na Young Generation no from-space, após um tempo de execução, se o from-space enche, o algoritmo identifica os objetos ativos e move eles para o to-space e então, descarta toda a from-space. Depois dessa etapa, os dois espaços são reorganizados, onde o que era chamado de from-space se torna to-space, e o to-space se torna from-space. Quando essa operação de movimentação dos objetos de um espaço para o outro é realizada, ela executa em um formato chamado de stop-the-world, onde o JavaScript para por completo enquanto ela move os objetos. Contudo, esse ainda é um processo rápido mesmo com essa parada, pois considerando a hipótese geracional, apenas uma pequena parcela desses objetos serão copiados, logo o volume é muito baixo. Esse processo é executado por um algoritmo chamado Scavenger, e o garbage collector na Young Generation é chamado de Minor GC .

Se um objeto sobrevive a duas rodadas do Scavenger, ele é movido para Old Generation, onde o processo de garbage collector é chamado de Major GC e executa um algoritmo de Mark-Compact. O processo desse algoritmo é executado em três passos, começando pelo mark, onde o grafo de objetos é completamente analisado e os itens ativos são marcados. Na próxima etapa ocorre o sweep, onde todos objetos não marcados anteriormente são descartados e o espaço é liberado. Por fim, com o compact é realizada a desfragmentação, movendo os objetos de forma a preencher os espaços vazios na memória.

Garbage collection - Imagem própria
Garbage collection - Imagem própria

Todo esse processo garante a reutilização da memória ao decorrer da execução do nosso software, e para lidar com tudo isso de forma eficiente, o Orinoco aplica técnicas de otimização em cada processo, por exemplo, na etapa de marcação, ela é realizada de forma incremental, ou seja, ele marca alguns itens, devolve o controle para o JavaScript, depois retoma, e repete este ciclo. Além disso, parte do trabalho de marcação e sweeping roda em threads auxiliares, enquanto o processo principal do JavaScript continua executando na thread principal. Por fim, quando é necessário o stop-the-world, ele executa múltiplas threads de forma paralela para encurtar essa pausa.

O V8 é uma ferramenta extremamente complexa que exige muito tempo de estudo para entender seus detalhes mais íntimos. O objetivo dessa sequência inicial de postagens foi trazer uma visão ampla com uma certa profundidade em alguns conceitos, mas que devem sempre ser estudados e aprofundados. Futuramente, podemos retomar uma série exclusiva sobre V8, mas por agora, seguiremos para a próxima publicação da sequência onde começaremos a entender a Libuv e o Event Loop.

Referências