JavaScript de memória estática com pools de objetos

Introdução

Você recebe um e-mail informando que seu jogo / app da Web está com um desempenho ruim após um determinado período. Você analisa o código e não encontra nada que se destaque, até abrir as ferramentas de desempenho de memória do Chrome e ver o seguinte:

Um instantâneo da linha do tempo da memória

Um dos seus colegas de trabalho ri, porque percebe que você tem um problema de desempenho relacionado à memória.

Na visualização do gráfico de memória, esse padrão de serrilhado indica um problema de desempenho potencialmente crítico. À medida que o uso da memória aumenta, a área do gráfico também aumenta na captura da linha do tempo. Quando o gráfico cai repentinamente, é uma instância em que o coletor de lixo foi executado e limpou os objetos de memória referenciados.

O que os dentes serrilhados significam

Em um gráfico como esse, é possível ver que há muitos eventos de coleta de lixo, o que pode prejudicar a performance dos apps da Web. Neste artigo, vamos falar sobre como controlar o uso de memória, reduzindo o impacto no desempenho.

Coleta de lixo e custos de desempenho

O modelo de memória do JavaScript é criado com uma tecnologia conhecida como Garbage Collector. Em muitas linguagens, o programador é diretamente responsável por alocar e liberar memória do Memory Heap do sistema. No entanto, um sistema de coletor de lixo gerencia essa tarefa em nome do programador. Isso significa que os objetos não são liberados diretamente da memória quando o programador os derefere, mas sim em um momento posterior, quando as heurísticas do GC decidem que seria benéfico fazer isso. Esse processo de decisão exige que o GC execute algumas análises estatísticas em objetos ativos e inativos, o que leva um tempo para ser realizado.

A coleta de lixo é frequentemente retratada como o oposto do gerenciamento manual de memória, que exige que o programador especifique quais objetos serão desalocados e retornados ao sistema de memória.

O processo em que um GC recupera a memória não é sem custo financeiro. Ele geralmente reduz a performance disponível, ocupando um bloco de tempo para fazer o trabalho. Além disso, o próprio sistema decide quando executar. Você não tem controle sobre essa ação. Um pulso de GC pode ocorrer a qualquer momento durante a execução do código, o que vai bloquear a execução até que ela seja concluída. A duração desse pulso geralmente é desconhecida. Ela vai levar algum tempo para ser executada, dependendo de como o programa está usando a memória em um determinado momento.

Os aplicativos de alto desempenho dependem de limites de desempenho consistentes para garantir uma experiência tranquila aos usuários. Os sistemas de coletor de lixo podem encurtar esse objetivo, porque podem ser executados em horários e durações aleatórios, consumindo o tempo disponível que o aplicativo precisa para atingir as metas de performance.

Reduzir a rotatividade de memória e reduzir as taxas de coleta de lixo

Como observado, um pulso de GC vai ocorrer quando um conjunto de heurísticas determinar que há objetos inativos suficientes para que um pulso seja benéfico. Portanto, a chave para reduzir a quantidade de tempo que o coletor de lixo leva do seu aplicativo está em eliminar o máximo possível de casos de criação e liberação excessiva de objetos. Esse processo de criação/liberação de objetos com frequência é chamado de "rotatividade de memória". Se você conseguir reduzir a rotatividade de memória durante o tempo de vida do aplicativo, também vai reduzir o tempo que a GC leva para a execução. Isso significa que você precisa remover / reduzir o número de objetos criados e destruídos. Ou seja, você precisa parar de alocar memória.

Esse processo vai mover o gráfico de memória desta forma :

Um instantâneo da linha do tempo da memória

para isto:

Static Memory Javascript

Nesse modelo, o gráfico não tem mais um padrão de dente de serra, mas cresce muito no início e aumenta lentamente com o tempo. Se você estiver com problemas de desempenho devido à rotatividade de memória, esse é o tipo de gráfico que você vai querer criar.

Mudança para o JavaScript de memória estática

