Gerenciamento de recursos em linguagens de programação

É época de carnaval e provavelmente muito menos pessoas irão ler esse texto, mas, ainda… eu irei digitar, pois há essa necessidade estranha que eu tenho de me agarrar a essa pequena impressão de que o tempo está me tornando melhor, de alguma forma…

Pois bem, gerenciar recursos é um dos maiores desafios presentes em linguagens de programação e, dois indícios fortes que posso citar para sustentar esse argumento fraco é a existência do Buffer Overflow, um dos tipos de ataques mais explorados na história da computação que torna-se possível devido ao mau gerenciamento de memória, e a quantidade de recursos que investimos em ferramentas como a Valgrind.

Pretendo, nesse post, demonstrar o problema, apresentar conceitos simples e, logo após, analisar duas formas de gerenciamento de memória bem comuns, RAIIGarbage Collection.

E no princípio, tínhamos ponteiros…

Um ambiente familiar cria conforto para algumas pessoas, incluindo a mim e, sendo assim, começarei essa parte do texto com algo que me é familiar, um código-fonte:

Os programas que criamos gerenciam recursos e, no princípio, sequer existiam abstrações para procedimentos. O que dizer sobre abstrações para gerenciamento de recursos, então? O código anterior exemplifica o problema de lidar com recursos.

No exemplo, mesmo havendo apenas dois recursos a se gerenciar, torna-se evidente que esse modelo de programação torna o código mais complexo, dificulta a leitura e nos distancia do problema que queremos resolver, nos forçando a pensar em como os recursos se comportam e sendo fácil cometer erros como esquecer de liberar algum recurso ou liberá-los na ordem errada.

Sempre que inicializarmos um novo recurso, devemos verificar se a inicialização falhou, caso em que devemos liberar os recursos que haviam sido adquiridos antes de usar alguma construção da linguagem para quebrar o fluxo de instruções. É importante também analisar cuidadosamente o fluxo de instruções e detectar em quais momentos qualquer um dos recursos pode tornar-se inacessível, ponto em que devemos inserir mais código, redundante, de limpeza. Por último, e não menos importante, é uma boa prática liberar os recursos na ordem contrária em que eles foram adquiridos, para evitar referências inválidas, caso no qual um recurso referencia um outro recurso que já foi liberado.

Como eu chamaria toda essa bagunça, confusão, sujeira, desordem, porcaria, trapalhada, barafunda, balbúrdia, embaraço… que esse modelo de programação está criando? Código espaguete?

Formalizando…

Há o gerenciamento errôneo de um recurso quando:

  • Um recurso é destruído antes de ser usado. Nesse caso referências inválidas para o recurso irão existir e o uso dessas referências pode criar um comportamento imprevisível cuja origem será difícil de rastrear ou o encerramento da aplicação.
  • O recurso não é destruído após ser utilizado. Nesse caso chamamos o erro de vazamento de recurso e isso impede que poder computacional seja reciclado para outra tarefa.

Podemos evitar o primeiro problema deixando de fazer a limpeza de recursos manualmente, mas com as técnicas que (não) discutimos, essa decisão implica o segundo problema.

Há três definições diferentes que podemos usar para nos referir a vazamento de recursos.

A definição do usuário

Do ponto de vista do usuário:

Um recurso vazado é qualquer recurso que não pode ser possivelmente utilizado para qualquer propósito útil.

A definição poderia ser simplificada para “qualquer recurso que eu, como usuário, não estou interessado”, mas essa definição incluiria o uso de cache, por exemplo, o que seria inutilizável na nossa pesquisa.

A definição do desenvolvedor

Do ponto de vista do desenvolvedor:

Um recurso vazado é qualquer recurso que não é alcançável.

Um recurso alcançável é qualquer recurso para o qual exista uma referência, também alcançável, que você possa utilizar para acessá-lo. Deve-se notar que essa é uma definição recursiva e, quando o recurso observado é memória, podemos quebrar essa recursão afirmando que qualquer objeto alocado na stack é alcançável.

Essa é uma definição bem mais formal e, como consequência, é mais fácil criar uma ferramenta para detectar vazamentos de recursos quando utilizamos essa definição.

Entretanto, ela é menos abrangente que a definição do ponto de vista de usuário e um exemplo simples que, segundo essa definição, não apresenta nenhum vazamento de recursos, mas, de acordo com a definição do ponto de vista de usuário, possui um vazamento de recurso, segue:

