Gerenciamento eficaz da memória na escala do Gmail

John McCutchan
John McCutchan
Loreena Lee
Loreena Lee

Introdução

Embora o JavaScript use a coleta de lixo para o gerenciamento automático de memória, ele não substitui o gerenciamento de memória eficaz nos aplicativos. Os aplicativos JavaScript têm os mesmos problemas relacionados à memória que os aplicativos nativos, como vazamentos de memória e ocupação excessiva. No entanto, eles também precisam lidar com pausas na coleta de lixo. Aplicativos de grande escala, como o Gmail, encontram os mesmos problemas enfrentados por aplicativos menores. Continue lendo para saber como a equipe do Gmail usou o Chrome DevTools para identificar, isolar e corrigir problemas de memória.

Sessão do Google I/O 2013

Apresentamos esse material no Google I/O 2013. Confira o vídeo abaixo:

Gmail, temos um problema...

A equipe do Gmail estava enfrentando um problema sério. Histórias de que as guias do Gmail consumiam vários gigabytes de memória em laptops e desktops com recursos limitados eram ouvidas cada vez mais frequentemente, muitas vezes com a conclusão de que todo o navegador seria descontinuado. Histórias de CPUs fixadas em 100%, apps que não respondem e guias tristes do Chrome ("Ele está morto, Jim."). A equipe não sabia como começar a diagnosticar o problema e muito menos para corrigi-lo. Eles não tinham ideia de como o problema era generalizado, e as ferramentas disponíveis não eram escalonadas para aplicativos grandes. A equipe uniu forças com as equipes do Chrome e, juntas, desenvolveram novas técnicas para fazer a triagem de problemas de memória, aprimoraram as ferramentas existentes e possibilitaram a coleta de dados sobre memória em campo. Mas, antes de começar a usar as ferramentas, vamos abordar os fundamentos do gerenciamento de memória JavaScript.

Noções básicas de gerenciamento de memória

Antes de gerenciar efetivamente a memória no JavaScript, é preciso entender os princípios básicos. Esta seção vai abordar tipos primitivos, o gráfico de objetos e fornecer definições para sobrecarga de memória em geral e um vazamento de memória no JavaScript. A memória em JavaScript pode ser conceituada como um gráfico e, por causa dessa teoria dos gráficos, desempenha um papel no gerenciamento de memória JavaScript e no Heap Profiler.

Tipos primitivos

O JavaScript tem três tipos primitivos:

  1. Número (por exemplo, 4, 3,14159)
  2. Booleano (verdadeiro ou falso).
  3. String ("Hello World")

Esses tipos primitivos não podem referenciar nenhum outro valor. No gráfico de objetos, esses valores são sempre nós de folha ou de encerramento, o que significa que nunca têm uma borda de saída.

Há apenas um tipo de contêiner: o objeto. Em JavaScript, o objeto é uma matriz associativa. Um objeto não vazio é um nó interno com arestas de saída para outros valores (nós).

E as matrizes?

Uma matriz em JavaScript é, na verdade, um objeto que tem chaves numéricas. Essa é uma simplificação, porque os ambientes de execução do JavaScript vão otimizar objetos semelhantes a matrizes e os representar em segundo plano como matrizes.

Terminologia

  1. Valor: uma instância de um tipo primitivo, objeto, matriz etc.
  2. Variável: um nome que faz referência a um valor.
  3. Propriedade - um nome em um objeto que faz referência a um valor.

Gráfico de objetos

Todos os valores em JavaScript fazem parte do gráfico de objetos. O gráfico começa com raízes, por exemplo, o objeto window. Gerenciar a vida útil das raízes de GC não está sob seu controle, pois elas são criadas pelo navegador e destruídas quando a página é descarregada. Variáveis globais são, na verdade, propriedades na janela.

Gráfico de objetos

Quando um valor se torna lixo?

Um valor se torna lixo quando não há caminho da raiz para o valor. Em outras palavras, não é possível alcançar um valor porque ele se tornou um lixo, começando pelas raízes e fazendo a pesquisa exaustiva de todas as propriedades e variáveis do Objeto ativas no frame da pilha.

Gráfico de lixo

O que é vazamento de memória em JavaScript?

Um vazamento de memória no JavaScript ocorre com mais frequência quando há nós do DOM que não podem ser acessados pela árvore DOM da página, mas que ainda são referenciados por um objeto JavaScript. Embora os navegadores modernos estejam dificultando cada vez mais a criação acidental de vazamentos, isso ainda é mais fácil do que se pode imaginar. Vamos supor que você anexe um elemento à árvore do DOM da seguinte forma:

