Coleta de lixo na V8: como o novo GC Orinoco funciona

Para ser honesto, este é um dos artigos mais brutais que li recentemente: há muito sobre a morte em tenra idade, sobre perseguição de uma área da memória para outra e sobre uma feroz luta pela produtividade. Em geral, bem-vindo ao kat - há uma tradução de um excelente artigo de Peter Marshall sobre como a coleta de lixo funciona na V8 hoje.



Nos últimos anos, a abordagem da coleta de lixo na V8 mudou muito. Como parte do projeto Orinoco, ele passou de uma abordagem consistente de parar o mundo para abordagens paralelas e competitivas com fallback incremental.

Nota: se você preferir assistir ao relatório do que ler o artigo, pode fazê-lo aqui . Caso contrário, continue a ler.

Qualquer coletor de lixo possui um conjunto de tarefas que precisam ser executadas periodicamente:

  1. Encontre objetos vivos / mortos na memória.
  2. Reutilize a memória ocupada por objetos mortos.
  3. Memória compacta / desfragmentada (opcional).

Essas tarefas podem ser executadas sequencialmente ou você pode alternar. A maneira mais fácil é interromper a execução do JavaScript e fazer tudo sequencialmente no encadeamento principal. No entanto, isso pode levar a atrasos, sobre os quais falamos em postagens anteriores , bem como a uma diminuição no desempenho do programa em geral.

GC principal (marca compacta completa)


O GC principal coleta lixo de toda a pilha.

A limpeza do lixo ocorre em três etapas: rotulagem, descarte e compactação

Marcação


Determinar de quais objetos você pode liberar memória é uma parte essencial do coletor de lixo. Ele considera o objeto vivo com base em informações sobre sua acessibilidade. Isso significa que qualquer objeto referenciado no tempo de execução atual deve ser armazenado na memória e todos os objetos inacessíveis podem ser montados pelo GC.

A marcação é o processo de localização de objetos alcançáveis. O GC possui um conjunto de ponteiros com os quais começa a pesquisar, o chamado conjunto raiz. Este conjunto inclui objetos da pilha de execução atual e objeto global. Começando com este conjunto, o GC segue cada ponteiro para um objeto JavaScript e marca cada um como alcançável, após o que se move para ponteiros de objetos para outros objetos e repete esse processo recursivamente até que todos os objetos acessíveis sejam marcados.

Eliminação


O descarte é um processo no qual as áreas de memória restantes de objetos mortos são inseridas em uma lista chamada lista livre. Após a conclusão do processo de marcação, o GC encontra essas áreas e as adiciona à lista apropriada. As listas gratuitas diferem entre si em que tamanho as áreas de memória estão armazenadas nelas, o que permite encontrar rapidamente a correta. Posteriormente, quando queremos alocar memória, procuraremos em uma das listas e encontraremos uma seção de tamanho adequado.

Seal


Além disso, o GC principal às vezes toma decisões sobre a limpeza / compactação de algumas páginas de memória com base em suas próprias estimativas heurísticas, com base no grau de fragmentação da página. Você pode pensar em compactação como um análogo de desfragmentar um disco rígido em PCs mais antigos. Copiamos os objetos sobreviventes para outras páginas que ainda não foram compactadas (aqui apenas use a lista livre). Assim, podemos reutilizar os pequenos pedaços de memória dispersos que sobraram dos objetos mortos.

Uma das desvantagens do GC que copia objetos sobreviventes é que, quando você cria muitos objetos de vida longa, precisa pagar um preço alto por copiá-los. É por esse motivo que apenas algumas páginas de memória altamente fragmentadas são compactadas, enquanto as demais são simplesmente descartadas, o que não requer cópia de objetos sobreviventes.

Dispositivo de geração de memória


