Noções básicas do coletor de lixo e dicas de desempenho

 

Rico Mariani
Microsoft Corporation

Abril de 2003

Resumo: O coletor de lixo do .NET fornece um serviço de alocação de alta velocidade com bom uso de memória e sem problemas de fragmentação de longo prazo. Este artigo explica como os coletores de lixo funcionam e discute alguns dos problemas de desempenho que podem ser encontrados em um ambiente coletado por lixo. (10 páginas impressas)

Aplica-se a:
   Microsoft® .NET Framework

Sumário

Introdução
Modelo simplificado
Coletando o lixo
Desempenho
Finalização
Conclusão

Introdução

Para entender como fazer um bom uso do coletor de lixo e quais problemas de desempenho você pode encontrar ao executar em um ambiente coletado por lixo, é importante entender os conceitos básicos de como os coletores de lixo funcionam e como esses trabalhos internos afetam a execução de programas.

Este artigo é dividido em duas partes: primeiro discutirei a natureza do coletor de lixo do CLR (Common Language Runtime) em termos gerais usando um modelo simplificado e, em seguida, discutirei algumas implicações de desempenho dessa estrutura.

Modelo simplificado

Para fins explicativos, considere o seguinte modelo simplificado do heap gerenciado. Observe que isso não é o que realmente é implementado.

Figura 1. Modelo simplificado do heap gerenciado

As regras para esse modelo simplificado são as seguintes:

  • Todos os objetos de coleta de lixo são alocados de um intervalo contíguo de espaço de endereço.
  • O heap é dividido em gerações (mais sobre isso posteriormente) para que seja possível eliminar a maior parte do lixo examinando apenas uma pequena fração do heap.
  • Os objetos dentro de uma geração têm aproximadamente a mesma idade.
  • Gerações numeradas mais altas indicam áreas do heap com objetos mais antigos— esses objetos são muito mais propensos a serem estáveis.
  • Os objetos mais antigos estão nos endereços mais baixos, enquanto novos objetos são criados em endereços crescentes. (Os endereços estão aumentando na Figura 1 acima.)
  • O ponteiro de alocação para novos objetos marca o limite entre as áreas usadas (alocadas) e não utilizadas (livres) da memória.
  • Periodicamente, o heap é compactado removendo objetos mortos e deslizando os objetos ativos para cima em direção à extremidade de endereço baixo do heap. Isso expande a área não utilizado na parte inferior do diagrama em que novos objetos são criados.
  • A ordem dos objetos na memória permanece a ordem em que eles foram criados, para uma boa localidade.
  • Nunca há lacunas entre objetos no heap.
  • Apenas parte do espaço livre é confirmado. Quando necessário, ** mais memória é adquirida do sistema operacional no intervalo de endereços reservados .

Coletando o lixo

O tipo mais fácil de coleta para entender é a coleta de lixo totalmente compactada, então começarei discutindo isso.

Coleções completas

Em uma coleção completa, devemos interromper a execução do programa e encontrar todas as raízes no heap do GC. Essas raízes vêm em uma variedade de formas, mas são mais notavelmente variáveis de pilha e globais que apontam para o heap. A partir das raízes, visitamos todos os objetos e seguimos cada ponteiro de objeto contido em cada objeto visitado marcando os objetos à medida que avançamos. Dessa forma, o coletor terá encontrado todos os objetos acessíveis ou dinâmicos . Os outros objetos, os inacessíveis , agora estão condenados.

Figura 2. Raízes no heap do GC

Depois que os objetos inacessíveis forem identificados, queremos recuperar esse espaço para uso posterior; o objetivo do coletor neste ponto é deslizar os objetos ativos para cima e eliminar o espaço desperdiçado. Com a execução interrompida, é seguro que o coletor mova todos esses objetos e corrija todos os ponteiros para que tudo esteja vinculado corretamente em seu novo local. Os objetos sobreviventes são promovidos para o número da próxima geração (ou seja, os limites para as gerações são atualizados) e a execução pode ser retomada.

Coleções parciais

Infelizmente, a coleta de lixo completa é simplesmente muito cara para fazer todas as vezes, portanto, agora é apropriado discutir como ter gerações na coleção nos ajuda.

