Por dentro do Gerenciamento de Memória

As opções, trade-offs e implementações da alocação dinâmica

Obtenha uma visão geral das técnicas de gerenciamento de memória disponíveis para programadores Linux™, com foco na linguagem C, mas aplicáveis também a outras linguagens. Este artigo detalha como o gerenciamento de memória funciona, depois mostra como gerenciar a memória manualmente e semimanualmente utilizando contagem ou conjuntos de referências e como gerenciar a memória automaticamente utilizando a coleta de lixo.

Dave Bartlett, Consultant, author, and lecturer, 自由职业者

Dave BartlettJonathan Bartlett é autor do livro Programming from the Ground Up, uma introdução à programação usando linguagem assembly Linux. Ele é o desenvolvedor líder de novas mídias, desenvolvendo aplicativos para Web, vídeo, kiosk e desktop para clientes.



16/Nov/2004

Por que a memória deve ser gerenciada

O gerenciamento de memória é uma das áreas mais fundamentais da programação de computador. Em muitas linguagens de script, não é necessário se preocupar com o modo como a memória é gerenciada, mas isso não torna esse gerenciamento menos importante. Conhecer as capacidades e limitações do gerenciador de memória é essencial para a programação efetiva. Na maioria das linguagens de sistemas como C e C++, é necessário fazer o gerenciamento de memória. Este artigo abrange os fundamentos das práticas de gerenciamento de memória manual, semi-automático e automático.

Voltando à programação da linguagem assembly da Apple II, o gerenciamento de memória não era motivo de preocupação. Basicamente, você tinha todo o sistema executado. A quantidade de memória que o sistema tinha, você também tinha. Não era necessário se preocupar com o quanto de memória o sistema tinha, pois todo computador era igual. Assim, se seus requisitos de memória fossem estáticos, apenas um intervalo de memória seria escolhido para usar e o usava.

No entanto, mesmo nesse simples computador você ainda tinha problemas, especialmente o fato de não saber o quanto de memória cada parte do seu programa precisaria. Se você tinha espaço limitado e necessidades de memória variáveis, precisaria então atender, de alguma forma, a estes requisitos:

  • Determinar se possuía memória suficiente para processar dados
  • Obter uma seção de memória da memória disponível
  • Retornar uma seção de memória novamente ao conjunto de memórias disponíveis para que ela pudesse ser utilizada por outras partes do programa ou por outros programas

As bibliotecas que implementam esses requisitos são chamadas de alocadores, pois são responsáveis pela alocação e desalocação de memória. Quanto mais dinâmico um programa é, mais o gerenciamento de memória se torna um problema e mais importante se torna sua escolha quanto ao alocador de memória. Observemos os diferentes métodos disponíveis para gerenciar memória, seus benefícios e desvantagens e as situações nas quais eles funcionam melhor.


Alocadores de Memória com Estilo C

A linguagem de programação C fornece duas funções para atender aos nossos três requisitos:

  • malloc: Aloca um determinado número de bytes e retorna um ponteiro para eles. Se não houver memória suficiente disponível, retorna um ponteiro nulo.
  • free: Obtém um ponteiro para um segmento da memória alocada por malloc e o retorna para uso posterior pelo programa ou sistema operacional (geralmente algumas implementações de malloc só podem retornar a memória novamente ao programa, não ao sistema operacional).

Memória Física e Virtual

Para entender como a memória é alocada dentro do seu programa, é necessário primeiro entender como a memória é alocada para seu programa do sistema operacional. Cada processo em seu computador entende que tem acesso a todas as suas memórias físicas. Obviamente, como vários programas estão sendo executados ao mesmo tempo, cada processo não pode ter todas as memórias. O que ocorre é que seus processos estão utilizando a memória virtual.

Apenas como exemplo, digamos que seu programa está acessando o endereço de memória 629. O sistema de memória virtual, no entanto, não a armazenou necessariamente no local 629 da RAM. Na verdade, ela pode nem mesmo estar na RAM -- pode ter sido movida novamente para o disco, caso a RAM física esteja cheia! Como os endereços não necessariamente refletem o local físico no qual a memória está localizada, ela é chamada de memória virtual. O sistema operacional mantém uma tabela de conversões de endereços virtuais para físicos para que o hardware do computador possa responder adequadamente aos pedidos de endereço. E, se o endereço estiver no disco e não na RAM, o sistema operacional pára temporariamente o processo, descarrega outra memória para o disco, carrega na memória solicitada no disco e reinicia o processo. Dessa forma, cada processo obtém seu próprio espaço de endereço para reprodução e pode acessar mais memória do que a instalada fisicamente.

Nos sistemas x86 de 32 bits, cada processo pode acessar 4 GB de memória. A maioria das pessoas não tem 4 GB de memória em seus sistemas; mesmo incluindo troca, elas devem ter menos que 4 GB por processo. Assim, quando um processo é carregado, ele obtém uma alocação inicial de memória até um determinado endereço, chamado quebra do sistema. Depois disso há a memória não-mapeada -- memória à qual nenhum local físico correspondente foi designado na RAM ou no disco. Assim, se um processo for executado sem memória de sua alocação inicial, ele precisará solicitar que o sistema operacional "mapeie" mais memória. (O mapeamento é um termo matemático para a correspondência de um para um -- a memória é "mapeada" quando seu endereço virtual tem um local físico correspondente para armazenamento.)

