Atualizado em

Como funciona o Garbage Collection no Node.js


Introdução

Esse artigo um estudo sobre o Garbage Collection no V8, e consequentemente no Node.js, ele não tem a intenção de apresentar algo prático com aplicações diretas no dia a dia, apesar de apresentar código e exemplos a intenção aqui é aprofundar o conhecimento sobre Node.js e como ele funciona.

De forma geral, todo Garbage collector funciona dessa forma:

  1. Identifica objetos “vivos” e “mortos”.
  2. Recicla/reutiliza a memória ocupada por objetos mortos.
  3. Compacta/desfragmenta a memória (opcional).

A definição de objetos vivos e mortos depende do “alcance”: em JavaScript, um objeto está vivo se pode ser alcançado a partir da raiz. Um objeto morto não pode ser alcançado.


var a = {b: 1, c: 2}
var d = {e: 1}

console.log(window.a) //{b: 1, c: 2} - live object
console.log(window.d) //{e: 1} - live object
console.log(window.a.b) //1 - also live object

d = undefined

console.log(window.a) //{b: 1, c: 2} - live object
console.log(window.d) //undefined - dead object

Obs.: O caso do let e const é diferente, por isso existem outras formas de o GC identificar raízes. Para esse artigo, vamos considerar o caso do var, que é vinculado ao objeto global window.

O custo do GC

Executar o GC vem com um custo: ele consome recursos e, consequentemente, pode aumentar o tempo de processamento. Essa estratégia é chamada de “stop-the-world”, apesar de simples, essa estratégia precisa usar os recursos da main-thread, ou seja, pode causar o aumento de latência.

Atualmente, o V8 suporta outras estratégias de execução do GC, sendo elas:

Paralela: Helper threads são criadas para dividir a carga de trabalho por igual entre elas. Essa estratégia ainda causa uma pausa na main-thread, porém, como a carga foi distribuída entre as helper threads, esse tempo tende a ser menor.

Incremental: Usa apenas a main-thread, porém o trabalho do GC é dividido durante a execução do programa em cargas menores. Ainda gasta o mesmo tempo que “stop-the-world”, porém sem sobrecarregar a main-thread. Na prática, a latência percebida será menor. Imagine um vídeo carregando no YouTube: pequenos engasgos geram um impacto menor no usuário do que um carregamento demorado, mesmo que o tempo total seja o mesmo. Na prática, essa estratégia pode demandar um tempo um pouco maior, dado que alternar o código JS executado pode invalidar a execução do GC.

Concorrente: É quando o código JS é executado na main-thread enquanto o GC é executado em helper threads. À primeira vista, pode parecer a melhor estratégia, porém ela vem com um custo: é a mais complicada porque sofre do mesmo problema da Incremental, porém agora com código JS sendo executado de forma concorrente, ou seja, com operações que podem gerar race conditions.

Atenção: A descrição usada pode parecer com a definição de execução paralela, porém, no contexto do GC, como as helper threads estão manipulando o mesmo espaço de memória (heap), entende-se que elas estão concorrendo entre si pelo mesmo recurso, por isso, é concorrência.

Pausa stop-the-world para execução do garbage collector

Estratégias paralela, incremental e concorrente do garbage collector

Generation Layout

O V8 divide a heap em duas áreas chamadas generations (gerações), sendo elas a Young generation e a Old generation. A Young generation tem mais duas divisões, “Nursery” e “Intermediate”. São nomes conceituais, na seção sobre o Minor aprenseto os nomes corretos.

A ideia aqui é que é mais comum criarmos objetos que devem ser destruídos logo na sequência, ou que esse tipo de comportamento é o mais esperado. Por isso, todo objeto nasce na Young generation, em Nursery. Após a primeira rodada do GC, se ele sobreviveu, vai para Intermediate, e, após mais uma rodada, se o objeto ainda estiver vivo, vai para Old generation. Lembre-se de que estar vivo significa ser acessível através da raiz, no exemplo, ser acessível a partir do objeto global window.

Existem dois tipos de GC no V8: o Major é aplicado em toda a heap (Young e Old), enquanto o Minor é aplicado apenas na Young.

Organização da Young generation e da Old generation

Major GC (Full Mark-Compact)

Marking: É o processo de acessar os objetos acessíveis e marcá-los. O GC segue os ponteiros e acessa e marca os objetos em tempo de execução de forma recursiva, similar a percorrer uma árvore, até acessar todos os objetos.