email.message = document.createElement("div");
displayList.appendChild(email.message);

Depois, você remove o elemento da lista de exibição:

displayList.removeAllChildren();

Se email existir, o elemento DOM referenciado pela mensagem não será removido, mesmo que agora esteja desconectado da árvore DOM da página.

O que é inchaço?

Sua página fica exagerada quando você está usando mais memória do que o necessário para ter a velocidade ideal. Indiretamente, vazamentos de memória também causam sobrecarga, mas isso não foi projetado por padrão. Um cache de aplicativo sem limite de tamanho é uma fonte comum de sobrecarga da memória. Além disso, sua página pode ficar cheia de dados do host, por exemplo, dados de pixel carregados de imagens.

O que é a coleta de lixo?

A coleta de lixo é como a memória é recuperada no JavaScript. O navegador decide quando isso acontecerá. Durante uma coleta, toda execução de script na sua página é suspensa enquanto os valores ativos são descobertos por uma travessia do gráfico de objetos começando nas raízes de GC. Todos os valores que não são acessíveis são classificados como lixo. A memória para valores de lixo é recuperada pelo gerenciador de memória.

Coletor de lixo V8 em detalhes

Para entender melhor como a coleta de lixo acontece, vamos dar uma olhada no coletor de lixo do V8 em detalhes. O V8 usa um coletor geracional. A memória é dividida em duas gerações: a jovem e a velha. A alocação e a coleta dentro da geração mais jovem são rápidas e frequentes. A alocação e a coleta na geração antiga são mais lentas e menos frequentes.

Coletor geracional

O V8 usa um coletor de duas gerações. A idade de um valor é definida como o número de bytes alocados desde a alocação. Na prática, a idade de um valor geralmente é aproximada pelo número de coleções da geração jovem às quais ele sobreviveu. Depois que um valor é suficientemente antigo, ele permanece na geração antiga.

Na prática, os valores recém-alocados não duram muito tempo. Um estudo dos programas Smalltalk mostrou que apenas 7% dos valores sobrevivem após uma coleção de uma geração jovem. Estudos semelhantes em ambientes de execução descobriram que, em média, 90% e 70% dos valores recém-alocados nunca são mantidos na geração antiga.

Geração jovem

O heap da geração mais jovem no V8 é dividido em dois espaços, chamados "de" e "to". A memória é alocada do para o espaço. A alocação é muito rápida até que o espaço esteja cheio e uma coleção de geração jovem é acionada. A coleção jovem da geração primeiro troca o de e para o espaço, o antigo para o espaço (agora do espaço) é digitalizado e todos os valores ativos são copiados para o espaço ou mantidos na geração antiga. Uma coleção típica de geração jovem leva cerca de 10 milissegundos (ms).

Intuitivamente, você deve entender que cada alocação que seu aplicativo faz aproxima você do esgotamento do espaço e incorre em uma pausa da GC. Desenvolvedores de jogos, observem: para garantir um tempo de renderização de 16 ms (necessário para atingir 60 quadros por segundo), o aplicativo não pode fazer alocações, porque uma única coleção de geração jovem consumirá a maior parte do tempo de renderização de frames.

Heap da geração jovem

Geração antiga

O heap de geração antiga no V8 usa um algoritmo mark-compact para coleta. As alocações de geração antiga ocorrem sempre que um valor é mantido da geração jovem para a geração antiga. Sempre que ocorre uma coleção de uma geração antiga, uma coleção de uma geração mais jovem também é feita. Seu aplicativo será pausado em segundos. Na prática, isso é aceitável porque as coleções da geração antiga não são frequentes.

Resumo de GC do V8

O gerenciamento automático de memória com coleta de lixo é ótimo para a produtividade do desenvolvedor, mas cada vez que você aloca um valor, você se aproxima cada vez mais de uma pausa na coleta de lixo. As pausas da coleta de lixo podem prejudicar a aparência do aplicativo ao introduzir instabilidade. Agora que você sabe como o JavaScript gerencia a memória, é possível fazer as escolhas certas para seu aplicativo.

Como corrigir o Gmail

Ao longo do ano passado, vários recursos e correções de bugs entraram no Chrome DevTools, tornando-o mais poderoso do que nunca. Além disso, o próprio navegador fez uma alteração importante na API performance.memory, possibilitando que o Gmail e qualquer outro aplicativo coletassem estatísticas de memória do campo. Municiado com essas ferramentas incríveis, o que parecia uma tarefa impossível logo se tornou um jogo emocionante de encontrar culpados.

