Depurando vazamentos de memória em aplicativos Node.js
Publicados: 2022-03-11Certa vez, dirigi um Audi com um motor V8 twin-turbo e seu desempenho foi incrível. Eu estava dirigindo a cerca de 140 MPH na rodovia IL-80 perto de Chicago às 3 da manhã quando não havia ninguém na estrada. Desde então, o termo “V8” passou a ser associado a alto desempenho para mim.
Embora o V8 da Audi seja muito potente, você ainda está limitado com a capacidade do seu tanque de gasolina. O mesmo vale para o V8 do Google - o mecanismo JavaScript por trás do Node.js. Seu desempenho é incrível e há muitas razões pelas quais o Node.js funciona bem para muitos casos de uso, mas você está sempre limitado pelo tamanho do heap. Quando você precisa processar mais solicitações em seu aplicativo Node.js, você tem duas opções: dimensionar verticalmente ou dimensionar horizontalmente. A escala horizontal significa que você precisa executar mais instâncias de aplicativos simultâneas. Quando bem feito, você acaba conseguindo atender mais solicitações. O dimensionamento vertical significa que você precisa melhorar o uso e o desempenho da memória do seu aplicativo ou aumentar os recursos disponíveis para sua instância do aplicativo.
Recentemente me pediram para trabalhar em um aplicativo Node.js para um dos meus clientes Toptal para corrigir um problema de vazamento de memória. O aplicativo, um servidor de API, foi projetado para processar centenas de milhares de solicitações a cada minuto. O aplicativo original ocupava quase 600 MB de RAM e, portanto, decidimos pegar os endpoints da API quente e reimplementá-los. As despesas gerais ficam muito caras quando você precisa atender a muitos pedidos.
Para a nova API, escolhemos restify com driver MongoDB nativo e Kue para trabalhos em segundo plano. Parece uma pilha muito leve, certo? Não exatamente. Durante o pico de carga, uma nova instância de aplicativo pode consumir até 270 MB de RAM. Portanto, meu sonho de ter duas instâncias de aplicativos por 1X Heroku Dyno desapareceu.
Arsenal de depuração de vazamento de memória Node.js
Memwatch
Se você procurar por “como encontrar vazamento no nó”, a primeira ferramenta que você provavelmente encontrará é o memwatch . A embalagem original foi abandonada há muito tempo e não é mais mantida. No entanto, você pode encontrar facilmente versões mais recentes na lista de fork do GitHub para o repositório. Este módulo é útil porque pode emitir eventos de vazamento se o heap crescer mais de 5 coletas de lixo consecutivas.
Despejo de pilha
Ótima ferramenta que permite que os desenvolvedores do Node.js tirem instantâneos de heap e os inspecionem posteriormente com as Ferramentas do desenvolvedor do Chrome.
Inspetor de nós
Uma alternativa ainda mais útil ao heapdump, porque permite que você se conecte a um aplicativo em execução, faça dump de heap e até depure e recompile-o em tempo real.
Levando o "inspetor de nós" para um giro
Infelizmente, você não poderá se conectar a aplicativos de produção que estão sendo executados no Heroku, porque ele não permite que sinais sejam enviados para processos em execução. No entanto, o Heroku não é a única plataforma de hospedagem.
Para experimentar o node-inspector em ação, escreveremos um aplicativo Node.js simples usando restify e colocaremos uma pequena fonte de vazamento de memória nele. Todos os experimentos aqui são feitos com o Node.js v0.12.7, que foi compilado em relação ao V8 v3.28.71.19.
var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });
A aplicação aqui é bem simples e tem um vazamento bem óbvio. As tarefas de matriz cresceriam ao longo da vida útil do aplicativo, fazendo com que ele ficasse lento e eventualmente travasse. O problema é que não estamos apenas vazando o fechamento, mas também objetos de solicitação inteiros.
O GC no V8 emprega a estratégia stop-the-world, portanto, significa que mais objetos você tem na memória quanto mais tempo levará para coletar o lixo. No log abaixo, você pode ver claramente que no início da vida do aplicativo levaria em média 20ms para coletar o lixo, mas algumas centenas de milhares de solicitações depois demora cerca de 230ms. As pessoas que estão tentando acessar nosso aplicativo teriam que esperar 230ms a mais agora por causa do GC. Além disso, você pode ver que o GC é invocado a cada poucos segundos, o que significa que a cada poucos segundos os usuários teriam problemas para acessar nosso aplicativo. E o atraso aumentará até que o aplicativo falhe.
[28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].
Essas linhas de log são impressas quando um aplicativo Node.js é iniciado com o sinalizador –trace_gc :
node --trace_gc app.js
Vamos supor que já iniciamos nosso aplicativo Node.js com esse sinalizador. Antes de conectar o aplicativo com o node-inspector, precisamos enviar o sinal SIGUSR1 para o processo em execução. Se você executar o Node.js no cluster, certifique-se de se conectar a um dos processos escravos.
kill -SIGUSR1 $pid # Replace $pid with the actual process ID
Ao fazer isso, estamos fazendo com que o aplicativo Node.js (V8 para ser mais preciso) entre no modo de depuração. Neste modo, o aplicativo abre automaticamente a porta 5858 com V8 Debugging Protocol.
Nossa próxima etapa é executar o node-inspector que se conectará à interface de depuração do aplicativo em execução e abrirá outra interface da web na porta 8080.
$ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.
Caso o aplicativo esteja sendo executado em produção e você tenha um firewall instalado, podemos encapsular a porta remota 8080 para localhost:
ssh -L 8080:localhost:8080 [email protected]
Agora você pode abrir seu navegador da Web Chrome e obter acesso total às Ferramentas de desenvolvimento do Chrome anexadas ao seu aplicativo de produção remota. Infelizmente, as Ferramentas do desenvolvedor do Chrome não funcionarão em outros navegadores.
Vamos encontrar um vazamento!
Vazamentos de memória no V8 não são vazamentos de memória reais como os conhecemos de aplicativos C/C++. Em JavaScript as variáveis não desaparecem no vazio, elas apenas ficam “esquecidas”. Nosso objetivo é encontrar essas variáveis esquecidas e lembrá-los de que Dobby é gratuito.
Dentro do Chrome Developer Tools, temos acesso a vários profilers. Estamos particularmente interessados em Record Heap Allocations , que executa e tira vários instantâneos de heap ao longo do tempo. Isso nos dá uma visão clara de quais objetos estão vazando.
Comece a gravar as alocações de heap e vamos simular 50 usuários simultâneos em nossa página inicial usando o Apache Benchmark.
ab -c 50 -n 1000000 -k http://example.com/
Antes de tirar novos instantâneos, o V8 realizaria a coleta de lixo mark-sweep, então definitivamente sabemos que não há lixo antigo no instantâneo.
Corrigindo o vazamento em tempo real
Depois de coletar instantâneos de alocação de heap durante um período de 3 minutos , acabamos com algo como o seguinte:
Podemos ver claramente que existem alguns arrays gigantescos, muitos objetos IncomingMessage, ReadableState, ServerResponse e Domain também no heap. Vamos tentar analisar a origem do vazamento.
Ao selecionar heap diff no gráfico de 20s a 40s, veremos apenas os objetos que foram adicionados após 20s de quando você iniciou o criador de perfil. Dessa forma, você pode excluir todos os dados normais.
Observando quantos objetos de cada tipo estão no sistema, expandimos o filtro de 20s para 1min. Podemos ver que os arrays, já bastante gigantescos, não param de crescer. Em “(array)” podemos ver que existem muitos objetos “(propriedades do objeto)” com a mesma distância. Esses objetos são a fonte do nosso vazamento de memória.

