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 eficaz de memória em aplicativos. Os aplicativos JavaScript sofrem dos mesmos problemas relacionados à memória que os aplicativos nativos, como vazamentos e inchaço de memória, mas também precisam lidar com pausas de 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 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. As anedotas de guias do Gmail que consumiam vários gigabytes de memória em laptops e desktops com recursos limitados eram cada vez mais frequentes, muitas vezes com a conclusão de que o navegador inteiro estava sendo desativado. Histórias de CPUs fixadas em 100%, apps que não respondem e guias tristes do Chrome ("Ele morreu, Jim"). A equipe não sabia nem por onde começar a diagnosticar o problema, muito menos corrigi-lo. Eles não tinham ideia de como o problema era generalizado e as ferramentas disponíveis não eram dimensionadas para grandes aplicativos. A equipe se uniu às 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 no campo. Mas, antes de chegar às ferramentas, vamos abordar os conceitos básicos do gerenciamento de memória do JavaScript.

Conceitos básicos de gerenciamento de memória

Antes de gerenciar a memória de forma eficaz em JavaScript, é preciso entender os fundamentos. Esta seção vai abordar tipos primitivos, o gráfico de objetos e fornecer definições para o inchaço 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 isso, a teoria de grafos desempenha um papel no gerenciamento de memória do 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 outros valores. No gráfico de objetos, esses valores são sempre folhas ou nós terminais, o que significa que eles nunca têm uma aresta de saída.

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

E as matrizes?

Uma matriz em JavaScript é, na verdade, um objeto com chaves numéricas. Essa é uma simplificação, porque os ambientes de execução do JavaScript vão otimizar objetos semelhantes a matrizes e representá-los 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. Não é possível gerenciar a vida útil das raízes do GC, porque elas são criadas pelo navegador e destruídas quando a página é descarregada. As variáveis globais são propriedades na janela.

Gráfico de objetos

Quando um valor se torna lixo?

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

Gráfico de lixo

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

Um vazamento de memória no JavaScript geralmente ocorre quando há nós do DOM que não podem ser acessados pela árvore do DOM da página, mas ainda são referenciados por um objeto JavaScript. Embora os navegadores modernos estejam tornando cada vez mais difícil criar vazamentos acidentalmente, ainda é mais fácil do que se pensa. Digamos que você anexe um elemento à árvore DOM assim:

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

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

displayList.removeAllChildren();

Enquanto email existir, o elemento DOM referenciado pela mensagem não será removido, mesmo que seja removido da árvore DOM da página.

O que é bloat?

A página está inflada quando você usa mais memória do que o necessário para ter a maior velocidade. Indiretamente, os vazamentos de memória também causam inchaço, mas isso não é intencional. Um cache de aplicativo sem limite de tamanho é uma fonte comum de aumento de memória. Além disso, sua página pode ficar inchada por dados do host, por exemplo, dados de pixels carregados de imagens.

O que é a coleta de lixo?

A coleta de lixo é como a memória é recuperada em JavaScript. O navegador decide quando isso acontece. Durante uma coleta, toda a execução de script na página é suspensa enquanto os valores em tempo real são descobertos por uma travessia do gráfico de objetos, começando 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 V8 em detalhes

Para entender melhor como a coleta de lixo acontece, vamos analisar o coletor de lixo V8 em detalhes. O V8 usa um coletor de gerações. A memória é dividida em duas gerações: a jovem e a velha. 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.

Generational Collector

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 é frequentemente aproximada pelo número de coleções de geração jovem que ele sobreviveu. Depois que um valor fica suficientemente antigo, ele é mantido na 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 mantidos na geração antiga.

Young Generation

A pilha de geração jovem no V8 é dividida em dois espaços, chamados de "from" e "to". A memória é alocada do espaço para o espaço. A alocação é muito rápida, até que o espaço esteja cheio e uma coleta de geração jovem seja acionada. A coleção de geração jovem primeiro troca os espaços de origem e de 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 mantidos na geração antiga. Uma coleta de geração jovem típica leva cerca de 10 milissegundos (ms).

Intuitivamente, você precisa entender que cada alocação feita pelo seu aplicativo aproxima você de esgotar o espaço e incorrer em uma pausa do GC. Desenvolvedores de jogos, atenção: para garantir um tempo de frame de 16 ms (necessário para alcançar 60 frames por segundo), seu aplicativo precisa fazer zero alocações, porque uma única coleção de geração jovem consome a maior parte do tempo de frame.

Heap de geração jovem

Geração antiga

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

Resumo do GC do V8

O gerenciamento automático de memória com a coleta de lixo é ótimo para a produtividade do desenvolvedor, mas, sempre que você aloca um valor, se aproxima cada vez mais de uma pausa na coleta de lixo. As pausas de coleta de lixo podem arruinar a sensação do seu aplicativo, introduzindo o jank. 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 poderoso 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 colete estatísticas de memória do campo. Com essas ferramentas incríveis, o que antes parecia uma tarefa impossível logo se tornou um jogo emocionante de encontrar os culpados.