No exemplo anterior, a variável buffer contém referência a um recurso que não é mais útil quando o fluxo de repetição presente é quebrado, mas, ainda assim, o recurso é alcançável. Essa situação exemplifica a diferença entre as duas definições.

É garantido que um objeto inalcançável não será usado novamente, mas o contrário não é verdade. Algumas pessoas sugerem que essa definição seja uma forma de “aproximação”.

A definição do depurador

Do ponto de vista do depurador:

Um recurso vazado é um recurso que não foi destruído durante o encerramento da aplicação.

Essa é uma definição muito usada em programas de detecção de vazamentos de memória, como Valgrind ou Visual Studio.

Uma situação onde essa definição se distingue da definição do ponto de vista do usuário é a mesma situação demonstrada anteriormente.

Outra situação onde ela se distingue é quando um recurso é utilizado até o final da aplicação, mas não é destruído. Caso o recurso em questão seja memória, isso não costuma ser um problema, pois sistemas operacionais modernos irão lidar com essa situação. Além disso, o objetivo do programa é solucionar um problema do usuário e, assim, a definição do usuário deveria ter precedência caso não haja outras questões envolvidas (como manutenção de código, desempenho, …).

Garbage Collection

Uma das soluções que foi sugerida para resolver esse problema é chamada de Garbage Collection. A premissa é que o desenvolvedor não deveria se preocupar com o gerenciamento de recursos e alguma outra ferramenta, como o compilador ou o interpretador da máquina virtual, é que devia lidar com essa tarefa. Essa técnica depende de uma estratégia e um algoritmo e é a solução que linguagens como Java adotam.

Para automatizar o gerenciamento de memória, a definição do ponto de vista do programador é utilizada. Então a pergunta que resta para solucionar o problema é “como podemos saber que um objeto nunca mais será usado novamente?”.

Estratégias de Garbage Collection decidem quando a limpeza de recursos não utilizados deve ocorrer e podem ser eventos como “quando a memória acabar” ou “a cada 5 minutos”. Alguns algoritmos serão discutidos isoladamente a seguir, mas eles não deveriam depender de uma estratégia específica. Uma forma de conseguir um algoritmo de Garbage Collection desacoplado da estratégia é projetando um algoritmo in situ.

Mark and Sweep

Mark and Sweep é um algoritmo de Garbage Collection que obriga que todos os objetos tenham o campo mark bit e cujo funcionamento acontece em duas fases, mark e sweep.

Na fase mark, o algoritmo deve identificar os objetos alcançáveis, marcando-os através do campo mark bit. Um pseudocódigo para essa fase segue:

No pseudocódigo anterior, root é a variável que contém os objetos raízes, que são objetos localizados na stack.

Na fase sweep, todos os objetos devem ser vasculhados. Os que tiverem o campo mark bit com o valor falso devem ser destruídos e os objetos restantes devem ter o campo mark bit atribuídos ao valor falso. Um pseudocódigo segue:

O algoritmo apresentado não é in situ, como observado, pela recursão que acontece na fase mark. É possível remover essa recursão usando uma lista, mas a lista em si faz com que o algoritmo não seja in situ. Um truque usado para transformar o algoritmo mark and sweep em um algoritmo in situ é inverter os ponteiros quando nós os seguimos, fazendo o objeto observado apontar para o objeto pai. Dessa forma, podemos armazenar a trajetória de quais objetos foram observados sem usar qualquer espaço extra quando o algoritmo é executado.

Stop and Copy

No algoritmo Stop and Copy, a região de memória é dividida em duas metades, uma reservada para a aplicação e outra reservada para o Garbage Collector. Quando o algoritmo é executado, ele começa a rastrear os objetos alcançáveis e, sempre que um novo objeto alcançável é encontrado, ele é movido para a região de memória reservada para o Garbage Collector e toda referência para esse objeto é atualizada. Ao final do processo, a região de memória reservada ao Garbage Collector passa a ser a região de memória reservada a aplicação e vice-versa.

Uma forma de atualizar as referências aos objetos é, no momento em que um objeto for copiado, utilizar a região na qual ele residia para armazenar um ponteiro que aponta para sua nova região.