JavaScript de memória estática é uma técnica que envolve a pré-alocação, no início do app, de toda a memória que será necessária para a vida útil dele e o gerenciamento dessa memória durante a execução, já que os objetos não são mais necessários. Podemos alcançar esse objetivo em algumas etapas simples:

  1. Instrumentar seu aplicativo para determinar qual é o número máximo de objetos de memória ativa necessários (por tipo) para vários cenários de uso.
  2. Reimplemente o código para pré-alocar esse valor máximo e, em seguida, busque/libere-o manualmente em vez de acessar a memória principal.

Na verdade, para alcançar o objetivo 1, precisamos fazer um pouco do objetivo 2. Vamos começar por aí.

Pool de objetos

Em termos simples, o pool de objetos é o processo de retenção de um conjunto de objetos não usados que compartilham um tipo. Quando você precisa de um novo objeto para o código, em vez de alocar um novo do Memory Heap do sistema, você recicla um dos objetos não usados do pool. Quando o código externo é concluído com o objeto, em vez de ser liberado para a memória principal, ele é retornado ao pool. Como o objeto nunca é desreferenciado (ou seja, excluído) do código, ele não será coletado. O uso de pools de objetos coloca o controle da memória nas mãos do programador, reduzindo a influência do coletor de lixo no desempenho.

Como há um conjunto heterogêneo de tipos de objetos que um aplicativo mantém, o uso adequado de pools de objetos exige que você tenha um pool por tipo que tenha alta rotatividade durante a execução do aplicativo.

var newEntity = gEntityObjectPool.allocate();
newEntity.pos = {x: 215, y: 88};

//..... do some stuff with the object that we need to do

gEntityObjectPool.free(newEntity); //free the object when we're done
newEntity = null; //free this object reference

Para a grande maioria dos aplicativos, você vai chegar a um nível de necessidade de alocar novos objetos. Ao executar o aplicativo várias vezes, você vai conseguir ter uma ideia de qual é esse limite máximo e poderá pré-alocar esse número de objetos no início do aplicativo.

Pré-alocar objetos

A implementação do pool de objetos no projeto vai fornecer um máximo teórico para o número de objetos necessários durante a execução do aplicativo. Depois de executar seu site em vários cenários de teste, você terá uma boa noção dos tipos de requisitos de memória que serão necessários. Além disso, poderá catalogar esses dados em algum lugar e analisá-los para entender quais são os limites máximos de requisitos de memória para seu aplicativo.

Em seguida, na versão de envio do app, você pode definir a fase de inicialização para pré-preencher todos os pools de objetos com uma quantidade especificada. Essa ação vai empurrar toda a inicialização do objeto para a frente do app e reduzir a quantidade de alocações que ocorrem dinamicamente durante a execução.

function init() {
  //preallocate all our pools. 
  //Note that we keep each pool homogeneous wrt object types
  gEntityObjectPool.preAllocate(256);
  gDomObjectPool.preAllocate(888);
}

A quantidade escolhida tem muito a ver com o comportamento do aplicativo. Às vezes, o máximo teórico não é a melhor opção. Por exemplo, escolher o máximo médio pode reduzir a pegada de memória para usuários não avançados.

Longe de ser uma bala de prata

Há uma classificação completa de apps em que os padrões de crescimento de memória estática podem ser uma vantagem. No entanto, como o colega do Chrome DevRel Renato Mangini aponta, há algumas desvantagens.

Conclusão

Uma das razões pelas quais o JavaScript é ideal para a Web é que ele é uma linguagem rápida, divertida e fácil de começar. Isso se deve principalmente à baixa barreira para restrições de sintaxe e ao tratamento de problemas de memória em seu nome. Você pode programar e deixar que ele cuide do trabalho sujo. No entanto, para aplicativos da Web de alto desempenho, como jogos HTML5, o GC pode consumir a taxa de frames necessária, reduzindo a experiência do usuário final. Com uma instrumentação cuidadosa e a adoção de pools de objetos, é possível reduzir a carga na taxa de frames e recuperar esse tempo para coisas mais legais.

Código-fonte

Há muitas implementações de pools de objetos disponíveis na Web, então não vou entediar você com mais uma. Em vez disso, vou direcionar você a estes, cada um com nuances de implementação específicas, o que é importante, considerando que cada uso do aplicativo pode ter necessidades de implementação específicas.

Referências