Ferramentas e técnicas

Dados de campo e API performance.memory

A partir do Chrome 22, a API performance.memory será ativada por padrão. Para aplicativos de longa duração, como o Gmail, os dados de usuários reais são inestimáveis. Essas informações nos permitem distinguir entre usuários avançados (que passam de 8 a 16 horas por dia no Gmail e recebem centenas de mensagens por dia) de usuários mais comuns que passam alguns minutos por dia no Gmail e recebem dezenas de mensagens por semana.

Essa API retorna três dados:

  1. jsHeapSizeLimit - a quantidade de memória (em bytes) à qual a pilha JavaScript está limitada.
  2. totalJSHeapSize - A quantidade de memória (em bytes) que a heap JavaScript alocou, incluindo espaço livre.
  3. usedJSHeapSize - A quantidade de memória (em bytes) atualmente sendo utilizada.

Lembre-se de que a API retorna valores de memória para todo o processo do Chrome. Embora esse não seja o modo padrão, em determinadas circunstâncias, o Chrome pode abrir várias guias no mesmo processo do renderizador. Isso significa que os valores retornados por performance.memory podem conter o consumo de memória de outras guias do navegador, além daquela que contém seu aplicativo.

Medição de memória em escala

O Gmail instrumentou o JavaScript para usar a API performance.memory para coletar informações de memória, aproximadamente uma vez a cada 30 minutos. Como muitos usuários do Gmail deixam o aplicativo ativo por dias, a equipe conseguiu monitorar o crescimento da memória ao longo do tempo, assim como as estatísticas gerais de consumo de memória. Poucos dias após a instrumentação do Gmail para coletar informações de memória de uma amostragem aleatória de usuários, a equipe tinha dados suficientes para entender a generalização dos problemas de memória entre os usuários comuns. Eles definiram um valor de referência e usaram o fluxo de dados recebidos para monitorar o progresso em direção ao objetivo de reduzir o consumo de memória. Esses dados também seriam usados para capturar regressões de memória.

Além de fins de rastreamento, as medições de campo também oferecem insights sobre a correlação entre o consumo de memória e o desempenho do aplicativo. Ao contrário da crença popular de que "mais memória resulta em melhor desempenho", a equipe do Gmail descobriu que quanto maior o consumo de memória, maiores as latências para ações comuns do Gmail. Municiados com essa revelação, eles estavam mais motivados do que nunca a controlar seu consumo de memória.

Medição de memória em escala

Como identificar um problema de memória com a linha do tempo do DevTools

A primeira etapa para resolver qualquer problema de desempenho é provar que ele existe, criar um teste reproduzível e fazer uma medição de referência do problema. Sem um programa reproduzível, não é possível medir o problema de maneira confiável. Sem uma medição de linha de base, você não saberá o quanto melhorou a performance.

O painel Timeline do DevTools é um candidato ideal para provar que o problema existe. Ele oferece uma visão geral completa de onde é gasto o tempo ao carregar e interagir com seu aplicativo ou página da Web. Todos os eventos, do carregamento de recursos à análise de JavaScript, cálculo de estilos, pausas na coleta de lixo e repintura, são representados em uma linha do tempo. Para investigar problemas de memória, o painel Linha do tempo também tem um modo de memória que rastreia o total de memória alocada, o número de nós DOM, o número de objetos de janela e o número de listeners de eventos alocados.

Como provar que um problema existe

Comece identificando uma sequência de ações que você suspeita estar vazando memória. Comece a gravar a linha do tempo e execute a sequência de ações. Use o botão de lixeira na parte inferior para forçar uma coleta de lixo completa. Se, após algumas iterações, você vir um gráfico em forma de sawtooth, isso significa que está alocando muitos objetos de vida curta. Mas se não se esperar que a sequência de ações resulte em qualquer memória retida e a contagem de nós DOM não voltar ao valor de referência onde você começou, você tem um bom motivo para suspeitar que há um vazamento.

Gráfico em forma de dente

Depois de confirmar que o problema existe, você pode receber ajuda para identificar a origem dele no Criador de perfil de pilha do DevTools.

Como encontrar vazamentos de memória com o criador de perfil de heap do DevTools