Primeiro vamos considerar um caso imaginário onde temos extraordinariamente sorte. Vamos supor que houve uma coleção completa recente e que o heap está bem compactado. A execução do programa é retomada e algumas alocações acontecem. Na verdade, muitas e muitas alocações acontecem e, depois de alocações suficientes, o sistema de gerenciamento de memória decide que é hora de coletar.

Agora é aqui que temos sorte. Suponha que, em todo o tempo em que estávamos executando desde a última coleção, não escrevemos em nenhum dos objetos mais antigos, apenas os objetos recém-alocados, geração zero (geração0), foram gravados. Se isso acontecesse, estaríamos em uma ótima situação porque podemos simplificar o processo de coleta de lixo massivamente.

Em vez de nossa coleta completa usual, podemos apenas assumir que todos os objetos mais antigos (gen1, gen2) ainda estão vivos ou pelo menos o suficiente deles estão vivos, o que não vale a pena olhar para esses objetos. Além disso, como nenhum deles foi escrito (lembra a sorte que temos?) não há ponteiros dos objetos mais antigos para os objetos mais recentes. Então, o que podemos fazer é olhar para todas as raízes como de costume, e se alguma raiz apontar para objetos antigos simplesmente ignorar essas. Para outras raízes (aquelas que apontam para a geração0), continuamos como de costume, seguindo todos os ponteiros. Sempre que encontramos um ponteiro interno que volta para os objetos mais antigos, nós o ignoramos.

Quando esse processo for concluído, teremos visitado todos os objetos dinâmicos na geração0 sem ter visitado nenhum objeto das gerações mais antigas. Os objetos de geração0 podem então ser condenados como de costume e deslizamos para cima apenas essa região da memória, deixando os objetos mais antigos não resolvidos.

Agora esta é realmente uma grande situação para nós porque sabemos que a maior parte do espaço morto provavelmente estará em objetos mais jovens onde há uma grande quantidade de rotatividade. Muitas classes criam objetos temporários para seus valores de retorno, cadeias de caracteres temporárias e outras classes de utilitário variadas, como enumeradores e outras coisas. Olhar apenas para a geração0 nos dá uma maneira fácil de recuperar a maior parte do espaço morto olhando para apenas pouquíssimos objetos.

Infelizmente, nunca temos a sorte de usar essa abordagem, porque pelo menos alguns objetos mais antigos são obrigados a mudar para que apontem para novos objetos. Se isso acontecer, não é suficiente simplesmente ignorá-los.

Fazendo gerações funcionarem com barreiras de gravação

Para que o algoritmo acima realmente funcione, devemos saber quais objetos mais antigos foram modificados. Para lembrar o local dos objetos sujo, usamos uma estrutura de dados chamada cartão tabela e, para manter essa estrutura de dados, o compilador de código gerenciado gera as chamadas barreiras de gravação. Essas duas noções são fundamentais para o sucesso da coleta de lixo baseada em geração.

A tabela cartão pode ser implementada de várias maneiras, mas a maneira mais fácil de pensar nisso é como uma matriz de bits. Cada bit na tabela cartão representa um intervalo de memória no heap— digamos, 128 bytes. Sempre que um programa grava um objeto em algum endereço, o código de barreira de gravação deve calcular qual parte de 128 bytes foi gravada e, em seguida, definir o bit correspondente na tabela cartão.

Com esse mecanismo em vigor, agora podemos revisitar o algoritmo de coleção. Se estivermos fazendo uma coleta de lixo de geração0, podemos usar o algoritmo conforme discutido acima, ignorando quaisquer ponteiros para gerações mais antigas, mas depois de fazer isso, também devemos encontrar cada ponteiro de objeto em cada objeto que está em uma parte que foi marcada como modificada na tabela cartão. Devemos tratá-los como raízes. Se considerarmos esses ponteiros também, coletaremos corretamente apenas os objetos gen0 .

Essa abordagem não ajudaria em nada se a cartão tabela estivesse sempre cheia, mas, na prática, comparativamente, poucos dos ponteiros das gerações mais antigas realmente são modificados, portanto, há uma economia substancial com essa abordagem.