A pilha na V8 é dividida em áreas chamadas gerações. Há uma geração jovem (que por sua vez é subdividida em geração "intermediária" e "intermediária") e gerações antigas. Objetos criados são colocados na "manjedoura". Posteriormente, se sobreviverem à próxima coleta de lixo, permanecerão na geração mais jovem, mas passarão para a categoria "intermediário". Se eles sobreviverem após a próxima assembléia, serão colocados na geração mais antiga.

Um monte de V8 é dividido em gerações. Objetos passam de mais jovens para mais velhos se sobreviverem à coleta de lixo

Na coleta de lixo, existe o importante termo "hipótese geracional". Em termos simples, isso significa que a maioria dos objetos "morre jovem". Em outras palavras, a maioria dos objetos é criada e morre quase imediatamente do ponto de vista do GC. E esta afirmação é verdadeira não apenas para JavaScript, mas para as linguagens de programação mais dinâmicas.

A organização de heap na V8 é baseada na hipótese acima. Por exemplo, à primeira vista, pode parecer contra-intuitivo que o GC esteja compactando / movendo objetos que sobreviveram à coleta de lixo, porque copiar objetos é uma operação bastante cara para executar durante a coleta de lixo. Mas, com base na hipótese geracional, sabemos que muito poucos objetos sobreviverão a esse procedimento. Portanto, se você mover apenas objetos sobreviventes, tudo o que não foi movido poderá ser automaticamente considerado lixo. Isso significa que o preço que pagamos pela cópia é proporcional ao número de objetos sobreviventes, e nem todos criados.

GC auxiliar (limpador)


Na verdade, existem dois coletores de lixo na V8. O principal (mark-compact) coleta o lixo com bastante eficiência de toda a pilha, enquanto o secundário coleta o lixo apenas em uma memória jovem, porque a hipótese de geração nos diz que os principais esforços de coleta de lixo devem ser direcionados para lá.

O princípio operacional do GC auxiliar é que os objetos sobreviventes sempre se movem para uma nova página de memória. Na V8, a memória jovem é dividida em duas metades. Um é sempre livre para permitir que objetos sobreviventes sejam movidos para ele e, durante a montagem, essa área inicialmente vazia é chamada de To-space. A área da qual a cópia ocorre é denominada From-space. Na pior das hipóteses, todos os objetos podem sobreviver e, em seguida, você deve copiá-los todos.

Para esse tipo de montagem, existe um conjunto separado de ponteiros que se referem da memória antiga para a jovem. E, em vez de varrer toda a pilha, usamos barreiras de gravação para manter esse conjunto. Assim, combinando esse conjunto com uma pilha e um objeto global, obtemos todos os links na memória jovem sem precisar varrer todos os objetos da memória antiga.

Ao copiar objetos do espaço para o espaço, todos os objetos restantes são colocados em uma seção contínua da memória. Assim, é possível se livrar da fragmentação - falhas de memória deixadas por objetos mortos. Após a conclusão da transferência, o To-space torna-se From-space e vice-versa. Assim que o GC terminar seu trabalho, a memória para novos objetos será alocada a partir do primeiro endereço livre no From-space.

O limpador transfere objetos sobreviventes para uma nova página de memória

Se você usar apenas essa estratégia e não mover objetos da memória jovem, a memória terminará rapidamente. Portanto, objetos que sobreviveram a duas coletas de lixo são movidos para a memória antiga.

A etapa final é atualizar os ponteiros para objetos que foram movidos. Cada objeto copiado deixa seu endereço original, deixando o endereço de encaminhamento, o que é necessário para encontrar o objeto original no futuro.

O limpador transfere objetos "intermediários" para a memória antiga e objetos da "manjedoura" - para uma nova página

Assim, a coleta de lixo na memória jovem consiste em três etapas: marcar objetos, copiá-los, atualizar ponteiros.

Orinoco


A maioria desses algoritmos é descrita em várias fontes e geralmente é usada em ambientes de tempo de execução que suportam a coleta automática de lixo. Mas o GC no V8 percorreu um longo caminho antes de se tornar uma ferramenta verdadeiramente moderna. Uma das métricas significativas que descrevem seu desempenho é com que frequência e por quanto tempo o encadeamento principal é interrompido enquanto o coletor de lixo executa suas funções. Para os construtores clássicos de parar o mundo, esse tempo deixa sua marca na experiência de usar a página devido a atrasos, renderização de baixa qualidade e aumento do tempo de resposta.

Orinoco GC V8 Logo

Orinoco é o nome de código do GC usando técnicas avançadas de coleta de lixo paralela, incremental e competitiva. Existem alguns termos que têm significados específicos no contexto do GC, então vamos primeiro dar suas definições.

Paralelismo


Paralelismo é quando os encadeamentos principal e auxiliar realizam aproximadamente a mesma quantidade de trabalho por unidade de tempo. Essa ainda é a abordagem de parar o mundo, mas a duração da pausa neste caso é dividida pelo número de threads que participam do trabalho (menos o custo da sincronização).

Essa é a mais simples das três técnicas. O heap não muda porque o JavaScript não é executado, portanto, é suficiente que os threads mantenham a sincronização do acesso aos objetos.

Threads principal e auxiliar trabalham na mesma tarefa ao mesmo tempo

Incrementalidade


Incrementalidade é quando o thread principal faz uma pequena quantidade de trabalho intermitentemente. Em vez de coleta de lixo completa, pequenas tarefas para coleta parcial são concluídas.

Essa é uma tarefa mais difícil, porque o JavaScript é executado entre montagens incrementais, o que significa que o estado do heap muda, o que por sua vez pode invalidar parte do trabalho realizado na iteração anterior.

Como pode ser visto no diagrama, essa abordagem não reduz a quantidade total de trabalho (e, em regra, até aumenta), mas distribui esse trabalho no tempo. Portanto, essa é uma boa maneira de resolver uma das principais tarefas - reduzindo o tempo de resposta do fluxo principal.
Ao permitir que o JavaScript seja executado com pouca interrupção na coleta de lixo, o aplicativo pode continuar respondendo: responda à entrada do usuário e atualize as animações.

Pequenas áreas do GC trabalham no segmento principal

Competitividade


A competição ocorre quando o encadeamento principal executa o JavaScript continuamente e os encadeamentos auxiliares coletam lixo em segundo plano. Essa é a mais difícil das três técnicas: o heap pode mudar a qualquer momento, invalidando o trabalho feito pelo GC antes.

Além disso, também existem corridas de leitura / gravação, pois os fluxos auxiliar e principal lêem ou modificam simultaneamente os mesmos objetos.

A montagem ocorre completamente em segundo plano, o thread principal no momento pode executar JavaScript

Status do GC na V8


Limpeza


O V8 distribui o trabalho de coleta de lixo entre os threads auxiliares na memória jovem (eliminação). Cada thread recebe um conjunto de ponteiros, após o qual move todos os objetos vivos para o To-space.

Ao mover objetos no To-space, os encadeamentos precisam ser sincronizados por meio de operações atômicas de leitura / gravação / comparação e troca para evitar uma situação em que, por exemplo, outro encadeamento tenha detectado o mesmo objeto, mas seguindo um caminho diferente, e também tente movê-lo.

O thread que moveu o objeto para To-space retorna e deixa um ponteiro de encaminhamento para que outros threads que encontrarem esse objeto possam seguir o endereço correto. Para alocação de memória rápida e sem sincronização para objetos sobreviventes, os threads usam buffers locais do thread.

A montagem paralela distribui o trabalho entre várias roscas auxiliares e a rosca principal

Core gc


O GC principal na V8 começa marcando objetos. Assim que o heap atinge um certo limite (calculado dinamicamente), marcadores competitivos começam seu trabalho. Cada um dos fluxos recebe um conjunto de ponteiros e, seguindo-os, eles marcam cada objeto encontrado como alcançável.