Também podemos ver que os objetos “(fechamento)” também crescem rapidamente.
Pode ser útil olhar para as cordas também. Sob a lista de cordas, há muitas frases “Hi Leaky Master”. Esses podem nos dar alguma pista também.
No nosso caso sabemos que a string ”Hi Leaky Master” só poderia ser montada sob a rota “GET /”.
Se você abrir o caminho dos retentores, verá que essa string é de alguma forma referenciada via req , então há contexto criado e tudo isso adicionado a uma matriz gigante de closures.
Então, neste ponto, sabemos que temos algum tipo de matriz gigantesca de fechamentos. Vamos dar um nome a todos os nossos encerramentos em tempo real na guia de fontes.
Depois que terminarmos de editar o código, podemos pressionar CTRL+S para salvar e recompilar o código em tempo real!
Agora vamos gravar outro instantâneo de alocações de heap e ver quais encerramentos estão ocupando a memória.
Está claro que SomeKindOfClojure() é nosso vilão. Agora podemos ver que os encerramentos SomeKindOfClojure() estão sendo adicionados a algumas tarefas nomeadas de matriz no espaço global.
É fácil ver que essa matriz é simplesmente inútil. Podemos comentar. Mas como liberamos a memória que a memória já ocupava? Muito fácil, apenas atribuímos um array vazio às tarefas e com a próxima solicitação ele será substituído e a memória será liberada após o próximo evento do GC.
Dobby está livre!
Vida de lixo em V8
O heap V8 é dividido em vários espaços diferentes:
- Novo Espaço : Este espaço é relativamente pequeno e tem um tamanho entre 1 MB e 8 MB. A maioria dos objetos são alocados aqui.
- Old Pointer Space : Possui objetos que podem ter ponteiros para outros objetos. Se o objeto sobreviver por tempo suficiente no Novo Espaço, ele será promovido ao Espaço do Ponteiro Antigo.
- Espaço de dados antigo : contém apenas dados brutos como strings, números encaixotados e matrizes de duplos não encaixotados. Objetos que sobreviveram à GC no Novo Espaço por tempo suficiente também são movidos para cá.
- Espaço de Objeto Grande : Objetos que são grandes demais para caber em outros espaços são criados neste espaço. Cada objeto tem sua própria região
mmap
'ed na memória - Espaço de código : Contém código de montagem gerado pelo compilador JIT.
- Espaço de célula, espaço de célula de propriedade, espaço de mapa : Este espaço contém
Cell
s,PropertyCell
seMap
s. Isso é usado para simplificar a coleta de lixo.
Cada espaço é composto por páginas. Uma página é uma região de memória alocada do sistema operacional com mmap. Cada página tem sempre 1 MB de tamanho, exceto para páginas com grande espaço de objeto.
O V8 tem dois mecanismos de coleta de lixo integrados: Scavenge, Mark-Sweep e Mark-Compact.
Scavenge é uma técnica de coleta de lixo muito rápida e opera com objetos no Novo Espaço . Scavenge é a implementação do Algoritmo de Cheney. A ideia é muito simples, o New Space é dividido em dois semi-espaços iguais: To-Space e From-Space. O Scavenge GC ocorre quando o To-Space está cheio. Ele simplesmente troca os espaços de e para e copia todos os objetos vivos para o espaço ou os promove para um dos espaços antigos se eles sobreviveram a duas eliminações e são totalmente apagados do espaço. As coletas são muito rápidas, mas têm a sobrecarga de manter o heap de tamanho duplo e copiar objetos constantemente na memória. A razão para usar necrófagos é porque a maioria dos objetos morre jovem.
Mark-Sweep & Mark-Compact é outro tipo de coletor de lixo usado na V8. O outro nome é coletor de lixo completo. Ele marca todos os nós ativos, depois varre todos os nós mortos e desfragmenta a memória.
Dicas de desempenho e depuração do GC
Embora o alto desempenho de aplicativos da Web possa não ser um problema tão grande, você ainda deve evitar vazamentos a todo custo. Durante a fase de marcação no GC completo, o aplicativo é realmente pausado até que a coleta de lixo seja concluída. Isso significa que quanto mais objetos você tiver no heap, mais tempo levará para executar o GC e mais tempo os usuários terão que esperar.
Sempre dê nomes a closures e funções
É muito mais fácil inspecionar rastreamentos de pilha e heaps quando todos os seus closures e funções têm nomes.
db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })
Evite objetos grandes em funções quentes
Idealmente, você deseja evitar objetos grandes dentro de funções quentes para que todos os dados caibam no Novo Espaço . Todas as operações de CPU e memória devem ser executadas em segundo plano. Evite também gatilhos de desotimização para funções quentes, a função quente otimizada usa menos memória do que as não otimizadas.
As funções quentes devem ser otimizadas
Funções quentes que são executadas mais rapidamente, mas também consomem menos memória, fazem com que o GC seja executado com menos frequência. A V8 fornece algumas ferramentas de depuração úteis para identificar funções não otimizadas ou funções não otimizadas.
Evite polimorfismo para ICs em funções quentes
Caches Inline (IC) são usados para acelerar a execução de alguns pedaços de código, seja armazenando em cache o acesso à propriedade do objeto obj.key
ou alguma função simples.
function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3
Quando x(a,b) é executado pela primeira vez, V8 cria um IC monomórfico. Quando você chama x
uma segunda vez, o V8 apaga o IC antigo e cria um novo IC polimórfico que suporta os dois tipos de operandos inteiro e string. Quando você chama o IC pela terceira vez, o V8 repete o mesmo procedimento e cria outro IC polimórfico de nível 3.
No entanto, há uma limitação. Depois que o nível de IC atinge 5 (pode ser alterado com o sinalizador –max_inlining_levels ), a função se torna megamórfica e não é mais considerada otimizável.
É intuitivamente compreensível que as funções monomórficas sejam executadas mais rapidamente e também tenham uma pegada de memória menor.
Não adicione arquivos grandes à memória
Este é óbvio e bem conhecido. Se você tiver arquivos grandes para processar, por exemplo, um arquivo CSV grande, leia-o linha por linha e processe em pequenos pedaços em vez de carregar o arquivo inteiro na memória. Existem casos bastante raros em que uma única linha de csv seria maior que 1mb, permitindo que você a encaixasse no New Space .
Não bloqueie o thread do servidor principal
Se você tiver alguma API quente que demore algum tempo para processar, como uma API para redimensionar imagens, mova-a para um thread separado ou transforme-a em um trabalho em segundo plano. As operações intensivas da CPU bloqueariam o encadeamento principal, forçando todos os outros clientes a esperar e continuar enviando solicitações. Os dados de solicitação não processados seriam empilhados na memória, forçando assim o GC completo a levar mais tempo para ser concluído.
Não crie dados desnecessários
Uma vez tive uma experiência estranha com o restify. Se você enviar algumas centenas de milhares de solicitações para uma URL inválida, a memória do aplicativo aumentará rapidamente em até cem megabytes até que um GC completo seja ativado alguns segundos depois, quando tudo voltará ao normal. Acontece que para cada URL inválida, restify gera um novo objeto de erro que inclui rastreamentos de pilha longos. Isso forçou os objetos recém-criados a serem alocados no Espaço de Objetos Grandes em vez de no Novo Espaço .
Ter acesso a esses dados pode ser muito útil durante o desenvolvimento, mas obviamente não é necessário na produção. Portanto, a regra é simples - não gere dados a menos que você certamente precise deles.
Conheça suas ferramentas
Por último, mas certamente não menos importante, é conhecer suas ferramentas. Existem vários depuradores, coletores de vazamento e geradores de gráficos de uso. Todas essas ferramentas podem ajudá-lo a tornar seu software mais rápido e eficiente.
Conclusão
Compreender como funciona a coleta de lixo e o otimizador de código do V8 é a chave para o desempenho do aplicativo. O V8 compila JavaScript para assembly nativo e, em alguns casos, código bem escrito pode atingir desempenho comparável com aplicativos compilados pelo GCC.
E caso você esteja se perguntando, o novo aplicativo de API para meu cliente Toptal, embora haja espaço para melhorias, está funcionando muito bem!
Joyent lançou recentemente uma nova versão do Node.js que usa uma das versões mais recentes do V8. Alguns aplicativos escritos para Node.js v0.12.x podem não ser compatíveis com a nova versão v4.x. No entanto, os aplicativos experimentarão uma tremenda melhoria de desempenho e uso de memória na nova versão do Node.js.