Ferramentas e técnicas

Dados de campo e API performance.memory

A partir do Chrome 22, a API performance.memory fica ativada por padrão. Para aplicativos de longa duração, como o Gmail, os dados de usuários reais são inestimáveis. Com essas informações, podemos distinguir 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 cerca de uma dúzia de mensagens por semana.

Essa API retorna três tipos de dados:

  1. jsHeapSizeLimit: é a quantidade de memória (em bytes) a que a pilha de JavaScript está limitada.
  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) que está sendo usada.

Uma coisa a se 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 de renderizador. Isso significa que os valores retornados por performance.memory podem conter a pegada de memória de outras guias do navegador, além da que contém o 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 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, bem como as estatísticas gerais da pegada de memória. Alguns dias depois de instrumentar o Gmail para coletar informações de memória de uma amostragem aleatória de usuários, a equipe tinha dados suficientes para entender como os problemas de memória estavam generalizados entre os usuários comuns. Eles definiram uma meta e usaram o fluxo de dados recebidos para acompanhar o progresso em direção ao objetivo de reduzir o consumo de memória. Eventualmente, esses dados também seriam usados para detectar regressões de memória.

Além dos fins de rastreamento, as medições em campo também fornecem um insight importante sobre a correlação entre a pegada 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 a pegada de memória, mais longa é a latência para ações comuns do Gmail. Com essa revelação, eles estavam 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, você não sabe o quanto melhorou a performance.

O painel da linha do tempo do DevTools é o candidato ideal para provar que o problema existe. Ele fornece uma visão geral completa de onde o tempo é gasto ao carregar e interagir com seu app ou página da Web. 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 representados em uma linha do tempo. Para investigar problemas de memória, o painel "Timeline" também tem um modo de memória que rastreia a memória alocada total, 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 há um problema

Comece identificando uma sequência de ações que você suspeita que estejam vazando memória. Comece a gravar a linha do tempo e realize a sequência de ações. Use o botão da lixeira na parte de baixo para forçar uma coleta de lixo completa. Se, após algumas iterações, você notar um gráfico em forma de dente de serra, significa que está alocando muitos objetos de curta duração. No entanto, 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 para a linha de base em que você começou, você terá bons 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 Heap Profiler do DevTools.

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

O painel do Profiler oferece um criador de perfil de CPU e um criador de perfil de alocação heap. O perfil de heap funciona fazendo um snapshot do gráfico de objetos. Antes de tirar um snapshot, as gerações mais novas e mais velhas são coletadas. Em outras palavras, você só vai encontrar valores que estavam ativos quando o snapshot foi feito.

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

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

O Heap Allocation Profiler combina as informações detalhadas do Heap Profiler com a atualização e o rastreamento incrementais do painel da Linha do tempo. Abra o painel "Perfis", inicie um perfil de Gravar alocações de heap, realize uma sequência de ações e interrompa a gravação para análise. O criador de perfil de alocação gera periodicamente instantâneos da pilha durante toda a gravação (a intervalos de 50 ms!) e um instantâneo final ao final da gravação.

Criador de perfil de alocação heap

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

No exemplo acima, uma ação foi realizada 10 vezes. O programa de exemplo armazena em cache cinco objetos, portanto, as últimas cinco barras azuis são normais. 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 conferir os objetos alocados recentemente naquele momento. Um clique em um objeto específico da pilha vai mostrar a árvore de retenção na parte de baixo do snapshot da pilha. Examinar o caminho de retenção para o objeto deve dar a você informações suficiente para entender por que o objeto não foi coletado, e, assim, será possível fazer as mudanças adequadas no código para remover a referência desnecessárias.

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 em crescimento infinito aguardando algo que nunca acontece e listeners de eventos que retêm involuntariamente os alvos. Com a correção desses problemas, o uso geral de memória do Gmail foi drasticamente reduzido. Os usuários no percentil 99% usaram 80% menos memória do que antes, e o consumo de memória dos usuários medianos caiu quase 50%.

Uso de memória do Gmail

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

Além disso, com a equipe do Gmail coletando estatísticas sobre o uso da memória, foi possível 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 lacuna entre a memória total alocada e a memória ativa.

Call-to-action

Faça estas perguntas:

  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 efeito 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 usa tem um impacto mensurável na performance.
  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 outras guias. Use o rastreador de objetos para ajudar a restringir os vazamentos.
  3. Com que frequência minha página está fazendo GC? É possível conferir qualquer pausa de GC usando o painel da 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 de geração jovem.

Conclusão

Começamos com uma crise. Cobriu os conceitos 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. 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.