Os sistemas baseados em UNIX têm duas chamadas básicas do sistema que mapeiam memória adicional:

  • brk:brk() é uma chamada do sistema muito simples. Lembra-se da quebra do sistema, o local que é o início da memória mapeada para o processo? brk() simplesmente move esse local para frente ou para trás, para incluir ou remover a memória do processo ou para ele.
  • mmap:mmap(), ou "mapa de memória", é semelhante a brk() mas muito mais flexível. Primeiro, ele pode mapear memória em qualquer local, não apenas no final do processo. Segundo, não apenas ele pode mapear endereços virtuais para RAM física ou troca, como também pode mapeá-los para arquivos e locais de arquivos, de modo que os endereços da memória de leitura e gravação possam ler e gravar dados nos arquivos ou a partir deles. Aqui, no entanto, a única preocupação é quanto à capacidade de mmap em incluir RAM mapeada em nosso processo. munmap() faz o inverso de mmap().

Como é possível ver, tanto brk() ou mmap() podem ser utilizados para incluir memória virtual adicional para nossos processos. Usaremos brk() em nossos exemplos, pois ele é mais simples e mais comum.

Implementando um Alocador Simples

Se já fez muita programação em C, provavelmente utilizou malloc() e free() algumas vezes. No entanto, pode ser que não tenha tido tempo de pensar sobre como eles podem ser implementados em seu sistema operacional. Esta seção mostrará o código para uma implementação simples de malloc e free para ajudar a demonstrar o que está envolvido no gerenciamento de memória.

Para testar esses exemplos, copie esta lista de códigos e cole-a em um arquivo chamado malloc.c. Essa lista será explicada em uma seção posterior, abaixo.

A alocação de memória na maioria dos sistemas operacionais é tratada por duas funções simples:

  • void *malloc(long numbytes): Aloca numbytes de memória e retorna um ponteiro para o primeiro byte.
  • void free(void *firstbyte): Fornecido um ponteiro retornado pelo malloc anterior, fornece o espaço alocado novamente para o "espaço livre" do processo.

malloc_init será a função para inicializar nosso alocador de memória. Ele efetua três tarefas: marca o alocador como inicializado, localiza o último endereço de memória válido no sistema e configura o ponto para o início da memória gerenciada. Essas três variáveis são globais:

Lista 1. Variáveis Globais do Nosso Alocador Simples
int has_initialized = 0;

void *managed_memory_start;

void *last_valid_address;

Como mencionado acima, o início da memória mapeada -- último endereço válido -- geralmente é conhecido como quebra do sistema ou quebra atual. Em muitos sistemas UNIX®, para localizar a quebra atual do sistema, é necessário utilizar a função sbrk(0). sbrk efetua a quebra atual do sistema pelo número de bytes em seu argumento e depois retorna a nova quebra do sistema. Chamá-lo com um argumento 0 simplesmente retornará a quebra atual. A seguir, nosso código de inicialização malloc, que localiza a quebra atual e inicializa nossas variáveis:

Lista 2. Função de Inicialização do Alocador
/* Incluir a função sbrk */

#include <unistd.h>

void malloc_init()

{

	/* obter o último endereço válido do S.O */

	last_valid_address = sbrk(0);


	/* Ainda não temos nenhuma memória para gerenciar, portanto,
	 *configuramos apenas o início como last_valid_address
	 */

	managed_memory_start = last_valid_address;

	/* Ok, já fizemos a inicialização e estamos prontos para começar */

 	has_initialized = 1;

}

Agora, para gerenciar corretamente a memória, é necessário rastrear o que estamos alocando e desalocando. É necessário efetuar tarefas como marcar blocos como não utilizados depois que free for chamado por eles e localizar blocos não utilizados quando malloc for chamado. Assim, o início de cada parte da memória retornado por malloc terá esta estrutura:

Lista 3. Definição da Estrutura do Bloco de Controle de Memória
struct mem_control_block {

	int is_available;

	int size;

};

Talvez você pense que isso possa causar problemas para os programas que chamam malloc -- como eles conhecem essa estrutura? A resposta é que eles não a conhecem; nós a ocultamos movendo o ponteiro para o final dessa estrutura antes de retorná-la. Isso faz com que o ponteiro retornado aponte para a memória que não foi utilizada para nenhum outro fim. Dessa forma, da perspectiva dos programas de chamada, tudo o que é obtido é a memória livre e aberta. Assim, quando eles retornam o ponteiro por meio de free(), é feito backup de alguns bytes de memória para localizar essa estrutura novamente.

Falaremos sobre a liberação de memória antes de tratarmos da alocação de memória, porque ela é mais simples. A única coisa que devemos fazer para liberar memória é obter o ponteiro fornecido, fazer backup dos bytes sizeof(struct mem_control_block) e marcá-lo como disponível. A seguir, o código para isso:

Lista 4. Função de Desalocação
void free(void *firstbyte) {

	struct mem_control_block *mcb;

	/* Fazer backup do ponteiro fornecido para localizar
	 * mem_control_block
	 */

	mcb = firstbyte - sizeof(struct mem_control_block);

	/* Marcar o bloco como estando disponível */

	mcb->is_available = 1;

	/* É isso!  Terminamos. */

	return;
}

Como é possível ver, neste alocador, a liberação de memória é feita constantemente, utilizando um mecanismo muito simples. A alocação de memória é um pouco mais complicada. Aqui está um esboço do algoritmo:

Lista 5. Pseudocódigo para o Alocador Principal
1. Se nosso alocador não tiver sido inicializado, inicialize-o.

2. Inclua sizeof(struct mem_control_block) no tamanho solicitado.

3. Comece em managed_memory_start.

4. Estamos no endereço last_valid?

5. Em caso afirmativo:

   A. Não localizamos nenhum espaço existente que fosse grande suficiente
      -- peça ao sistema operacional mais espaço e repita a operação.

6. Em caso negativo:

   A. O espaço atual está disponível (verifique o is_available de mem_control_block)?

   B. Se estiver:

      i)   Ele é suficientemente grande (verifique o "tamanho" de
           mem_control_block)?

      ii)  Se sim:

           a. Marque-o como indisponível

           b. Mova o mem_control_block mais recente e retorne o ponteiro

      iii) Caso contrário:

           a. Avance para os bytes de "tamanho"

           b. Volte para a etapa 4

   C. Caso contrário:

      i)   Avance para os bytes de "tamanho"

      ii)  Volte para a etapa 4

Basicamente, estamos percorrendo a memória utilizando os ponteiros vinculados, procurando por partes abertas. A seguir, o código:

Lista 6. O Alocador Principal
void *malloc(long numbytes) {

	/* Holds where we are looking in memory */

	void *current_location;

	/* This is the same as current_location, but cast to a
	 * memory_control_block
	 */

	struct mem_control_block *current_location_mcb;

	/* This is the memory location we will return.  It will
	 * be set to 0 until we find something suitable
	 */

	void *memory_location;

	/* Initialize if we haven't already done so */

	if(! has_initialized) 	{

		malloc_init();

	}

	/* The memory we search for has to include the memory
	 * control block, but the users of malloc don't need
	 * to know this, so we'll just add it in for them.
	 */

	numbytes = numbytes + sizeof(struct mem_control_block);

	/* Set memory_location to 0 until we find a suitable
	 * location
	 */

	memory_location = 0;

	/* Begin searching at the start of managed memory */

	current_location = managed_memory_start;

	/* Keep going until we have searched all allocated space */

	while(current_location != last_valid_address)

	{

		/* current_location and current_location_mcb point
		 * to the same address.  However, current_location_mcb
		 * is of the correct type, so we can use it as a struct.
		 * current_location is a void pointer so we can use it
		 * to calculate addresses.
		 */

		current_location_mcb =

			(struct mem_control_block *)current_location;

		if(current_location_mcb->is_available)

		{

			if(current_location_mcb->size >= numbytes)

			{

				/* Woohoo!  We've found an open,
				 * appropriately-size location.
				 */

				/* It is no longer available */

				current_location_mcb->is_available = 0;

				/* We own it */

				memory_location = current_location;

				/* Leave the loop */

				break;

			}

		}

		/* If we made it here, it's because the Current memory
		 * block not suitable; move to the next one
		 */

		current_location = current_location +

			current_location_mcb->size;

	}

	/* If we still don't have a valid location, we'll
	 * have to ask the operating system for more memory
	 */

	if(! memory_location)

	{

		/* Move the program break numbytes further */

		sbrk(numbytes);

		/* The new memory will be where the last valid
		 * address left off
		 */

		memory_location = last_valid_address;

		/* We'll move the last valid address forward
		 * numbytes
		 */

		last_valid_address = last_valid_address + numbytes;

		/* We need to initialize the mem_control_block */

		current_location_mcb = memory_location;

		current_location_mcb->is_available = 0;

		current_location_mcb->size = numbytes;

	}

	/* Now, no matter what (well, except for error conditions),
	 * memory_location has the address of the memory, including
	 * the mem_control_block
	 */

	/* Move the pointer past the mem_control_block */

	memory_location = memory_location + sizeof(struct mem_control_block);

	/* Return the pointer */

	return memory_location;

 }

Este é nosso gerenciador de memória. Agora só é necessário criá-lo e executá-lo com nossos programas.

Para criar nosso alocador compatível com malloc (algumas funções como realloc() estão ausentes, mas malloc() e free() são as principais), execute o seguinte comando:

Lista 7. Compilando o Alocador
gcc -shared -fpic malloc.c -o malloc.so

Isso irá produzir um arquivo chamado malloc.so, que é uma biblioteca compartilhada que contém nosso código.

Nos sistemas UNIX, agora é possível utilizar seu alocador no lugar do malloc() do sistema executando:

Lista 8. Substituindo o malloc Padrão
LD_PRELOAD=/path/to/malloc.so

export LD_PRELOAD