Desempenho

Agora que temos um modelo básico de como as coisas estão funcionando, vamos considerar algumas coisas que poderiam dar errado que o tornariam lento. Isso nos dará uma boa idéia de que tipo de coisas devemos tentar evitar para obter o melhor desempenho do coletor.

Muitas alocações

Isso é realmente a coisa mais básica que pode dar errado. Alocar uma nova memória com o coletor de lixo é realmente muito rápido. Como você pode ver na Figura 2 acima, tudo o que precisa acontecer normalmente é que o ponteiro de alocação seja movido para criar espaço para seu novo objeto no lado "alocado", ele não fica muito mais rápido do que isso. No entanto, mais cedo ou mais tarde uma coleta de lixo tem que acontecer e, sendo todas as coisas iguais, é melhor que isso aconteça mais tarde do que mais cedo. Portanto, você deseja ter certeza quando está criando novos objetos de que é realmente necessário e apropriado fazê-lo, mesmo que a criação de apenas um seja rápida.

Isso pode parecer um conselho óbvio, mas, na verdade, é extremamente fácil esquecer que uma pequena linha de código que você escreve pode disparar muitas alocações. Por exemplo, suponha que você esteja escrevendo uma função de comparação de algum tipo e suponha que seus objetos tenham um campo de palavras-chave e que você queira que sua comparação não diferencia maiúsculas de minúsculas nas palavras-chave na ordem fornecida. Agora, nesse caso, você não pode simplesmente comparar toda a cadeia de caracteres de palavras-chave, pois a primeira palavra-chave pode ser muito curta. Seria tentador usar String.Split para dividir a cadeia de caracteres palavra-chave em partes e comparar cada peça para usar a comparação normal que não diferencia maiúsculas de minúsculas. Parece excelente, não é mesmo?

Bem, como acontece que fazer isso não é uma boa idéia. Você verá que String.Split vai criar uma matriz de cadeias de caracteres, o que significa que um novo objeto de cadeia de caracteres para cada palavra-chave originalmente na cadeia de caracteres de palavras-chave mais um objeto para a matriz. Caramba! Se estivermos fazendo isso no contexto de uma espécie, isso é muitas comparações e sua função de comparação de duas linhas agora está criando um número muito grande de objetos temporários. De repente, o coletor de lixo vai trabalhar muito duro em seu nome, e mesmo com o esquema de coleta mais inteligente há apenas um monte de lixo para limpo. Melhor escrever uma função de comparação que não exija as alocações.

alocações de Too-Large

Ao trabalhar com um alocador tradicional, como malloc(), os programadores geralmente escrevem código que faz o menor número possível de chamadas para malloc() porque sabem que o custo de alocação é relativamente alto. Isso se traduz na prática de alocar em partes, muitas vezes alocando especulativamente objetos que talvez precisemos, para que possamos fazer menos alocações totais. Os objetos pré-alocados são gerenciados manualmente de algum tipo de pool, criando efetivamente uma espécie de alocador personalizado de alta velocidade.

No mundo gerenciado, essa prática é muito menos convincente por várias razões:

Primeiro, o custo de fazer uma alocação é extremamente baixo— não há pesquisa de blocos gratuitos como ocorre com os alocadores tradicionais; tudo o que precisa acontecer é o limite entre as áreas livres e alocadas precisa ser movido. O baixo custo de alocação significa que o motivo mais atraente para pool simplesmente não está presente.

Em segundo lugar, se você optar por pré-alocar, será claro que fará mais alocações do que as necessárias para suas necessidades imediatas, o que, por sua vez, poderia forçar coletas de lixo adicionais que, de outra forma, poderiam ter sido desnecessárias.

Por fim, o coletor de lixo não poderá recuperar espaço para objetos que você está reciclando manualmente, pois da perspectiva global todos esses objetos, incluindo aqueles que não estão em uso no momento, ainda estão ativos. Você pode achar que uma grande quantidade de memória é desperdiçada mantendo objetos prontos para uso, mas não em uso em mãos.

Isso não quer dizer que a pré-alocação seja sempre uma má ideia. Talvez você queira fazer isso para forçar determinados objetos a serem inicialmente alocados juntos, por exemplo, mas provavelmente achará que ele é menos atraente como uma estratégia geral do que seria em código não gerenciado.

Muitos ponteiros

Se você criar uma estrutura de dados que seja uma grande malha de ponteiros, você terá dois problemas. Primeiro, haverá muitas gravações de objeto (consulte a Figura 3 abaixo) e, em segundo lugar, quando chegar a hora de coletar essa estrutura de dados, você fará com que o coletor de lixo siga todos esses ponteiros e, se necessário, altere todos eles à medida que as coisas se movem. Se sua estrutura de dados for de longa duração e não mudar muito, o coletor só precisará visitar todos esses ponteiros quando as coleções completas acontecerem (no nível gen2 ). Mas se você criar essa estrutura em uma base transitória, digamos, como parte do processamento de transações, você pagará o custo com muito mais frequência.

Figura 3. Estrutura de dados pesada em ponteiros

As estruturas de dados que são pesadas em ponteiros também podem ter outros problemas, não relacionados ao tempo de coleta de lixo. Novamente, como discutimos anteriormente, quando os objetos são criados, eles são alocados contíguamente na ordem de alocação. Isso é ótimo se você estiver criando uma estrutura de dados grande, possivelmente complexa por, por exemplo, restaurando informações de um arquivo. Embora você tenha tipos de dados diferentes, todos os seus objetos estarão próximos na memória, o que, por sua vez, ajudará o processador a ter acesso rápido a esses objetos. No entanto, à medida que o tempo passa e sua estrutura de dados é modificada, os novos objetos provavelmente precisarão ser anexados aos objetos antigos. Esses novos objetos serão criados muito mais tarde e, portanto, não estarão perto dos objetos originais na memória. Mesmo quando o coletor de lixo compacta sua memória, seus objetos não serão embaralhados na memória, eles simplesmente "deslizam" juntos para remover o espaço desperdiçado. A desordem resultante pode ficar tão ruim ao longo do tempo que você pode estar inclinado a fazer uma nova cópia de toda a sua estrutura de dados, tudo bem embalado, e deixar o antigo desordenado ser condenado pelo coletor no devido tempo.

Muitas raízes

O coletor de lixo deve, naturalmente, dar às raízes um tratamento especial no momento da coleta— elas sempre precisam ser enumeradas e devidamente consideradas por sua vez. A coleção gen0 só pode ser rápida na medida em que você não dá a ela uma enxurrada de raízes a serem consideradas. Se você criar uma função profundamente recursiva que tenha muitos ponteiros de objeto entre suas variáveis locais, o resultado poderá realmente ser bastante caro. Esse custo é incorrido não apenas em ter que considerar todas essas raízes, mas também no número extra-grande de objetos de geração0 que essas raízes podem estar mantendo vivas por não muito tempo (discutido abaixo).

Muitas gravações de objeto

Mais uma vez referindo-se à nossa discussão anterior, lembre-se de que sempre que um programa gerenciado modifica um ponteiro de objeto, o código de barreira de gravação também é disparado. Isso pode ser ruim por dois motivos:

Primeiro, o custo da barreira de gravação pode ser comparável ao custo do que você estava tentando fazer em primeiro lugar. Se você estiver, por exemplo, fazendo operações simples em algum tipo de classe de enumerador, talvez você descubra que precisa mover alguns de seus principais ponteiros da coleção main para o enumerador em cada etapa. Na verdade, isso é algo que você pode querer evitar, pois você efetivamente dobra o custo de copiar esses ponteiros devido à barreira de gravação e talvez seja necessário fazer isso uma ou mais vezes por loop no enumerador.

Em segundo lugar, disparar barreiras de gravação será duplamente ruim se você estiver escrevendo de fato em objetos mais antigos. À medida que você modifica seus objetos mais antigos, você cria raízes adicionais para marcar (discutidos acima) quando a próxima coleta de lixo acontece. Se você modificou o suficiente de seus objetos antigos, você efetivamente negaria as melhorias de velocidade usuais associadas à coleta apenas da geração mais jovem.

