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, mas não encontra nada que se destaque, até abrir as ferramentas de desempenho de memória do Chrome e conferir 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 da 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 heap de memória 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 alocar e retornar ao sistema de memória.

O processo em que um GC recupera a memória não é gratuito. 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 bloqueia a execução do código até que ele seja concluído. 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 coletores de lixo podem causar um curto-circuito nessa meta, já que podem ser executados em momentos aleatórios por durações aleatórias, o que consome o tempo disponível que o aplicativo precisa para atingir as metas de desempenho.

Reduza a desistência de memória, reduza os impostos sobre 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 o 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 a vida útil 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. Na verdade, é preciso parar de alocar memória.

Esse processo moverá o gráfico de memória deste :

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

para isto:

Static Memory Javascript

Neste modelo, é possível observar que o gráfico não tem mais um padrão dente de serra, mas cresce bastante no início e depois aumenta lentamente com o tempo. Se você está enfrentando problemas de desempenho devido à rotatividade de memória, esse é o tipo de gráfico que convém criar.

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

JavaScript de memória estática é uma técnica que envolve pré-alocação, no início do app, toda a memória que será necessária para o ciclo de vida 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 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 utilizados que compartilham um tipo. Quando você precisa de um novo objeto para o código, em vez de alocar um novo da Memory Heap do sistema, você recicla um dos objetos não usados do pool. Depois que o código externo termina com o objeto, em vez de liberá-lo na 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ê acabará atingindo um nível de dificuldade em termos 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);
}

O valor escolhido 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 oferecer um consumo de memória menor para quem não é usuário avançado.

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. Como aponta um colega do Chrome DevRel Renato Mangini, há algumas desvantagens.

Conclusão

Uma das razões pelas quais o JavaScript é ideal para a web se deve ao fato de ser uma linguagem rápida, divertida e fácil para começar. Isso se deve principalmente à baixa barreira para restrições de sintaxe e ao tratamento de problemas de memória em seu nome. É possível programar e deixar o app cuidar 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 essa carga no frame rate e recuperar esse tempo para coisas mais incríveis.

Código-fonte

Há muitas implementações de pools de objetos flutuando na Web, então não vou aborrecer você com outra. 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