A rotulagem competitiva ocorre completamente em segundo plano enquanto o JavaScript está em execução no segmento principal. Barreiras de gravação são usadas para acompanhar novos links entre objetos criados em JavaScript enquanto os threads estão marcando.


O GC primário usa rotulagem, descarte e compactação paralela e atualização de indicadores

No final da rotulagem competitiva, a rosca principal executa um passo rápido para finalizar a rotulagem. Durante isso, a execução do JavaScript no thread principal é pausada.

O conjunto raiz é verificado novamente para garantir que todos os objetos vivos estejam marcados e, em seguida, a compactação de memória e a atualização dos ponteiros começam em vários segmentos.
Nem todas as páginas da memória antiga são compactadas - aquelas que não são serão digitalizadas para as áreas de memória liberada (varredura) para listá-las em listas gratuitas.

Durante essa pausa, tarefas abrangentes que competem com as tarefas de compactação de memória e o encadeamento principal e podem continuar mesmo quando o JavaScript é executado no encadeamento principal.

GC em tempo ocioso


Os desenvolvedores de JavaScript não têm acesso ao GC - ele faz parte do ambiente de implementação. E embora o código JS não possa chamar o GC diretamente, a V8 fornece esse acesso ao ambiente que incorpora o mecanismo.

O GC pode enviar tarefas (tarefas ociosas) que podem ser feitas "no seu tempo livre" e que são partes do trabalho que precisariam ser feitas de qualquer maneira. Um ambiente como o Chrome, onde o mecanismo está incorporado, pode ter uma idéia do que considerar como tempo livre. Por exemplo, no Chrome, a uma taxa de quadros de 60 quadros por segundo, o navegador tem aproximadamente 16,6 ms para renderizar um quadro de animação.

Se o trabalho de animação for concluído anteriormente, no seu tempo livre, antes do próximo quadro, o Chrome poderá executar algumas das tarefas recebidas do GC.

O GC usa o tempo livre do fluxo principal para pré-limpar

Detalhes podem ser encontrados em nossa publicação no GC em tempo ocioso .

Sumário


O GC na V8 percorreu um longo caminho desde a sua introdução. A adição de técnicas paralelas, incrementais e competitivas levou vários anos, mas valeu a pena, permitindo que você fizesse a maior parte do trabalho em segundo plano.

Tudo relacionado a pausas do fluxo principal, tempo de resposta e carregamento da página melhorou significativamente, o que permite tornar as animações, a rolagem e a interação do usuário na página muito mais suaves. O coletor paralelo permitiu reduzir o tempo total de processamento da memória jovem em 20-50%, dependendo da carga.

O GC em tempo ocioso reduz o tamanho do heap usado para o Gmail em 45%. A rotulagem e descarte competitivos (varredura) podem reduzir a duração das pausas do GC em jogos WebGL pesados ​​em até 50%.

No entanto, o trabalho ainda não está concluído. Reduzir pausas continua sendo uma tarefa importante para simplificar a vida dos usuários da Web, e estamos buscando a possibilidade de usar técnicas mais avançadas para atingir a meta.

Além disso, o Blink (um renderizador no Chrome) também é equipado com um tanque de óleo, e estamos trabalhando para melhorar a interação entre os dois GCs, bem como para usar as técnicas do Orinoco no Oilpan.

A maioria dos desenvolvedores de JavaScript não precisa pensar em como o GC funciona, mas alguma compreensão disso pode ajudá-lo a tomar as melhores decisões em relação ao uso de memória e aos padrões de programação. Por exemplo, dada a estrutura geracional da pilha V8, objetos de baixa vida são realmente muito baratos do ponto de vista do GC, pois pagamos principalmente pelos objetos sobreviventes. E esse tipo de padrão é característico não apenas do JavaScript, mas também de muitos idiomas com suporte para coleta de lixo.

Source: https://habr.com/ru/post/pt454930/


All Articles