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, isso não substitui o gerenciamento eficaz de memória em aplicativos. Os aplicativos JavaScript sofrem com os mesmos problemas relacionados à memória que os apps nativos, como vazamentos de memória e excesso de memória, mas também precisam lidar com pausas na coleta de lixo. Aplicativos de grande escala, como o Gmail, enfrentam os mesmos problemas que os aplicativos menores. Continue lendo para saber como a equipe do Gmail usou o Chrome DevTools para identificar, isolar e corrigir os problemas de memória.

Sessão do Google I/O 2013

Apresentamos este 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. Relatos de guias do Gmail consumindo vários gigabytes de memória em laptops e desktops com recursos limitados estavam se tornando cada vez mais frequentes, muitas vezes resultando no travamento de todo o navegador. Histórias de CPUs travadas em 100%, apps que não respondem e guias tristes do Chrome ("Ele está morto, Jim"). A equipe não sabia nem por onde começar a diagnosticar o problema, muito menos como corrigi-lo. Eles não tinham ideia de como o problema era generalizado, e as ferramentas disponíveis não eram escalonáveis para aplicativos grandes. A equipe uniu forças com as equipes do Chrome e, juntas, elas desenvolveram novas técnicas para triagem de problemas de memória, melhoraram as ferramentas existentes e permitiram a coleta de dados de memória em campo. Mas, antes de falar sobre as ferramentas, vamos abordar os conceitos básicos do gerenciamento de memória do JavaScript.

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

Antes de gerenciar a memória de forma eficaz no JavaScript, você precisa entender os conceitos básicos. Esta seção aborda os tipos primitivos, o gráfico de objetos e as definições de sobrecarga de memória em geral e de vazamento de memória em JavaScript. A memória em JavaScript pode ser conceituada como um gráfico e, por isso, a teoria dos grafos desempenha um papel no gerenciamento de memória em JavaScript e no criador de perfil de alocação heap.

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 outros valores. No gráfico de objetos, esses valores são sempre nós de folha ou de terminação, o que significa que eles 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 otimizam objetos semelhantes a matrizes e os representam internamente 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 de janela. O gerenciamento do ciclo de vida das raízes do GC não está sob seu controle, já que elas são criadas pelo navegador e destruídas quando a página é descarregada. As variáveis globais são, na verdade, propriedades em window.

Gráfico de objetos

Quando um valor se torna lixo?

Um valor se torna lixo quando não há um caminho de uma raiz até ele. Em outras palavras, começando pelas raízes e pesquisando exaustivamente todas as propriedades e variáveis de objeto que estão ativas no frame de pilha, um valor não pode ser alcançado, ele se tornou lixo.

Gráfico de lixo

O que é um vazamento de memória em JavaScript?

Um vazamento de memória em JavaScript geralmente ocorre quando há nós DOM que não podem ser acessados na árvore do DOM da página, mas ainda são referenciados por um objeto JavaScript. Embora os navegadores mais recentes estejam dificultando cada vez mais a criação inadvertida de vazamentos, ainda é mais fácil do que se imagina. Digamos que você adicione um elemento à árvore do DOM desta forma:

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

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

displayList.removeAllChildren();

Enquanto email existir, o elemento DOM referenciado por message não será removido, mesmo que agora esteja separado da árvore do DOM da página.

O que é inchaço?

Sua página está sobrecarregada quando você usa mais memória do que o necessário para uma velocidade de página ideal. Indiretamente, os vazamentos de memória também causam inchaço, mas isso não é intencional. Um cache de aplicativo que não tem limite de tamanho é uma fonte comum de excesso de memória. Além disso, sua página pode ficar sobrecarregada com 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 acontece. Durante uma coleta, toda a execução de scripts na página é suspensa enquanto os valores ativos são descobertos por uma travessia do gráfico de objetos que começa nas raízes do 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 do V8 em detalhes

Para entender melhor como a coleta de lixo acontece, vamos analisar o 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 antiga. A alocação e a coleta na 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 que ele foi alocado. Na prática, a idade de um valor geralmente é aproximada pelo número de coletas de geração jovem que ele sobreviveu. Depois que um valor fica suficientemente antigo, ele é transferido para a geração antiga.

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