Garantir a propriedade de algoritmo in situ nesse caso é bem mais fácil e uma forma de se fazer isso seria dividir a região reservada ao Garbage Collector em 3 partes, sendo a primeira a de objetos analisados e copiados, a segunda apenas de copiados e a terceira a região livre. Tudo que é necessário para manter esse modelo de memória são dois ponteiros, além do ponteiro que aponta para o começo da região de memória.

Exceptions

Exceções formam uma técnica de tratamento de erros que visa diminuir o número de erros e melhorar a manutenção de código. Um assunto interessante, de fato, porém, não é um objetivo desse texto explicá-las. É um objetivo, entretanto, explicar que elas impõem novas restrições nas soluções de gerenciamento de recursos. Considere o seguinte código:

No exemplo anterior, o que acontece quando uma exceção é lançada antes do arquivo ser fechado? A linguagem Java permite que objetos tenham destrutores, métodos que executam instruções quando a máquina virtual destruir o objeto. Nada impede que a classe FileReader implemente um destrutor que chame o método close, mas, por outro lado, não há garantias de quando, ou mesmo se, o objeto será destruído. O comportamento não é determinístico.

A falta de determinismo pode parecer um problema pequeno a olhos desatentos, mas é um problema tão sério que o tratamento de exceções de linguagens como Java tem uma palavra-chave extra, a palavra-chave finally.

Considere o caso em que o programa fica aberto por bastante tempo ou trata uma quantidade de arquivos razoavelmente grande. Vários sistemas operacionais seguros costumam impor limites configuráveis aos processos e há um limite também para o número de arquivos abertos. O limite de arquivos abertos por processo, por exemplo, no sistema que estou usando nesse momento, é 1024. Recursos são finitos e não é desejável desperdiçá-los.

O bloco finally sempre será executado, independente se uma exceção foi lançada ou não.

Eu não quero discutir aqui, nesse momento, os impactos na performance introduzidos pelo uso de Garbage Collection, mas Garbage Collection, de fato, resolve o problema de gerenciar memória. Entretanto, esse texto é sobre gerenciamento de recursos, um problema mais geral e, por outro lado, o principal foco das técnicas de Garbage Collection é o gerenciamento de memória.

RAII

Agora que você já conhece a proposta Garbage Collection, é chegado o momento de conhecer a técnica Resource Acquisition Is Intialization, ou RAII, para economizar. A técnica, em contraste com Garbage Collection, assume que a responsabilidade de gerenciar os recursos do programa não é inteiramente do compilador/interpretador. Essa é a solução adotada por linguagens como C++.

A ideia é que todos os objetos tenham um construtor e um destrutor, sejam eles fornecidos por você ou pelo compilador. Quando um objeto entra em escopo, ele é construído, e quando sai de escopo, ele é destruído, havendo assim um gerenciamento de recursos automático e determinístico.

Como mostra o exemplo anterior, você não precisa criar seus próprios construtores e destrutores para se beneficiar dessa técnica. Por ser determinístico, a técnica RAII pode ser utilizada onde a técnica GC não seria, sozinha, o suficiente, o que exigiria o uso de finally. Um uso é demonstrado no exemplo a seguir:

O código anterior mostra a simplicidade e elegância da técnica RAII. O RAII exige que você encapsule cada tipo de recurso em sua própria classe, o que poderia significar uma quantidade maior de código, porém, apesar de serem ambos comuns, aquisições de recursos são bem mais comuns que recursos personalizados que precisam de comportamento especializado. Um fato que ajuda ainda mais a aumentar a produtividade é a presença de abstrações genéricas como unique_ptr e shared_ptr que encapsulam comportamentos comuns e podem ser usadas para qualquer classe.

Referências

Além da experiência de memórias profundas cuja origem não é mais rastreável, esse post foi baseado, principalmente, nas aulas sobre Garbage Collection do Alex Aiken disponíveis no Coursera e em alguns artigos das edições 106 e 107 da revista Overload.

Esse texto não traz nenhuma contribuição nova para a área e o único diferencial entre ele e as referências citadas é o conteúdo em português. Entretanto, esse texto pode ajudar a divulgar conhecimento útil e alimentar a discussão sobre o tema. Além disso, até onde lembro, não é plágio se você citar as referências, então minha consciência permanece limpa, caso ela exista.

EDIT (2015/01/15):

Exemplo de tentativa desesperada em C.

Tags:, ,

Comentários (with MarkDown support)

Preencha os seus dados abaixo ou clique em um ícone para log in:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s

%d blogueiros gostam disto: