Garbage Collector e IDisposable

Objetivo

É recorrente nos forums que eu participo algumas dúvidas sobre o Garbage Collector (GC) e como controlar sua ação. Essa foi a minha motivação para escrever este post, desmistificar algumas verdades e mentiras sobre o GC.

A interface IDisposable geralmente é usada de maneira incorreta para tentar controlar o comportamento do GC. A intenção aqui é explicar as duas coisas, e como elas podem ser usadas em conjunto.

Controlando o Garbage Collector

A primeira pergunta que sempre aparece nos forums é como tratar manualmente a liberação da memória. Muitas pessoas vão atrás do método Dispose, então descobrem que existe a interface IDisposeable e acabam implementando ela na esperança que vão controlar a liberação de memória através do método Dispose. IDisposable não é pra isso. Posteriormente vou explicar a idéia dela.

Então, se não dá pra usar o Dispose, como eu controlo a memória? Surge a segunda resposta: é só executar um GC.Collect().

Mentira novamente. O GC.Collect() vai forçar o gargabe collector a fazer uma verificação por referências perdidas e marcar aquele endereço de memória como livre. Não significa que neste exato momento ele vai liberar a memória.

A resposta para a pergunta de como controlar o GC é: Você não controla. A memória vai ser liberada quando o GC quiser.

Esse é um dos pilares de uma linguagem gerenciada. Se você precisa de um nível maior de controle de memória, ou está num ambiente com recursos mais escassos ou não consegue conviver com a idéia que o GC é responsável por isso, considere-se programando em C ou C++. Lá você tem que controlar absolutamente tudo. É a abordagem correta para problemas que necessitam desse nível de controle.

Como o GC funciona: O básico

Dá pra escrever um livro sobre GC. A melhor explicação que vi até hoje foi no livro do Jeffrey Richter: Applied Microsoft .NET Framework Programming. Ele explica direitinho, detalhe a detalhe o funcionamento do GC. Vou dar uma boa resumida pra tentar explicar aqui.

Primeiro ponto: é necessário entender o conceito de tipos por valor e tipos por referência (ver: Tipos Escalares E Tipos Complexos Ou Tipos Por Valor E Referência) para entender o que é alocado no managed heap (heap gerenciado). Tudo o que vai no heap gerenciado é gerenciado pelo GC.

Quando você tem um tipo por referência, sempre que dá um “new” está criando uma nova instância na memória e guardando uma referência para ela:

public void DoSomething()
{
  Button b = new Button(); //Nova instância criada. Referência armazenada no método b.
  b.Text = "Texto"; 
} // b saiu de escopo. Ninguém mais aponta para aquela instância de Button ali acima.

No exemplo acima, a nova instância de Button ficou “perdida”. Em C++ isso seria automaticamente um memory leak (vazamento de memória). Ninguém mais conseguiria acessar aquele lugar para liberar a memória.

Em C# (.NET) é um pouco diferente. Na próxima passada do GC, ele vai pegar todas as referências que ele tem apontando na aplicação e montar uma “árvore” ou grafo, dizendo: objeto A guarda uma referência para objeto B, que guarda pra C, que guarda pra A e assim sucessivamente.

Quando ele montar essa árvore, vai saber todos os objetos que precisam ser liberados, ou seja, aqueles que não tem ninguém apontando para ele. Nesse momento eles vão para a Finalization Queue (fila de finalização). Posteriormente vamos falar sobre ela. Depois que a fila de finalização é executada a memória é liberada.

Complexo, né? Um pouco.

Vamos para outro exemplo:

public class Teste
{
  private Button b;

  public Teste(){
    b = new Button();
  }
}

Neste exemplo, enquanto a instância da classe Teste existir, a instância de button também vai existir, porque a referência dela está armazenada na variável privada “b”.

Nesse caso, pode ou não pode ser considerado como um memory leak. Se a instância é útil por alguma razão não é um memory leak. Se é inútil, pode ser considerada um memory leak.

Mais um exemplo:

public void CreateButton(Form f){
  Button b = new Button();
  f.Controls.Add(b);
}

Neste exemplo, a instância continuará viva já que a collection “Controls” do form, continua apontando para ela.

A dinâmica é essa. Sempre que tem alguém apontando para a instância, ela continuará viva. Isso é o que popularmente chamamos de “guardar referência pendurada”, ou contagem de referências (quantos lugares apontam para determinado lugar na memória).

Quer ver sua aplicação .NET estourar um “OutOfMemoryException”? Fácil:

public void SwallowAllMemory()
{
  List<Button> l = new List<Button>();
  while (true)
    l.Add(new Button());
}

Aguarde alguns minutos e verá a Exception. Isso acontece porque você está criando novas instâncias de Button e guardando as referências para ela na lista l. Ela nunca será liberada. Não, a memória não é infinita.

Gerações

No texto acima falei sobre “quando o GC passar”, mas quando exatamente ele passa?

O GC trabalha com alguns gatilhos internos. Por exemplo, para geração 0 ele estabelece um tamanho de 256kb. Para geração 1, 1Mb, para geração 2, 2Mb (esses números são palpites, não sei exatamente quais ele usa na prática).

Quando o GC chega no primeiro gatilho de geração 0, ele executa. Os objetos coletados são eliminados e os não coletados são compactados no começo do heap (cópia bit a bit do objeto para outra região da memória). Os objetos não coletados são promovidos para a próxima geração, no caso, geração 1.

Se o gatilho da geração 1 (1 Mb) foi atingido, o GC também passa para a geração 1. Se não foi atingido, ele vai esperar a próxima coleta de geração 0.

Como percebemos, pode ser que fiquem objetos nesta geração não coletados, até que ocorram coletas suficientes de geração 0 para chegar no gatilho da geração 1.

Quando o gatilho da geração 1 é atingido, o GC passa na geração 1, e promove para geração 2 os não coletados, coleta os de geração 1 e compacta novamente o heap.

Se o gatilho da geração 2 não for atingido, ele não passará o GC na geração 2 e irá esperar coletas suficientes de geração 0 e 1 até que o gatilho da 2 seja atingido.

Esse processo as vezes pode fazer com que objetos fiquem vivos e consumindo memória por mais tempo que o necessário.

Finalização e Destructors

O destrutor e o método “Finalize” (classe object) são a mesma coisa. Sempre que o método Finalize é chamado, ele executa o Dispose. Somente a sintaxe do C# nos força a implementar um destrutor, já que se tentarmos fazer um override do método Finalize, recebemos um erro em tempo de compilação.

Aqui é o grande ponto que gera confusão sobre o funcionamento do GC. Já que os desenvolvedores não tem muito controle sobre o processo de liberação da memória, para que serve o destrutor da classe?

A resposta: para liberar recursos não-gerenciados.

Se o objeto possui um destrutor implementado, ele vai passar pela fila de finalização. Isso significa que o GC vai percorrer o grafo de objetos, quando ele percebe que não existe mais nenhuma referência apontando para aquela instância, ele verifica se existe um destrutor implementado. Se existe, ele enfileira o objeto e continua percorrendo o grafo. Se não existe, ele faz a liberação do objeto na hora (mais rápido).

Ou seja, ao contrário do que pensamos, implementar um destrutor na verdade faz com que o objeto seja um pouquinho mais custoso para ser liberado do que se ele não tivesse um.

Vejamos o exemplo:

	public class ClassWithDestructor
	{
		private int _instanceID;

		public ClassWithDestructor(int instanceID)
		{
			_instanceID = instanceID;
			Console.WriteLine("ClassWithDestructor created: " + _instanceID.ToString());
		}

		~ClassWithDestructor()
		{
			Console.WriteLine("ClassWithDestructor destroyed: " + _instanceID.ToString());
		}
	}

		private void button4_Click(object sender, EventArgs e)
		{
			int i = 0;
			while (true)
			{
				ClassWithDestructor c = new ClassWithDestructor(i);
				i++;
			}
		}

O interessante desse exemplo é percebermos o momento em que o destrutor é executado. A aplicação fica rodando por um tempo somente criando instâncias (o heap gerenciado está enchendo) até que uma bela hora começam a ocorrer passagens do Garbage Collector e algumas instâncias são liberadas.

Isso mostra a falta de controle que temos sobre a hora que ele vai passar. Neste exemplo ainda é relativamente simples, porque as coletas vão ocorrer somente na geração 0. Se algumas instâncias fossem promovidas para a geração 1, elas demorariam mais ainda para serem coletadas.

Então podemos raciocinar: que vantagem temos em usar o destrutor para liberar recursos não gerenciados se não sabemos a hora que ele vai passar?

Na minha opinião, nenhuma. Eis o próximo tópico.

IDisposable

A interface IDisposable server para controlarmos a liberação de recursos não gerenciados. Ela está intimamente ligada à palavra chave “using”.

O que podemos entender por recursos não gerenciados (já que a memória é gerenciada): Kernel objects, File handles, GDI handles e outros handles do sistema operacional, sockets, semáforos, mutexes, etc.

A interface “IDisposable” é que ajuda no controle destes recursos. Quando você implementa esta interface ela obriga a implementação do método “Dispose”, que vem da idéia de liberar recursos não gerenciados e está intimamente ligada com a palavra chave “using”.

Por exemplo:

	public class DisposableClass : IDisposable
	{
		#region IDisposable Members

		public void Dispose()
		{
			Console.WriteLine("Dispose!");
		}

		#endregion
	}


		private void button2_Click(object sender, EventArgs e)
		{
			DisposableClass c = new DisposableClass();
			using (c)
			{
				Console.WriteLine("Do Something");
			}
		}

Quando executamos o código do botão 2, o resultado é:

Do Something
Dispose!

O que mostra que quando usamos a palavra chave “using” implicitamente o método IDisposable.Dispose é executado quando termina o escopo do using, justamente para liberarmos os recursos não gerenciados.

Podemos chamar o método Dispose diretamente também, sem o uso da palavra using. Eu particularmente gosto muito dela para esses casos, assim não tem como esquecer de chamar o Dispose pela própria estrutura da linguagem.

Dispose != Destructor

Para deixarmos a idéia ainda mais completa, vamos ver o exemplo:

	public class DisposableClass : IDisposable
	{
		private int _instanceID;		

		#region IDisposable Members

		public void Dispose()
		{
			Console.WriteLine("Dispose! " + _instanceID.ToString());
		}

		#endregion

		public DisposableClass(int instanceID)
		{
			_instanceID = instanceID;
			Console.WriteLine("Constructor invoked " + instanceID.ToString());
		}

		~DisposableClass()
		{
			Console.WriteLine("Destructor invoked!" + _instanceID.ToString());
		}
	}


		private void button3_Click(object sender, EventArgs e)
		{
			int i = 0;
			while (true)
			{
				DisposableClass c = new DisposableClass(i);				
				i++;
			}
		}

Quando executarmos o código do button3, veremos que após algum tempo simplesmente alocando memória loucamente, o GC começa a passar. Então veremos passagens em “Constructor invoked” e em “Destructor invoked”, porém, não veremos nenhuma passagem por Dispose.

O ponto que eu quero mostrar com essa idéia é que o método Dispose não está relacionado a alocação de memória. Supondo que no construtor desta classe criássemos um objeto do kernel do windows e segurássemos um handle. Mesmo com a Garbage Collector coletando esse objeto, aconteceria um leak de “handles” na aplicação, já que este handle não seria liberado para o SO.

Novamente, Dispose não tem nada a ver com destructor. Você pode chamar o Dispose de dentro do destrutor para garantir a liberação do recurso, mesmo assim não é possível saber quando o GC vai passar para coletar esse objeto.

O ideal é usar a palavra chave using para garantir a liberação dos recursos não gerenciados no momento que você sabe não precisar mais deles.

Como Implementar Dispose e Finalize

A Microsoft sugere uma implementação para o “dispose pattern”, ou seja, garantir que o destrutor sempre execute o Dispose e não fiquem recursos não gerenciados não liberados. O padrão sugerido é Implementing Finalize and Dispose to Clean Up Unmanaged Resources.

Acho interessante usar esta abordagem, mas ainda prefiro usar a palavra chave using e não implementar o destrutor, simplesmente para deixar mais barato o “custo” do GC e garantir que os handles sejam liberados tão logo não sejam mais necessários. Acho que esse padrão pode segurar os handles vivos mais tempo que o necessário por permitir que os desenvolvedores “esqueçam” de liberá-los. Mas é uma questão de gosto. Cada caso é um caso. Não aplicaria isso como regra.

Evitando memory leaks

Esse processo de gerenciar a memória “simplificado” gera um problema. Se as instâncias ficam desnecessariamente sendo apontadas, elas continuarão vivas na memória. A parte boa disso é não tomar os famosos “Access Violation”. A parte ruim, é a memória que nunca será liberada.

Existem diversas ferramentas do tipo “Memory Profiler” para ajudar a depurar esse tipo de problema, localizando onde no código esse tipo de referência foi esquecida. Essa é a melhor forma de investigar este tipo de problema, além de ser cuidadoso no seu código para não guardar referências desnecessariamente.

Com esse artigo, concluímos também a idéia de que é possível controlar quando o .NET libera a memória. Infelizmente para uns e felizmente para outros, não é possível ter um nível completo de controle sobre este aspecto, por isso este assunto gera tantas dúvidas para quem está começando com .NET.

E outra conclusão que podemos chegar é que a implementação de IDisposable não nos dá esse controle esperado da memória, mas não deixa de ser uma prática muito boa para recursos não-gerenciados.

Advertisement

2 thoughts on “Garbage Collector e IDisposable

  1. Ótimo post, me ajudou no meu projeto e me tirou uma dúvida enorme e ainda me propôs boas práticas de programação. Muito obrigado mesmo. 🙂

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s