Geração jovem

O heap de geração jovem no V8 é dividido em dois espaços, chamados "de" e "para". A memória é alocada do espaço de destino. A alocação é muito rápida até que o espaço de destino fique cheio, o que aciona uma coleta de geração jovem. A coleta da geração jovem primeiro troca os espaços de origem e destino. O antigo espaço de destino (agora o espaço de origem) é verificado, e todos os valores ativos são copiados para o espaço de destino ou transferidos para a geração antiga. Uma coleta típica de geração jovem leva cerca de 10 milissegundos (ms).

Intuitivamente, você deve entender que cada alocação feita pelo aplicativo aproxima você de esgotar o espaço e causar uma pausa de GC. Desenvolvedores de jogos, atenção: para garantir um tempo para a renderização do frame de 16 ms (necessário para atingir 60 quadros por segundo), o aplicativo não pode fazer alocações, porque uma única coleta de geração jovem vai consumir a maior parte do tempo para a renderização do frame.

Heap de geração jovem

Geração antiga

O heap de geração antiga no V8 usa um algoritmo de compactação de marcação para coleta. As alocações de geração antiga ocorrem sempre que um valor é transferido da geração jovem para a antiga. Sempre que ocorre uma coleta de geração antiga, uma coleta de geração jovem também é feita. O aplicativo será pausado por alguns segundos. Na prática, isso é aceitável porque as coletas de geração antiga são raras.

Resumo do 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, fica mais perto de uma pausa na coleta de lixo. As pausas na coleta de lixo podem prejudicar a experiência do aplicativo ao introduzir instabilidade. Agora que você entende como o JavaScript gerencia a memória, pode fazer as escolhas certas para seu aplicativo.

Como corrigir o Gmail

No ano passado, vários recursos e correções de bugs foram adicionados ao Chrome DevTools, tornando-o mais eficiente do que nunca. Além disso, o próprio navegador fez uma mudança importante na API performance.memory, permitindo que o Gmail e qualquer outro aplicativo coletem estatísticas de memória em campo. Com essas ferramentas incríveis, o que antes parecia uma tarefa impossível logo se tornou um jogo emocionante de rastreamento de culpados.

Ferramentas e técnicas

Dados de campo e API performance.memory

A partir do Chrome 22, a API performance.memory é ativada por padrão. Para aplicativos de longa duração, como o Gmail, os dados de usuários reais são valiosos. Com essas informações, podemos distinguir os usuários avançados (aqueles que passam de 8 a 16 horas por dia no Gmail e recebem centenas de mensagens diariamente) dos usuários comuns (que passam alguns minutos por dia no Gmail e recebem cerca de uma dúzia de mensagens por semana).

Essa API retorna três dados:

  1. jsHeapSizeLimit: a quantidade de memória (em bytes) que limita o heap do JavaScript.
  2. totalJSHeapSize: a quantidade de memória (em bytes) que o heap do JavaScript alocou, incluindo o espaço livre.
  3. usedJSHeapSize: a quantidade de memória (em bytes) em uso no momento.

É importante lembrar que a API retorna valores de memória para todo o processo do Chrome. Embora 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 app.

Como medir a memória em grande escala

O Gmail instrumentou o JavaScript para usar a API performance.memory e coletar informações de memória aproximadamente uma vez a cada 30 minutos. Como muitos usuários do Gmail deixam o app aberto por dias, a equipe conseguiu acompanhar o crescimento da memória ao longo do tempo, além de estatísticas gerais de consumo de memória. Em poucos dias após instrumentar o Gmail para coletar informações de memória de uma amostra aleatória de usuários, a equipe tinha dados suficientes para entender a extensã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 acompanhar o progresso em direção à meta de reduzir o consumo de memória. Por fim, esses dados também seriam usados para detectar regressões de memória.

Além de fins de rastreamento, as medições de campo também fornecem 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. Com essa revelação, eles ficaram mais motivados do que nunca para controlar o consumo de memória.

Como medir a memória em grande 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 forma confiável. Sem uma medição de referência, não é possível saber o quanto a performance melhorou.

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