Essas duas razões são, naturalmente, complementadas pelos motivos habituais para não fazer muitas gravações em qualquer tipo de programa. Sendo todas as coisas iguais, é melhor tocar menos em sua memória (leitura ou gravação, na verdade) para fazer uso mais econômico do cache do processador.

Muitos objetos de vida quase longa

Por fim, talvez a maior armadilha do coletor de lixo geracional seja a criação de muitos objetos, que não são exatamente temporários nem são exatamente de longa duração. Esses objetos podem causar muitos problemas, pois não serão limpos por uma coleção gen0 (a mais barata), pois ainda serão necessários, e podem até sobreviver a uma coleção gen1 porque ainda estão em uso, mas logo morrerão depois disso.

O problema é que, depois que um objeto chegar ao nível gen2 , apenas uma coleção completa se livrará dele e as coletas completas serão suficientemente dispendiosas para que o coletor de lixo os atrase, desde que seja razoavelmente possível. Portanto, o resultado de ter muitos objetos "quase de longa duração" é que sua geração2 tende a crescer, potencialmente a uma taxa alarmante; ele pode não ser limpo quase tão rápido quanto você gostaria, e quando ele é limpo, certamente será muito mais caro fazê-lo do que você poderia ter desejado.

Para evitar esses tipos de objetos, suas melhores linhas de defesa são assim:

  1. Aloque o menor número possível de objetos, com a devida atenção à quantidade de espaço temporário que você está usando.
  2. Mantenha os tamanhos de objeto de vida mais longa ao mínimo.
  3. Mantenha o menor número possível de ponteiros de objeto em sua pilha (esses são raízes).

Se você fizer essas coisas, suas coleções gen0 são mais propensas a serem altamente eficazes, e a geração1 não crescerá muito rápido. Como resultado, as coleções gen1 podem ser feitas com menos frequência e, quando se torna prudente fazer uma coleção gen1 , seus objetos de tempo de vida médio já estarão mortos e poderão ser recuperados, baratos, nesse momento.

Se as coisas estiverem indo bem, durante as operações de estado estável, o tamanho da geração2 não aumentará!

Finalização

Agora que abordamos alguns tópicos com o modelo de alocação simplificado, gostaria de complicar um pouco as coisas para que possamos discutir um fenômeno mais importante e esse é o custo dos finalizadores e da finalização. Resumidamente, um finalizador pode estar presente em qualquer classe— é um membro opcional que o coletor de lixo promete chamar em objetos mortos antes de recuperar a memória desse objeto. Em C#, você usa a sintaxe ~Class para especificar o finalizador.

Como a finalização afeta a coleção

Quando o coletor de lixo encontra pela primeira vez um objeto que está morto, mas ainda precisa ser finalizado, ele deve abandonar sua tentativa de recuperar o espaço para esse objeto nesse momento. Em vez disso, o objeto é adicionado a uma lista de objetos que precisam de finalização e, além disso, o coletor deve garantir que todos os ponteiros dentro do objeto permaneçam válidos até que a finalização seja concluída. Isso é basicamente a mesma coisa que dizer que cada objeto que precisa de finalização é como um objeto raiz temporário da perspectiva do coletor.

Depois que a coleção for concluída, o thread de finalização apropriadamente nomeado passará pela lista de objetos que precisam de finalização e invocará os finalizadores. Quando isso for feito, os objetos mais uma vez ficarão mortos e serão coletados naturalmente da maneira normal.

Finalização e desempenho

Com essa compreensão básica da finalização, já podemos deduzir algumas coisas muito importantes:

Primeiro, os objetos que precisam de finalização vivem mais do que os objetos que não o fazem. Na verdade, eles podem viver muito mais tempo. Por exemplo, suponha que um objeto que esteja na geração2 precise ser finalizado. A finalização será agendada, mas o objeto ainda está na geração2, portanto, ele não será recolhido até que a próxima coleção gen2 aconteça. Isso pode ser muito tempo, de fato, e, de fato, se as coisas estão indo bem , será muito tempo, porque as coleções gen2 são dispendiosas e, portanto, queremos que aconteçam muito raramente. Objetos mais antigos que precisam de finalização podem ter que esperar por dezenas, se não centenas de coleções de geração0 , antes que seu espaço seja recuperado.