Marcação dos objetos alcançáveis a partir da raiz

Sweeping: O espaço ocupado por objetos que não podem ser acessados é adicionado a uma estrutura chamada free-list. Essa estrutura organiza os endereços de memória dos espaços liberados e seus tamanhos. Quando houver necessidade de usar mais memória, a free-list é consultada para recuperar um espaço livre de acordo com o tamanho desejado.

Compactação dos objetos vivos e agrupamento do espaço livre

Compaction: É o processo de compactar (e evacuar - mover) a memória usada, em que os objetos vivos são movidos para regiões contínuas. Isso reduz a fragmentação e agrupa o espaço livre, permitindo que ele seja reutilizado em alocações futuras. As regiões livres podem ser registradas na free-list e disponibilizadas ao alocador.

Remoção de objetos mortos e registro do espaço livre na free-list

O Major GC começa de forma concorrente no processo de Marking. Quando termina, envia o resultado para a main-thread, que valida o Marking e inicia o Compact de forma paralela onde for possível. Então, a main-thread começa o Sweep de forma concorrente.

Minor GC (Scavenger)

Esse GC é responsável por mover objetos entre as duas divisões da Young generation, dividida em dois semi-spaces, From-Space e To-Space. Como a Young generation é menor, ~16 MB, o Minor GC é executado com mais frequência.

O Minor GC usa a estratégia paralela, dividindo a carga de trabalho entre a main-thread e as helper threads.

Analisando o trace do GC na prática

Execute esse código usando a flag –trace-gc:

//node --trace-gc gc.js
let maximum = 1_000_000;
const entries = new Set();

const main = () => {
  while (maximum > 0) {
    const obj = {
      timestamp: Date.now(),
      index: maximum,
      message: 'memory using'.repeat(100)
    }
    entries.add(obj);
    maximum --;
  }
}
main();

A saída será algo assim:

[55384:0x70480c000]       19 ms: Scavenge 4.4 (5.5) -> 4.2 (6.5) MB, pooled: 0 MB, 0.79 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure;

Vamos entender as partes mais importantes dessa saída:

[55384:0x70480c000] -> [PID: instância da heap JS]. 19 ms -> Quando a etapa começou. Aqui, significa que começou após 19 ms de execução. Scavenge -> Fase de execução. Nesse caso, Scavenge é Minor GC. 4.4 (5.5) -> Heap usada antes do GC (total da heap antes do GC), ambos em MB. 4.2 (6.5) -> Heap depois do GC (total da heap depois do GC), novamente em MB. 0.79 / ** ms -> Tempo que o JS ficou parado. allocation failure -> O motivo que disparou o GC: falha de alocação, ou seja, foi necessário alocar um novo objeto, porém a memória estava cheia.

Em outras linhas, podemos ver algo assim:

[55384:0x70480c000]      297 ms: Mark-Compact 206.7 (318.7) -> 186.0 (315.8) MB, pooled: 0 MB, 70.42 / 0.00 ms  (+ 0.7 ms in 37 steps since start of marking, biggest step 0.1 ms, walltime since start of marking 73 ms) (average mu = 0.755, current mu = 0.755) finalize incremental marking via stack guard; GC in old space requested

Note que agora foi executado o Mark-Compact, o Major GC, na Old generation, referenciada como old space.

[55384:0x70480c000]       19 ms: Scavenge 4.4 (5.5) -> 4.2 (6.5) MB, pooled: 0 MB, 0.79 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure;
[55384:0x70480c000]       20 ms: Scavenge 4.4 (6.5) -> 4.4 (9.0) MB, pooled: 0 MB, 0.62 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure;
[55384:0x70480c000]       22 ms: Scavenge 6.7 (9.5) -> 6.6 (10.3) MB, pooled: 0 MB, 1.42 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure;
[55384:0x70480c000]       25 ms: Scavenge 6.9 (10.3) -> 6.8 (15.8) MB, pooled: 0 MB, 2.29 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure;
[55384:0x70480c000]       29 ms: Scavenge 11.2 (16.4) -> 11.1 (16.4) MB, pooled: 0 MB, 1.71 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure;
[55384:0x70480c000]       30 ms: Scavenge 11.5 (16.4) -> 11.5 (28.2) MB, pooled: 0 MB, 1.29 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure;

Ao analisar as primeiras linhas do trace, podemos notar que o Scavenge (Minor GC) não consegue limpar a Young generation, dado que os valores da heap usada antes e depois do GC são quase iguais:

4.4 -> 42 4.4 -> 4.4 7.6 -> 6.6

Isso ocorre porque o Minor GC não conseguiu se livrar dos novos objetos criados e, olhando para a heap total depois do GC, notamos um aumento gradual.

6.5 MB -> 9.0 MB -> 10.3 MB -> 15.8 MB

Ou seja, os objetos estão sendo promovidos para a Old generation, e o Node.js precisa alocar mais recursos para a heap, solicitando-os ao SO.

[55384:0x70480c000]      297 ms: Mark-Compact 206.7 (318.7) -> 186.0 (315.8) MB, pooled: 0 MB, 70.42 / 0.00 ms  (+ 0.7 ms in 37 steps since start of marking, biggest step 0.1 ms, walltime since start of marking 73 ms) (average mu = 0.755, current mu = 0.755) finalize incremental marking via stack guard; GC in old space requested

Em bom português, aqui a vaca foi pro brejo: a quantidade de memória alocada chegou a 315.8 MB, e o Major GC foi acionado.

Agora execute essa versão do código. A diferença aqui é que os objetos são criados e, como não são adicionados ao Set, perdem sua referência. Logo, não vão para a Old generation. O trace irá mostrar apenas a execução do Minor GC.

let maximum = 1_000_000;
const entries = new Set();

const main = () => {
  while (maximum > 0) {
    const obj = {
      timestamp: Date.now(),
      index: maximum,
      message: 'memory using'.repeat(100)
    }
    //entries.add(obj);
    maximum --;
  }
}
main();

Sinas de vazamento de memória (o que procurar nos logs?)

1. O uso de memória aumenta depois do Major GC.

O esperado é que após o Major GC a Old generation fique menor ou se mantenha estável, isso significa que o Major GC está liberando a memória enquantos objetos de Young são introduzidos.

Retorno esperado - Major GC funcionando, memória se mantém ~30MB.

[PID] 1000 ms: Mark-Compact 150.2 (200.0) -> 30.5 (200.0) MB ...
[PID] 5000 ms: Mark-Compact 180.1 (200.0) -> 31.0 (200.0) MB ...
[PID] 9000 ms: Mark-Compact 175.4 (200.0) -> 29.8 (200.0) MB ...

Retorno ruim

[PID] 1000 ms: Mark-Compact 150.2 (200.0) -> 80.5 (200.0) MB ...
[PID] 3000 ms: Mark-Compact 190.1 (250.0) -> 120.2 (250.0) MB ...
[PID] 5000 ms: Mark-Compact 240.8 (300.0) -> 180.0 (300.0) MB ...
[PID] 7000 ms: Mark-Compact 290.0 (350.0) -> 250.4 (350.0) MB ...

2. Minor parece não funcionar.

Quando muitos objetos vão da Young para Old pode ser o indício de um problema dado que poderá sobrecarregar a Old. Nos logs você verá que a memória usada antes e depois do Minor GC (Scavenge) não diminui, mesmo após sucessivas execuções do Minor.

[PID] 100 ms: Scavenge 12.4 (16.0) -> 12.2 (16.0) MB
[PID] 120 ms: Scavenge 14.5 (16.0) -> 14.4 (20.0) MB

3. Major GC sendo executado com maior frequência.

Quando a Old fica sem espaço o Major GC precisa limpa-la. Como já sabemos o esperado é que boa parte dos objetos não sejam promividos da Young para a Old, e se a Old fica constantemente cheia você irá notar o Major GC sendo executado com mais frequência e num menor espaço de tempo entre as execuções.

Nos logs você verá algo assim:

[PID] 10000 ms: Mark-Compact
[PID] 12000 ms: Mark-Compact
[PID] 13000 ms: Mark-Compact
[PID] 13500 ms: Mark-Compact

Conclusão

Após entender como funciona o GC no Node, podemos ler o trace do GC e identificar se há vazamento de memória, porém não conseguimos identificar onde ele ocorre, para isso devemos usar outras ferramentas. Em breve escrevei um artigo sobre como identificar esses vazamentos.




Uso de IA:

  • Correção gramatical.

Fontes:
https://nodejs.org/learn/diagnostics/memory/using-gc-traces
https://v8.dev/blog/trash-talk
https://nodejs.org/api/cli.html