O painel do Profiler fornece um CPU Profiler e um criador de perfil de heap. A criação de perfil de heap funciona com um snapshot do gráfico do objeto. Antes da captura do instantâneo, tanto as gerações jovem quanto a antiga são coletadas como lixo. Em outras palavras, você só verá valores que estavam ativos quando o snapshot foi capturado.

Há muita funcionalidade no criador de perfil de alocação heap que não abordamos suficientemente neste artigo, mas a documentação detalhada pode ser encontrada no site para desenvolvedores do Chrome. Aqui, vamos nos concentrar no criador de perfil para alocação de heap.

Como usar o criador de perfil de alocação de heap

O criador de perfil de alocação de heap combina as informações detalhadas do instantâneo do criador de perfil de heap com a atualização e o rastreamento incrementais do painel da linha do tempo. Abra o painel "Profiles", inicie um perfil Record Heap Allocations, execute uma sequência de ações e interrompa a gravação para análise. O criador de perfil de alocação cria snapshots de heap periodicamente durante a gravação (a cada 50 ms) e um snapshot final ao final da gravação.

Criador de perfil de alocação de heap

As barras na parte superior indicam quando novos objetos são encontrados na heap. A altura de cada barra corresponde ao tamanho dos objetos alocados recentemente e a cor das barras indica se esses objetos ainda estão ativos no instantâneo de heap final: as barras azuis indicam objetos que ainda estão ativos no final da linha do tempo, as barras cinza indicam objetos que foram alocados durante a linha do tempo, mas foram coletados como lixo.

No exemplo acima, uma ação foi realizada 10 vezes. O programa de exemplo armazena cinco objetos em cache, portanto, as últimas cinco barras azuis são esperadas. Mas a barra azul mais à esquerda indica um possível problema. Você pode usar os controles deslizantes na linha do tempo acima para aumentar o zoom nesse instantâneo específico e ver os objetos que foram alocados recentemente naquele momento. Clicar em um objeto específico na heap mostrará sua árvore de retenção na parte inferior do instantâneo da heap. A análise do caminho de retenção para o objeto deve fornecer informações suficientes para entender por que o objeto não foi coletado, e você pode fazer as alterações necessárias no código para remover a referência desnecessária.

Como resolver a crise de memória do Gmail

Usando as ferramentas e técnicas discutidas acima, a equipe do Gmail conseguiu identificar algumas categorias de bugs: caches ilimitados, matriz infinita de callbacks esperando por algo que nunca acontece e ouvintes de eventos retendo seus alvos acidentalmente. Com a correção desses problemas, o uso geral da memória no Gmail foi reduzido drasticamente. Os usuários desses 99% usaram 80% menos memória do que antes, e o consumo de memória dos usuários médios caiu em quase 50%.

Uso da memória do Gmail

Como o Gmail usou menos memória, a latência de pausa da GC foi reduzida, melhorando a experiência geral do usuário.

Além disso, como a equipe do Gmail coletava estatísticas sobre o uso de memória, foi possível descobrir regressões de coleta de lixo dentro do Chrome. Especificamente, dois bugs de fragmentação foram descobertos quando os dados de memória do Gmail começaram a mostrar um aumento drástico na lacuna entre a memória total alocada e a memória ativa.

Call-to-action

Considere o seguinte:

  1. Quanta memória meu app está usando? É possível que você esteja usando muita memória, o que, ao contrário da crença popular, tem um resultado negativo para o desempenho geral do aplicativo. É difícil saber exatamente qual é o número correto, mas verifique se qualquer armazenamento em cache extra que sua página usa tem um impacto mensurável no desempenho.
  2. Minha página não tem vazamento? Se sua página tiver vazamentos de memória, isso poderá afetar não apenas o desempenho dela, mas também outras guias. Use o rastreador de objetos para ajudar a restringir quaisquer vazamentos.
  3. Com que frequência minha página recebe GC? É possível conferir qualquer pausa do GC usando o painel da linha do tempo nas Ferramentas para desenvolvedores do Chrome. Se sua página recebe GCs com frequência, é provável que você esteja alocando com muita frequência, apagando a memória da sua geração mais jovem.

Conclusão

Começamos em uma crise. Abordamos os fundamentos básicos de gerenciamento de memória em JavaScript e V8 em particular. Você aprendeu a usar as ferramentas, incluindo o novo recurso de rastreador de objetos disponível nas versões mais recentes do Chrome. A equipe do Gmail, equipada com esse conhecimento, resolveu seu problema de uso de memória e notou uma melhora no desempenho. Você pode fazer o mesmo com seus apps da Web.