A variável de ambiente LD_PRELOAD faz com que o vinculador dinâmico carregue os símbolos da biblioteca compartilhada específica antes do carregamento de qualquer executável. Ela também dá preferência para os símbolos na biblioteca especificada. Assim, qualquer aplicativo iniciado a partir de agora nesta sessão estará utilizando nosso malloc() e não o do sistema. Algumas aplicações não usam malloc(), mas isso é uma exceção. Aqueles que utilizam outras funções de gerenciamento de memória, como realloc() ou que fazem suposições pobres sobre o comportamento interno de malloc() provavelmente travarão. O shell ash parece trabalhar bem apenas quando usa nosso novo malloc().

Se deseja certificar-se de que seu malloc() está sendo utilizado, deve testá-lo incluindo chamadas para write() nos pontos de entrada de suas funções.

Nosso gerenciador de memória deixa muito a desejar, mas ele é bom para mostrar o que um gerenciador de memória precisa fazer. Algumas de suas desvantagens incluem:

  • Como ele opera na quebra do sistema (uma variável global), não pode coexistir com nenhum outro alocador ou com mmap.
  • Ao alocar memória, em um cenário ruim, ele terá de percorrer toda a memória de um processo; isso também pode incluir muita memória alocada em disco, o que significa que o sistema operacional terá de gastar tempo movendo dados para o e a partir do disco.
  • Não há manipulação melhor para erros de falta de memória (o malloc simplesmente supõe êxito).
  • Ele não implementa muitas das outras funções de memória, como realloc().
  • Como sbrk() pode retornar mais memória do que a solicitada, falta memória no final do heap.
  • O sinalizador is_available utiliza uma palavra de 4 bytes inteira, mesmo se ela contiver 1 byte de informação.
  • O alocador não é thread-safe.
  • O alocador não pode juntar o espaço livre em blocos maiores.
  • O algoritmo de ajuste simples do alocador pode causar muita fragmentação de memória.
  • Há ainda, com certeza, muitos outros problemas. É por isso que este é apenas um exemplo!

Outras Implementação de malloc

Há muitas implementações de malloc(), cada uma com seus próprios pontos fortes e fracos. Há muitas decisões de trade-offs quando se cria um alocador, incluindo:

  • Velocidade da alocação
  • Velocidade da desalocação
  • Comportamento em um ambiente encadeado
  • Comportamento quando a memória está próxima de seu limite
  • Localidade do cache
  • Registro do gasto adicional de memória
  • Comportamento nos Virtual Memory Environments
  • Objetos pequenos ou grandes
  • Garantias em tempo real

Cada implementação tem seu próprio conjunto de benefícios e malefícios. Em nosso alocador simples, a velocidade da alocação era muito lenta, mas a desalocação era rápida. Além disso, devido ao seu comportamento limitado com sistemas de memória virtual, ele funcionava melhor em objetos grandes.

Há muitos outros alocadores disponíveis. Alguns deles incluem:

  • Doug Lea Malloc: Doug Lea Malloc é, na verdade, uma família completa de alocadores, incluindo o alocador original de Doug Lea, o alocador de GNU libc e ptmalloc. O alocador de Doug Lea possui uma estrutura básica, parecida com nossa versão, mas incorpora índices para fazer buscas mais rapidamente e tem a capacidade de combinar várias partes não utilizadas em uma parte maior. Ele também permite o armazenamento em cache para reutilizar a memória liberada recentemente mais rapidamente. ptmalloc é uma versão do Doug Lea Malloc que foi estendida para oferecer suporte a vários encadeamentos. Um documento descrevendo a implementação de Malloc de Doug Lea está disponível na seção Recursos, mais adiante neste artigo.
  • BSD Malloc: BSD Malloc, a implementação que foi distribuída com o BSD 4.2 e incluída com o FreeBSD, é um alocador que aloca objetos de conjuntos de objetos de tamanhos pré-determinados. Ele tem classes de tamanhos para tamanhos de objetos que sejam uma potência de 2 menos uma constante. Assim, se for solicitado um objeto de um determinado tamanho, ele simplesmente aloca esse objeto na classe de tamanho que ele se ajuste. Isso fornece uma implementação rápida, mas pode desperdiçar memória. Um documento descrevendo essa implementação está disponível na seção Recursos.
  • Hoard: Hoard foi escrito com o propósito de ser muito rápido em um ambiente multiencadeado. Então, ele foi estruturado para fazer melhor uso do bloqueio, para que qualquer processo precise esperar para alocar memória. Ele pode aumentar drasticamente a velocidade dos processos multiencadeados que executam alocação e desalocação. Um documento descrevendo essa implementação está disponível na seção Recursos.

Estes são os alocadores disponíveis mais conhecidos. Se seu programa tiver necessidades de alocação específicas, talvez seja melhor criar um alocador customizado que atenda às necessidades de alocação de memória de seu programa. No entanto, se você não estiver familiarizado com o design do alocador, alocadores customizados geralmente podem criar mais problemas do que resolvê-los. Para uma boa introdução sobre o assunto, consulte o artigo The Art of Computer Programming Volume 1: Fundamental Algorithms de Donald Knuth na seção 2.5, "Dynamic Storage Allocation" (consulte Recursos para obter um link). Ele é um bit datado, porque não leva em consideração os ambientes de memória virtual, mas a maioria dos algoritmos tem como base os ambientes apresentados ali.