Provar que um problema existe

Comece identificando uma sequência de ações que você suspeita que esteja 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 de baixo para forçar uma coleta de lixo completa. Se, após algumas iterações, você encontrar um gráfico em forma de dente de serra, isso significa que você está alocando muitos objetos de curta duração. Mas se a sequência de ações não resultar em nenhuma memória retida e a contagem de nós do DOM não voltar ao valor de referência inicial, há motivos para suspeitar de um vazamento.

Gráfico em forma de dente de serra

Depois de confirmar que o problema existe, você pode receber ajuda para identificar a origem dele no criador de perfil de alocação heap do DevTools.

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

O painel do Profiler oferece um CPU Profiler e um criador de perfil de alocação heap. A criação de perfil de alocação heap funciona tirando um snapshot do gráfico de objetos. Antes de um snapshot ser feito, as gerações jovem e antiga são coletadas como lixo. Em outras palavras, você só verá os valores que estavam ativos quando o snapshot foi feito.

Há muitas funcionalidades no criador de perfil de alocação heap para serem abordadas neste artigo, mas você pode encontrar uma documentação detalhada no site para desenvolvedores do Chrome. Vamos nos concentrar aqui no criador de perfil de alocação de heap.

Como usar o Heap Allocation Profiler

O criador de perfil de alocação de heap combina as informações detalhadas do snapshot do criador de perfil de alocação heap com a atualização incremental e o rastreamento do painel "Linha do tempo". Abra o painel "Profiles", inicie um perfil Record Heap Allocations, realize uma sequência de ações e interrompa a gravação para análise. O criador de perfil de alocação faz instantâneos de alocação heap periodicamente durante a gravação (com frequência de até 50 ms) e um instantâneo final no fim da gravação.

Criador de perfil de alocação de heap

As barras na parte de cima indicam quando novos objetos são encontrados no 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 alocação heap final: as barras azuis indicam objetos que ainda estão ativos no final da linha do tempo, e as barras cinzas indicam objetos que foram alocados durante a linha do tempo, mas que já foram coletados como lixo.

No exemplo acima, uma ação foi realizada 10 vezes. O programa de amostra armazena cinco objetos em cache, então as últimas cinco barras azuis são esperadas. Mas a barra azul mais à esquerda indica um possível problema. Em seguida, use os controles deslizantes na linha do tempo acima para aumentar o zoom nesse snapshot específico e ver os objetos que foram alocados recentemente naquele momento. Clique em um objeto específico no heap para mostrar a árvore de retenção na parte de baixo do instantâneo de alocação heap. Examinar o 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 mudanças 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, matrizes de callbacks que crescem infinitamente esperando que algo aconteça, mas que nunca acontece, e listeners de eventos que retêm os destinos de forma não intencional. Ao corrigir esses problemas, o uso da memória geral do Gmail foi reduzido drasticamente. Os usuários no percentil de 99% usaram 80% menos memória do que antes, e o consumo de memória dos usuários medianos caiu quase 50%.

Uso da memória do Gmail

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

Além disso, ao coletar estatísticas sobre o uso da memória, a equipe do Gmail conseguiu descobrir regressões de coleta de lixo no 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 diferença entre a memória total alocada e a memória ativa.

Call-to-action

Considere estas perguntas:

  1. Quanta memória meu app está usando? É possível que você esteja usando muita memória, o que, ao contrário do que se acredita, tem um impacto negativo no desempenho geral do aplicativo. É difícil saber exatamente qual é o número certo, mas verifique se o armazenamento em cache extra que sua página está usando tem um impacto mensurável no desempenho.
  2. Minha página está livre de vazamentos? Se a página tiver vazamentos de memória, isso pode afetar não apenas o desempenho dela, mas também de outras guias. Use o rastreador de objetos para ajudar a identificar vazamentos.
  3. Com que frequência minha página está fazendo GC? É possível ver qualquer pausa de GC usando o painel Linha do tempo nas Ferramentas para desenvolvedores do Chrome. Se a página estiver fazendo GC com frequência, é provável que você esteja alocando com muita frequência, consumindo a memória da geração jovem.

Conclusão

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