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

Introdução

Você recebe um e-mail informando o desempenho ruim do seu jogo da Web / app da Web depois de um certo tempo, analisa seu código, não vê nada que se destaque, até abrir as ferramentas de desempenho de memória do Chrome e ver isto:

Uma captura de tela 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 da memória, esse padrão de dente é muito revelador sobre um problema de desempenho potencialmente crítico. À medida que o uso de memória aumenta, você verá a área do gráfico também crescer na captura da linha do tempo. Quando o gráfico cai repentinamente, trata-se de uma instância em que o Coletor de lixo foi executado e limpou seus objetos de memória referenciados.

O que significam os dentes de serra

Em um gráfico como este, você pode ver que há muitos eventos de coleta de lixo ocorrendo, o que pode ser prejudicial para o desempenho dos seus aplicativos da Web. Este artigo aborda como assumir o controle do 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 base em uma tecnologia conhecida como Coletor de lixo. 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 coletor de lixo gerencia essa tarefa em nome do programador, o que significa que os objetos não são liberados diretamente da memória quando o programador desreferencia a memória, mas, em um momento posterior, quando a heurística do GC decide que seria benéfico fazer isso. Esse processo de decisão exige que a GC execute algumas análises estatísticas sobre objetos ativos e inativos, o que leva um bloco de tempo para ser executado.

A coleta de lixo geralmente é retratada como o oposto do gerenciamento manual de memória, que exige que o programador especifique quais objetos desalocar e retornar ao sistema de memória.

O processo em que uma GC recupera memória não é livre, geralmente reduz o desempenho disponível ao tomar um bloco de tempo para fazer seu trabalho. Além disso, o próprio sistema toma a decisão 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 bloqueará a execução do código até que ele seja concluído. Geralmente, a duração desse pulso é desconhecida para você; por isso, ela levará algum tempo para ser executada, dependendo de como o programa está utilizando a memória em um determinado momento.

Aplicativos de alto desempenho dependem de limites de desempenho consistentes para garantir uma experiência tranquila para os usuários. Os sistemas de coletor de lixo podem causar curto-circuito nessa meta, já que podem ser executados em horários aleatórios por durações aleatórias, consumindo o tempo disponível que o aplicativo precisa para atingir as metas de desempenho.

Reduzir a rotatividade de memória e os tributos sobre coleta de lixo

Como observado, um pulso de GC ocorrerá quando um conjunto de heurística determina que há objetos inativos suficientes para que um pulso seja benéfico. Dessa forma, a chave para reduzir o tempo que o Coletor de lixo leva do seu aplicativo é eliminar o maior número possível de casos de criação e liberação de objetos em excesso. Esse processo de criar/liberar objetos com frequência é chamado de "rotatividade de memória". Se for possível reduzir a rotatividade de memória durante a vida útil do seu aplicativo, você também reduzirá o tempo que a GC leva para execução. Isso significa que é preciso remover / reduzir o número de objetos criados e destruídos. Ou seja, você precisa parar de alocar memória.

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

Uma captura de tela da linha do tempo da memória

para isto:

JavaScript de memória estática

Nesse modelo, o gráfico não tem mais um padrão semelhante a serpentina, mas aumenta muito no início e depois aumenta lentamente. Se você está enfrentando problemas de desempenho devido à rotatividade de memória, este é o tipo de gráfico que convém criar.

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

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

  1. Instrua o aplicativo a determinar qual é o número máximo de objetos de memória em tempo real necessários (por tipo) para diversos cenários de uso
  2. Implemente novamente o código para pré-alocar essa quantidade máxima e busque/liberá-la manualmente, em vez de ir para a memória principal.

Na realidade, para alcançar o primeiro nível, é preciso fazer o segundo item, então vamos começar por aqui.

Pool de objetos

Em termos simples, o pooling de objetos é o processo de reter um conjunto de objetos não utilizados que compartilham um tipo. Quando você precisar de um novo objeto para o código, em vez de alocar um novo objeto do Heap de memória do sistema, recicla um dos objetos não utilizados do pool. Depois que o código externo é feito com o objeto, em vez de liberá-lo na memória principal, ele é retornado ao pool. Como o objeto nunca é cancelado (ou seja, excluído) do código, ele não será coletado como lixo. A utilização de pools de objetos devolve o controle da memória ao programador, reduzindo a influência do coletor de lixo no desempenho.

Como há um conjunto heterogêneo de tipos de objetos que o aplicativo mantém, o uso adequado de pools de objetos exige que você tenha um pool por tipo que apresente alta rotatividade durante o tempo de 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 algum nível em termos de necessidade de alocar novos objetos. Ao longo de várias execuções do seu aplicativo, você poderá ter uma boa 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 de um pool de objetos no projeto fornecerá um máximo teórico para o número de objetos necessários durante o tempo de execução do seu aplicativo. Depois de executar seu site em vários cenários de teste, é possível ter uma boa noção dos tipos de requisitos de memória que serão necessários, catalogar esses dados em algum lugar e analisá-los para entender quais são os limites superiores de requisitos de memória do seu aplicativo.

Em seguida, na versão para envio do app, é possível definir a fase de inicialização para preencher automaticamente todos os pools de objetos com um valor especificado. Isso empurrará toda a inicialização do objeto para a frente do seu aplicativo e reduzirá a quantidade de alocações que ocorrem dinamicamente durante a execução dele.

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 seu aplicativo. Às vezes, o máximo teórico não é a melhor opção. Por exemplo, escolher o máximo médio pode consumir menos memória para pessoas que não são usuários avançados.

Longe de uma solução perfeita

Existe uma classificação completa de apps em que os padrões de crescimento de memória estática podem ser bons. No entanto, como Renato Mangini (link em inglês) do Chrome DevRel, há algumas desvantagens.

Conclusão

Uma das razões pelas quais o JavaScript é ideal para a web depende do fato de ser uma linguagem rápida, divertida e fácil para começar. Isso se deve principalmente à baixa barreira a restrições de sintaxe e ao processamento de problemas de memória em seu nome. Você pode programar e deixar que ele faça todo o trabalho. No entanto, para aplicativos da Web de alto desempenho, como jogos HTML5, a GC muitas vezes pode consumir o frame rate extremamente necessário, reduzindo a experiência para o 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 aproveitar esse tempo para ter 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 mais uma. Em vez disso, vou mostrar cada uma delas com nuances de implementação específicas. Isso é importante, considerando que cada uso de aplicativo pode ter necessidades específicas de implementação.

Referências