No C++, é possível implementar seu próprio alocador por classe ou por modelo, sobrecarregando operator new(). Modern C++ Design de Andrei Alexandrescu descreve um pequeno alocador de objetos no Capítulo 4, "Small Object Allocation" (consulte Recursos para obter um link).

Desvantagens do Gerenciamento de Memória Baseado em malloc()

Não apenas nosso gerenciador de memória apresenta desvantagens, como também o gerenciamento de memória baseado em malloc(), que apresenta desvantagens não importa qual alocador seja utilizado. Gerenciar memória com malloc() pode ser muito difícil para os programas que têm armazenamento de execução longo, que precisa ser mantido. Se existirem muitas referências de memória em transmissão, geralmente é mais difícil saber quando elas devem ser liberadas. A memória cujo tempo de vida é limitado à função atual é muito mais fácil de gerenciar, mas as memórias que vão além disso se tornam muito mais difíceis. Além disso, muitas APIs não são claras, como se a responsabilidade do gerenciamento de memória fosse do programa de chamada ou da função chamada.

Devido aos problemas de gerenciamento de memória, muitos programas são orientado por suas regras de gerenciamento de memória. A manipulação de exceção do C++ torna esta tarefa ainda mais problemática. Às vezes parece que mais de um código está dedicado ao gerenciamento de alocação de memória e limpeza do que às tarefas computacionais que estão realizando no momento! Assim, examinaremos outras alternativas para o gerenciamento de memória.


Estratégias de Gerenciamento de Memória Semi-automáticas

Contagem de Referência

A contagem de referência é uma técnica de gerenciamento de memória semi-automatizada, o que significa que ela requer algum suporte do programador, mas ele não precisa saber com precisão quando um objeto não será mais usado. O mecanismo de contagem de referência faz isso para você.

Na contagem de referência, todas as estruturas de dados compartilhadas têm um campo que contém diversas "referências" atualmente ativas para essa estrutura. Quando um procedimento é transmitido a um ponteiro para uma estrutura de dados, ele é incluído na contagem de referência. Basicamente, está sendo informado à estrutura de dados quantas alocações estão sendo armazenadas nela. Então, quando seu procedimento é concluído usando-o, a contagem de referência diminui. Quando isso ocorre, ele também verifica se a contagem chegou a zero. Se isso ocorreu, libera memória.

A vantagem disso é que não é necessário seguir cada caminho em seu programa que uma determinada estrutura de dados possa seguir. Cada referência localizada simplesmente aumenta ou diminui a contagem de forma adequada. Isso impede a liberação durante o uso. No entanto, lembre-se de executar as funções de contagem de referência sempre que estiver usando uma estrutura de dados contados pela referência. Além disso, as funções integradas e as bibliotecas de terceiros não saberão ou não poderão utilizar seu mecanismo de contagem de referência. A contagem de referência também tem dificuldades com estruturas que possuem referências circulares.

Para implementar a contagem de referência, são necessárias duas funções -- uma para aumentar a contagem de referência e outra para diminuí-la e liberar a memória quando ela chegar a zero.

Um exemplo do conjunto de funções de contagem de referência pode se assemelhar a:

Lista 9. Funções da Contagem de Referência Básica
/* Definições de Estrutura*/

/* Estrutura base que mantém uma refcount */

struct refcountedstruct

{

	int refcount;

}

/* Todas as estruturas de refcounted deve espelhar a estrutura
 * refcountedstruct quanto às suas primeiras variáveis
 */

/* Funções de manutenção de refcount */

/* Aumentar contagem de referência */

void REF(void *data)

{

	struct refcountedstruct *rstruct;

	rstruct = (struct refcountedstruct *) data;

	rstruct->refcount++;

}

/* Diminuir contagem de referência */

void UNREF(void *data)

{

	struct refcountedstruct *rstruct;

	rstruct = (struct refcountedstruct *) data;

	rstruct->refcount--;

	/* Liberar a estrutura se não houver mais usuários */

	if(rstruct->refcount == 0)

	{

		free(rstruct);

	}

}

REF e UNREF podem ser mais complicados, dependendo do que deseja-se fazer. Por exemplo, é possível querer incluir bloqueio para um programa multiencadeado e desejar estender refcountedstruct para que ele também inclua um ponteiro para uma função a ser chamada antes da liberação de memória (como um destruidor nas linguagens orientadas a objeto -- isso é necessário quando suas estruturas contêm ponteiros).

Ao utilizar REF e UNREF, será necessário obedecer às seguintes regras de designação de ponteiros:

  • UNREF o valor ao qual o ponteiro esquerdo está apontando antes da designação.
  • REF o valor ao qual o ponteiro esquerdo está apontando após a designação.

Nas funções que são estruturas refcounted transmitidas, as funções precisam seguir estas regras:

  • REF - cada ponteiro no início da função.
  • UNREF - todo ponteiro no final da função.

A seguir, um rápido exemplo de código utilizando a contagem de referência:

Lista 10. Exemplo Utilizando a Contagem de Referência
/* EXEMPLOS DE USO */


/* Tipo de dado para ser refcounted */

struct mydata

{

	int refcount; /* igual a refcountedstruct */

	int datafield1; /* Campos específicos para esta estrutura */

	int datafield2;

	/* outras declarações devem vir aqui, se adequado */

};


/* Usar as funções no código */

