Introdução
Você recebe um e-mail dizendo que seu jogo ou app da Web está com um desempenho ruim depois de um certo tempo. Você analisa o código, não vê nada de diferente, até abrir as ferramentas de desempenho de memória do Chrome e ver isto:
Um dos seus colegas de trabalho dá uma risadinha, 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 dente de serra é muito revelador sobre um problema de desempenho potencialmente crítico. Conforme 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 de repente, é um momento em que o coletor de lixo foi executado e limpou os objetos de memória referenciados.
Em um gráfico como este, é possível ver que há muitos eventos de coleta de lixo ocorrendo, o que pode ser prejudicial ao desempenho dos seus 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 é baseado 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 de 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 os desreferencia, mas sim em um momento posterior, quando a heurística do GC decide que seria benéfico fazer isso. Esse processo de decisão exige que o GC execute uma análise estatística 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 desalocar e retornar ao sistema de memória.
O processo em que um GC recupera memória não é isento de custos. Ele geralmente reduz o desempenho disponível ao levar um bloco de tempo para realizar 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, que será bloqueada até que o pulso seja concluído. A duração desse pulso geralmente é desconhecida para você. Ele leva algum tempo para ser executado, 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 para os usuários. Os sistemas de coleta de lixo podem atrapalhar essa meta, já que podem ser executados em momentos e por durações aleatórias, consumindo o tempo disponível que o aplicativo precisa para atingir as metas de performance.
Reduzir a rotatividade de memória e 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. Assim, a chave para reduzir o tempo que o coletor de lixo leva do seu aplicativo é eliminar o máximo possível de casos de criação e liberação excessivas de objetos. Esse processo de criação/liberação frequente de objetos é chamado de "rotatividade de memória". Se você reduzir a rotatividade de memória durante o ciclo de vida do aplicativo, também vai reduzir o tempo que a coleta de lixo leva para ser executada. Isso significa que você precisa remover/reduzir o número de objetos criados e destruídos, ou seja, parar de alocar memória.
Esse processo vai mover o gráfico de memória de :
por este:
Nesse modelo, o gráfico não tem mais um padrão de dente de serra, mas cresce muito no início e depois aumenta lentamente ao longo do tempo. Se você estiver enfrentando problemas de desempenho devido à rotatividade de memória, esse é o tipo de gráfico que você vai querer criar.
Migração para o JavaScript de memória estática
O 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 durante o ciclo de vida dele, além do gerenciamento dessa memória durante a execução, quando os objetos não forem mais necessários. Podemos atingir essa meta em algumas etapas simples:
- Instrumente o aplicativo para determinar o número máximo de objetos de memória ativa necessários (por tipo) para uma variedade de cenários de uso.
- Reimplemente o código para pré-alocar essa quantidade máxima e, em seguida, busque/libere-os manualmente em vez de acessar a memória principal.
Na verdade, para realizar o item 1, precisamos fazer um pouco do item 2. Então, 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 seu código, em vez de alocar um novo do heap de memória do sistema, você recicla um dos objetos não utilizados do pool. Quando o código externo termina de usar o objeto, em vez de liberá-lo para a memória principal, ele é devolvido ao pool. Como o objeto nunca é desreferenciado (ou seja, excluído) do código, ele não será coletado como lixo. O uso 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 um 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, em algum momento você vai atingir um nível de estabilidade em termos de necessidade de alocar novos objetos. Ao executar o aplicativo várias vezes, você vai ter uma boa noção de qual é esse limite superior e poderá pré-alocar esse número de objetos no início do aplicativo.
Pré-alocação de objetos
A implementação do pool de objetos no seu projeto vai fornecer um máximo teórico para o número de objetos necessários durante o ambiente de execução do aplicativo. Depois de executar o site em vários cenários de teste, você terá uma boa noção dos tipos de requisitos de memória necessários, poderá catalogar esses dados em algum lugar e analisá-los para entender quais são os limites superiores 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 preencher automaticamente todos os pools de objetos até um valor especificado. Isso vai enviar 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 resultar em um consumo de memória menor para usuários não avançados.
Longe de ser uma solução milagrosa
Há uma classificação inteira de apps em que os padrões de crescimento de memória estática podem ser uma vantagem. No entanto, como aponta Renato Mangini, colega do Chrome DevRel, há algumas desvantagens.
Conclusão
Um dos motivos que tornam o JavaScript ideal para a Web é que ele é uma linguagem rápida, divertida e fácil de aprender. 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 pesado. No entanto, para aplicativos da Web de alto desempenho, como jogos em HTML5, o GC geralmente pode consumir o frame rate necessário, 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 na taxa de frames e recuperar esse tempo para coisas mais incríveis.
Código-fonte
Há muitas implementações de pools de objetos na Web, então não vou entediar você com mais uma. Em vez disso, vou direcionar você para estes, cada um com nuances de implementação específicas, o que é importante, considerando que cada uso de aplicativo pode ter necessidades de implementação específicas.
- Pool de objetos do Gamecore.js
- Pools de objetos do Beej (em inglês)
- Pool de objetos super simples do Emehrkay (em inglês)
- Pool de objetos focado em jogos de Steven Lambert
- Configuração do objectPool do RenderEngine