Em segundo lugar, objetos que precisam de finalização causam danos colaterais. Como os ponteiros de objeto interno devem permanecer válidos, não só os objetos que precisam diretamente de finalização permanecerão na memória, mas tudo o que o objeto se refere, direta e indiretamente, também permanecerá na memória. Se uma árvore enorme de objetos fosse ancorada por um único objeto que exigisse a finalização, toda a árvore permaneceria, potencialmente por um longo tempo, como acabamos de discutir. Portanto, é importante usar finalizadores com moderação e colocá-los em objetos que tenham o menor número possível de ponteiros de objeto internos. No exemplo de árvore que acabei de dar, você pode facilmente evitar o problema movendo os recursos que precisam de finalização para um objeto separado e mantendo uma referência a esse objeto na raiz da árvore. Com essa alteração modesta, apenas um objeto (espero que um objeto pequeno agradável) permanecia e o custo de finalização é minimizado.

Por fim, os objetos que precisam de finalização criam trabalho para o thread do finalizador. Se o processo de finalização for complexo, o único thread do finalizador ficará gastando muito tempo executando essas etapas, o que pode fazer com que uma lista de pendências de trabalho e, portanto, faça com que mais objetos permaneçam aguardando a finalização. Portanto, é de vital importância que os finalizadores façam o menor trabalho possível. Lembre-se também de que, embora todos os ponteiros de objeto permaneçam válidos durante a finalização, pode ser o caso de que esses ponteiros levem a objetos que já foram finalizados e, portanto, podem ser menos úteis. Geralmente, é mais seguro evitar seguir ponteiros de objeto no código de finalização, mesmo que os ponteiros sejam válidos. Um caminho de código de finalização curto e seguro é o melhor.

IDisposable e Dispose

Em muitos casos, é possível que objetos que, de outra forma, sempre precisem ser finalizados para evitar esse custo implementando a interface IDisposable . Essa interface fornece um método alternativo para recuperar recursos cujo tempo de vida é bem conhecido pelo programador e isso realmente acontece bastante. É claro que é melhor ainda se seus objetos simplesmente usam apenas memória e, portanto, não exigem nenhuma finalização ou descarte; mas se a finalização for necessária e houver muitos casos em que o gerenciamento explícito de seus objetos é fácil e prático, a implementação da interface IDisposable é uma ótima maneira de evitar, ou pelo menos reduzir, os custos de finalização.

Na linguagem C#, esse padrão pode ser bastante útil:

class X:  IDisposable
{
   public X(…)
   {
   … initialize resources … 
   }

   ~X()
   {
   … release resources … 
   }

   public void Dispose()
   {
// this is the same as calling ~X()
        Finalize(); 

// no need to finalize later
System.GC.SuppressFinalize(this); 
   }
};

Em que uma chamada manual para Descartar evita a necessidade de o coletor manter o objeto ativo e chamar o finalizador.

Conclusão

O coletor de lixo do .NET fornece um serviço de alocação de alta velocidade com bom uso de memória e nenhum problema de fragmentação de longo prazo, no entanto, é possível fazer coisas que lhe darão um desempenho muito menor do que o ideal.

Para obter o melhor do alocador, você deve considerar práticas como a seguinte:

  • Aloque toda a memória (ou o máximo possível) a ser usada com uma determinada estrutura de dados ao mesmo tempo.
  • Remova alocações temporárias que podem ser evitadas com pouca penalidade na complexidade.
  • Minimize o número de vezes que os ponteiros de objeto são gravados, especialmente as gravações feitas em objetos mais antigos.
  • Reduza a densidade de ponteiros em suas estruturas de dados.
  • Faça uso limitado de finalizadores e, em seguida, apenas em objetos "folha", tanto quanto possível. Interromper objetos, se necessário, para ajudar com isso.

Uma prática regular de revisar suas principais estruturas de dados e conduzir perfis de uso de memória com ferramentas como o Allocation Profiler será um longo caminho para manter o uso de memória eficaz e fazer com que o coletor de lixo funcione melhor para você.