void dosomething(struct mydata *data)

{

	REF(data);

	/* Processar dados */

	/* quando estiver em */

	UNREF(data);

}


struct mydata *globalvar1;

/* Observe que aqui não diminuímos
 * refcount, pois estamos mantendo a referência
 * após o final da chamada de função através da
 * variável global
 */

void storesomething(struct mydata *data)

{

	REF(data); /* transmitido como parâmetro */

	globalvar1 = data;

	REF(data); /* ref por causa da Designação */

	UNREF(data); /* Função concluída */

}

Como a contagem de referência é muito simples, a maioria dos programadores a implementam, em vez de utilizarem bibliotecas. Mas eles dependem de alocadores de baixo nível, como malloc e free para a alocação real e liberação de memória.

A contagem de referência é utilizada em linguagens de alto nível, como Perl, para o gerenciamento de memória. Nessas linguagens, a contagem de referência é identificada automaticamente pela linguagem, de modo que não seja necessário preocupar-se com exceções ao criar módulos de extensão. Isso diminui a velocidade porque tudo deve ser contado como referência, mas aumenta a segurança e facilita a programação. Estes são os benefícios:

  • Implementação simples.
  • Facilidade de uso.
  • Como a referência faz parte da estrutura de dados, possui boa localidade de cache.

No entanto, ela também apresenta desvantagens:

  • Nunca se deve esquecer de chamar as funções de contagem de referência.
  • Não libera estruturas que fazem parte de uma estrutura de dados circular.
  • Diminui praticamente toda designação de ponteiro.
  • É necessário tomar precauções adicionais ao utilizar a manipulação de exceção (como try ou setjmp()/longjmp()) enquanto utiliza objetos contados como referência.
  • Requer memória extra para lidar com as referências.
  • O contador de referências obtém a primeira posição na estrutura, que é a mais veloz para acessar a maioria das máquinas.
  • É mais lenta e mais difícil de fazer em um ambiente multiencadeado.

O C++ pode mitigar alguns dos erros do programador usando ponteiros inteligentes, que podem manipular detalhes do ponteiro, como a contagem de referência, para você. No entanto, se for necessário utilizar qualquer código herdado que não possa manipular seus ponteiros inteligentes (como um vínculo para uma biblioteca C, será muito mais difícil e complicado utilizá-los do que não utilizá-los. Então, isso é geralmente útil apenas para projetos em C++. Se quiser usar os ponteiros inteligentes, será necessário ler o capítulo "Smart Pointers" do livro Modern C++ Design de Alexandrescu.

Conjuntos de Memórias

Conjuntos de memória são outro método para gerenciamento de memória semi-automático. Os conjuntos de memória ajudam a automatizar o gerenciamento de memória para programas que percorrem estágios específicos, cada um deles com memórias alocadas apenas para estágios específicos do processamento. Por exemplo, muitos processos de servidores de rede possuem muita memória alocada por conexão -- memória cujo tempo de vida máximo é a vida da conexão atual. A Apache, que usa a memória em conjunto, tem suas conexões divididas em estágios, cada uma delas com seu próprio conjunto de memória. No final do estágio, todo o conjunto de memória é liberado de uma vez.

No gerenciamento da memória em conjunto, cada alocação especifica um conjunto de memória do qual ela deve ser alocada. Cada conjunto tem um tempo de vida diferente. Na Apache, há um conjunto que dura o tempo de vida do servidor, um que dura o tempo de vida da conexão, outro que dura o tempo de vida dos pedidos, etc. Assim, se eu possuo uma série de funções que não irão gerar nenhum dado que durará por mais tempo que a conexão, é possível apenas alocá-las do conjunto de conexão, sabendo que no final da conexão elas serão liberados automaticamente. Adicionalmente, algumas implementações permitem registrar funções de limpeza, que são chamadas logo após a limpeza do conjunto de memória, para efetuar tarefas adicionais que precisam ser feitas antes que a memória seja limpa (semelhante aos destruidores, para conjuntos orientados a objetos).

Para utilizar conjuntos em seus próprios programas, é possível utilizar a implementação de obstack de GNU libc ou o Apache Portable Runtime da Apache. Os obstacks de GNU são bons, pois são incluídos por padrão nas distribuições Linux baseadas em GNU. O Apache Portable Runtime também é bom porque possui muitos outros utilitários para lidar com todos os aspectos da criação de softwares de servidor de multiplataforma. Para obter informações adicionais sobre os obstacks de GNU e a implementação de memória em conjunto da Apache, consulte os links para suas documentações na seção Recursos.

A seguinte lista de código hipotética mostra como os obstacks são usados:

Lista 11. Exemplo de Código para obstacks
#include <obstack.h>

#include <stdlib.h>

/* Exemplo de lista de código para utilizar obstacks */

/* Utilizado para macros de obstack (xmalloc é uma
   função de malloc que é encerrada quando a memória
   chega à exaustão */

#define obstack_chunk_alloc xmalloc

#define obstack_chunk_free free

/* Conjuntos */

/* Somente alocações permanentes devem ir neste conjunto */

struct obstack *global_pool;

/* Este conjunto destina-se a dados por conexão */

struct obstack *connection_pool;

/* Este conjunto destina-se a dados por pedido */

struct obstack *request_pool;

void allocation_failed()

{

	exit(1);

}

int main()

{

	/* Inicializar conjuntos */

	global_pool = (struct obstack *)

		xmalloc (sizeof (struct obstack));

	obstack_init(global_pool);

	connection_pool = (struct obstack *)

		xmalloc (sizeof (struct obstack));

	obstack_init(connection_pool);

	request_pool = (struct obstack *)

		xmalloc (sizeof (struct obstack));

	obstack_init(request_pool);

	/* Configurar a função de manipulação de erros */

	obstack_alloc_failed_handler = &allocation_failed;

	/* Loop principal do servidor */

	while (1) {

wait_for_connection();

		/* Estamos em uma conexão */

		while(more_requests_available())

		{

			/* Manipular pedido */

			handle_request();

			/* Liberar toda memória alocada

			 * no conjunto de pedidos

			 */

			obstack_free(request_pool, NULL);

		}

		/* Conexão encerrada, hora de

		 * liberar esse conjunto

		 */

		obstack_free(connection_pool, NULL);

	}

}

int handle_request()

{

	/* Certifique-se de que todas as alocações de objetos estejam alocadas
	 * a partir do conjunto de pedidos
	 */

	int bytes_i_need = 400;

	void *data1 = obstack_alloc(request_pool, bytes_i_need);

	/* Execute as tarefas para processar o pedido */

	/* return */

	return 0;
}

Basicamente, depois de cada estágio importante da operação, o obstack desse estágio é liberado. Observe, no entanto, que se um procedimento precisar alocar memória que não durará mais que o estágio atual, ele também poderá usar um obstack de longo prazo, como a conexão ou o obstack global. O NULL que é transmitido para obstack_free() indica que todo o conteúdo de obstack deve ser liberado. Outros valores estão disponíveis, mas geralmente não são úteis.

Os benefícios do uso da alocação de memória em conjunto incluem:

  • Facilidade para gerenciar memória para o aplicativo.
  • A alocação e desalocação de memória são muito mais rápidas, pois tudo é feito em um conjunto ao mesmo tempo. A alocação pode ser feita em O(1) hora e a liberação do conjunto é fechada (geralmente O(n) hora, mas dividida por um fator grande que constitui geralmente O(1)).
  • Os conjuntos de manipulação de erros podem ser pré-alocados de modo que seu programa ainda possa ser recuperado se a memória regular chegar à exaustão.
  • Há implementações padrão que são muito fáceis de usar.

As desvantagens da memória em conjunto são:

  • Os conjuntos de memória são úteis apenas para programas que operam em estágios.
  • Os conjuntos de memória geralmente não trabalham bem com bibliotecas de terceiros.
  • Se a estrutura do programa mudar, os conjuntos poderão ser modificados, o que pode levar a uma nova designação do sistema de gerenciamento de memória.
  • É necessário lembrar-se qual conjunto precisa alocar. Além disso, se fizer isso incorretamente, pode ser difícil obtê-lo.

Coleta de Lixo

Coleta de Lixo é a detecção totalmente automática e remoção de objetos de dados que não estão mais em uso. Os coletores de lixo geralmente são executados quando a memória disponível chega abaixo de um limite específico. Geralmente, eles começam com um conjunto "base" de dados que sabe-se que estão disponíveis para o programa -- dados de pilha, variáveis globais e registros. Depois eles tentam rastrear cada parte dos dados vinculados através deles. Tudo o que o coletor encontra é dado válido; tudo o que não encontra é lixo e pode ser destruído e reutilizado. Para gerenciar a memória mais efetivamente, muitos tipos de coletores de lixo exigem que se conheça o layout dos ponteiros dentro das estruturas de dados e, então, fazer parte da própria linguagem para o funcionamento correto.

Tipos de Coletores

  • Cópia: Divide o armazenamento de memória em duas partes e permite que os dados existam apenas de um lado. Periodicamente, os dados começam a ser copiados de um lado para outro começando com elementos "base". A seção recentemente ocupada da memória torna-se ativa e tudo no outro lado é considerado lixo. Além disso, quando essa cópia ocorre, todos os ponteiros precisam ser atualizados para o novo local de cada item da memória. Assim, para utilizar esse método de coleta de lixo, o coletor deve estar embarcado com a linguagem de programação.
  • Marca e Tempo de Acesso: Cada parte do dado é marcada com uma tag. Ocasionalmente, todas as tags são configuradas como 0 e o coletor percorre os dados começando pelos elementos "base". À medida que ele encontra a memória, marca a tag como 1. Tudo que o não é marcado como 1 no final é considerado lixo e reutilizado para alocações posteriores.
  • Incremental: Os coletores de lixo incrementais não exigem execução total através de todos os objetos de dados. A execução através da memória causa problemas porque todos os objetos aguardam de uma vez durante o período de coleta e devido a problemas de cache associados ao acesso de todos os dados atuais (tudo precisa ser paginado). Os coletores incrementais evitam esses problemas.
  • Conservador: Os coletores de lixo conservadores não precisam saber nada sobre a estrutura de seus dados para gerenciar a memória. Eles simplesmente olham todos os bytes de dados e assumem que eles poderiam ser ponteiros. Assim, se uma sequência de bytes puder ser um ponteiro para uma parte da memória alocada, ela será marcada como referência. Isso, às vezes, leva a problemas nos quais a memória que não era referência é coletada quando, por exemplo, um campo inteiro contém um valor que era o endereço da memória alocada. No entanto, isso ocorre raramente e desperdiça pouca memória. Os coletores conservadores têm a vantagem de poderem ser embarcados com qualquer linguagem de programação.

O coletor de lixo conservador de Hans Boehm é um dos coletores de lixo mais populares, pois é gratuito e, além de ser conservador, é incremental. É possível utilizá-lo como uma substituição informal de seu alocador de sistema (utilizando malloc/free em vez de sua própria API) criando-o com --enable-redirect-malloc. Na verdade, quando isso ocorre, é possível utilizar o mesmo truque de LD_PRELOAD que foi utilizado para nosso simples alocador, para ativar a garbage collectorna maioria dos programas em seu sistema. Se desconfia que um programa está sem memória, é possível utilizar esse coletor de lixo para manter o tamanho do processo baixo. Muitas pessoas utilizavam essa técnica anteriormente no Mozilla quando faltava muita memória. Esse coletor de lixo é executado no Windows® e no UNIX.

Algumas vantagens da coleta de lixo:

  • Não é necessário preocupar-se com a dupla liberação de memória ou ciclos de vida do objeto.
  • É possível, com alguns coletores, utilizar a mesma API usada para a alocação normal.

As desvantagens incluem:

  • Com a maioria dos coletores, não há informações de quando sua memória será liberada.
  • Em muitos casos, a garbage collectoré mais lenta do que outras formas de gerenciamento de memória.
  • Erros causados pela garbage collectorsão difíceis de depurar.
  • Ainda é possível ter falhas de memória se esquecer de configurar ponteiros não utilizados como nulos.

Conclusão

É um mundo de trade-offs: desempenho, facilidade de uso, facilidade de implementação e capacidade de encadeamento, para citar apenas alguns. Há vários padrões de gerenciamento de memória à sua disposição para atender aos requisitos do seu projeto. Cada padrão tem uma grande variedade de implementações, cada uma com suas vantagens e desvantagens. Utilizar as técnicas padrão para seu ambiente de programação é adequado a muitos projetos, mas conhecer as opções disponíveis o ajudará quando seu projeto tiver necessidades especiais. Esta tabela compara as estratégias de gerenciamento de memória abrangidas neste artigo.

Tabela 1. Comparação de Estratégias de Alocação de Memória
EstratégiaVelocidade de AlocaçãoVelocidade de DesalocaçãoLocalidade do CacheFacilidade de UsoGeneralidadeUso em Tempo RealAmigável a SMP e Encadeamento
Alocador Customizado Depende da implementação Depende da implementação Depende da implementação Muito difícil None Depende da implementação Depende da implementação
Alocador simplesRápido para uso de memória pequenoMuito rápidoPobre Muito Fácil Não Não
GNU mallocModerado Rápido Moderado Muito Fácil Não Moderado
Hoard Moderado Moderado Moderado Muito Fácil Não Sim
Contagem de Referência N/D N/D Excelente Moderado Moderado Sim (depende da implementação de malloc) Depende da implementação
Em conjunto Moderado Muito rápido Excelente Moderado Moderado Sim (depende da implementação de malloc) Depende da implementação
Coleta de Lixo Moderado (lento quando ocorre a coleta) Moderado Pobre Moderado Moderado Não Raramente
Garbage collectorincremental Moderado Moderado Moderado Moderado Moderado Não Raramente
Garbage collectorconservador incremental Moderado Moderado Moderado Muito Fácil Não Raramente

Recursos

Aprender

Obter produtos e tecnologias

Discutir

Comentários

developerWorks: Conecte-se

Los campos obligatorios están marcados con un asterisco (*).


Precisa de um ID IBM?
Esqueceu seu ID IBM?


Esqueceu sua senha?
Alterar sua senha

Ao clicar em Enviar, você concorda com os termos e condições do developerWorks.

 


A primeira vez que você entrar no developerWorks, um perfil é criado para você. Informações no seu perfil (seu nome, país / região, e nome da empresa) é apresentado ao público e vai acompanhar qualquer conteúdo que você postar, a menos que você opte por esconder o nome da empresa. Você pode atualizar sua conta IBM a qualquer momento.

Todas as informações enviadas são seguras.

Elija su nombre para mostrar



Ao se conectar ao developerWorks pela primeira vez, é criado um perfil para você e é necessário selecionar um nome de exibição. O nome de exibição acompanhará o conteúdo que você postar no developerWorks.

Escolha um nome de exibição de 3 - 31 caracteres. Seu nome de exibição deve ser exclusivo na comunidade do developerWorks e não deve ser o seu endereço de email por motivo de privacidade.

Los campos obligatorios están marcados con un asterisco (*).

(Escolha um nome de exibição de 3 - 31 caracteres.)

Ao clicar em Enviar, você concorda com os termos e condições do developerWorks.

 


Todas as informações enviadas são seguras.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=80
Zone=Linux
ArticleID=382596
ArticleTitle=Por dentro do Gerenciamento de Memória
publish-date=11162004