IDisposable - que sua mãe não falou em liberar recursos. Parte 1

Esta é uma tradução da primeira parte do artigo. O artigo foi escrito em 2008. Após 10 anos, quase perdeu sua relevância.


Liberação determinística de recursos - uma necessidade


Ao longo de mais de 20 anos de experiência em codificação, às vezes desenvolvi meus próprios idiomas para resolver problemas. Eles variavam de linguagens simples e imperativas a expressões regulares especializadas para árvores. Ao criar idiomas, existem muitas recomendações e algumas regras simples não devem ser violadas. Um deles:


Nunca crie um idioma de exceção no qual não haja liberação determinística de recursos.

Adivinha quais recomendações o tempo de execução do .NET não segue e, como resultado, todos os idiomas baseados nele?


O motivo dessa regra existir é que a liberação determinística de recursos é necessária para criar programas suportados . A liberação determinada de recursos fornece um certo ponto no qual o programador tem certeza de que o recurso foi liberado. Existem duas maneiras de escrever programas confiáveis: a abordagem tradicional é liberar recursos o mais cedo possível e a abordagem moderna é liberar recursos por tempo indeterminado. A vantagem da abordagem moderna é que o programador não precisa liberar recursos explicitamente. A desvantagem é que é muito mais difícil escrever um aplicativo confiável, existem muitos erros sutis. Infelizmente, o tempo de execução do .NET foi criado usando uma abordagem moderna.


O .NET suporta a liberação não determinística de recursos usando o método Finalize , que tem um significado especial. Para liberação determinística de recursos, a Microsoft também adicionou a interface IDisposable (e outras classes, as quais discutiremos mais adiante). No entanto, para o tempo de execução, IDisposable é uma interface normal, como todos os outros. Esse status de "segunda categoria" cria algumas dificuldades.


No C #, a "versão determinística para os pobres" pode ser implementada usando as try e finally ou using (que é quase a mesma coisa). A Microsoft vem discutindo há muito tempo se deve ou não fazer contagem de links, e me parece que a decisão errada foi tomada. Como resultado, para liberação determinística de recursos, você precisa usar as construções desajeitadas finally \ using ou uma chamada direta para IDisposable.Dispose , que está repleta de erros. Para um programador C ++ que está acostumado a usar o shared_ptr<T> duas opções não são atraentes. (A última frase deixa claro onde o autor tem esse relacionamento - aprox.


IDisposable


IDisposable é uma solução para liberação determinística de recursos oferecidos pela Misoftro. Um é para os seguintes casos:


  • Qualquer tipo que possua recursos gerenciados ( IDisposable ). Um tipo deve necessariamente possuir , isto é, gerenciar o tempo de vida, os recursos e não apenas se referir a eles.
  • Qualquer tipo que possua recursos não gerenciados.
  • Qualquer tipo que possua recursos gerenciados e não gerenciados.
  • Qualquer tipo herdado de uma classe que implementa IDisposable . Não recomendo herdar de classes que possuem recursos não gerenciados. Melhor usar um anexo.

IDisposable ajuda a liberar recursos deterministicamente, mas tem seus próprios problemas.


Dificuldades IDisposable - Usabilidade


Objetos IDisposable estão IDisposable usar bastante pesado. O uso de um objeto deve ser envolvido em uma construção using . A má notícia é que o C # não permite o using com um tipo que não implementa IDisposable . Portanto, o programador deve consultar a documentação todas as vezes para entender se é necessário escrever using , ou apenas escrever using qualquer lugar, e depois apagar onde o compilador jura.


C ++ gerenciado é muito melhor nesse sentido. Ele suporta semântica de pilha para tipos de referência , que funciona como somente para tipos quando necessário. O C # pode se beneficiar da capacidade de escrever using qualquer tipo.


Este problema pode ser resolvido com. ferramentas de análise de código. Para piorar a situação, se você esquecer de usar, o programa pode passar nos testes, mas travar enquanto trabalha "nos campos".


Em vez de contar os links, o IDisposable tem outro problema - determinar o proprietário. Quando em C ++ a última cópia do shared_ptr<T> sai do escopo, os recursos são liberados imediatamente, sem a necessidade de pensar em quem deve liberar. IDisposable pelo contrário, força o programador a determinar quem "possui" o objeto e é responsável por liberá-lo. Às vezes, a propriedade é óbvia: quando um objeto encapsula outro e ele implementa IDisposable , ele é responsável pela liberação de objetos filhos. Às vezes, a vida útil de um objeto é determinada por um bloco de código, e o programador simplesmente usa o using torno desse bloco. No entanto, existem muitos casos em que um objeto pode ser usado em vários lugares e é difícil determinar sua vida útil (embora, neste caso, a contagem de referência funcione perfeitamente).


Dificuldades IDisposable - Compatibilidade com versões anteriores


Adicionar IDisposable à classe e remover IDisposable da lista de interfaces implementadas é uma mudança de última hora. O código do cliente que não espera IDisposable não IDisposable recursos se você adicionar IDisposable a uma de suas classes passadas por referência a uma interface ou classe base.


A própria Microsoft encontrou esse problema. IEnumerator não IEnumerator herdado de IDisposable e IEnumerator<T> herdado. Se você passar IEnumerator<T> código que recebe IEnumerator , Dispose não será chamado.


Este não é o fim do mundo, mas fornece alguma essência secundária do IDisposable .


Dificuldades IDisposable - Criando uma hierarquia de classes


A maior desvantagem causada pelo IDisposable no campo do design da hierarquia é que cada classe e interface deve prever se o IDisposable será necessário por seus descendentes.


Se a interface não herdar IDisposable , mas as classes que implementam a interface também implementarem IDisposable , o código final ignorará a liberação determinística ou deverá verificar se o objeto implementa a interface IDisposable . Mas, para isso, não será possível usar a construção using e você terá que escrever uma try feia e finally .


Em resumo, o IDisposable complica o desenvolvimento de software reutilizável. O principal motivo é a violação de um dos princípios do design orientado a objetos - separação de interface e implementação. A liberação de recursos deve ser um detalhe de implementação. A Microsoft decidiu tornar a liberação determinística de recursos uma interface de segunda classe.


Uma das soluções não tão bonitas é fazer com que todas as classes implementem IDisposable , mas na grande maioria das classes, IDisposable.Dispose não fará nada. Mas isso não é muito bonito.


Outra dificuldade com o IDisposable é coleções. Algumas coleções “possuem” objetos neles, e outras não. No entanto, as próprias coleções não implementam IDisposable . O programador deve se lembrar de chamar IDisposable.Dispose nos objetos da coleção ou criar seus próprios descendentes de classes de coleção que implementam IDisposable para significar propriedade.


Dificuldades IDisposable - estado "errôneo" adicional


IDisposable pode ser chamado explicitamente a qualquer momento, independentemente da vida útil do objeto. Ou seja, um estado "liberado" é adicionado a cada objeto, no qual é recomendável lançar uma ObjectDisposedException . Verificar o status e lançar exceções é uma despesa adicional.


Em vez de verificar cada espirro, é melhor considerar acessar o objeto no estado "liberado" como "comportamento indefinido" como uma chamada à memória liberada.


Dificuldades IDisposable - sem garantias


IDisposable é apenas uma interface. Uma classe que implementa IDisposable oferece suporte à liberação determinística, mas não a garante . Para o código do cliente, não há problema em chamar Dispose . Portanto, uma classe que implementa IDisposable deve oferecer suporte à liberação determinística e não determinística.


Complexidades IDisposable - Implementação Complexa


A Microsoft oferece um padrão para implementar IDisposable . (Anteriormente, havia um padrão geralmente terrível, mas, relativamente recentemente, após o aparecimento do .NET 4, a documentação foi corrigida, inclusive sob a influência deste artigo. Nas edições antigas dos livros do .NET, é possível encontrar a versão antiga. - aprox. )


  • IDisposable.Dispose não pode ser chamado, portanto, a classe deve incluir um finalizador para liberar recursos.
  • IDisposable.Dispose pode ser chamado várias vezes e deve funcionar sem efeitos colaterais visíveis. Portanto, é necessário adicionar uma verificação se o método já foi chamado ou não.
  • Os finalizadores são chamados em um thread separado e podem ser chamados antes da IDisposable.Dispose . O uso do GC.SuppressFinalize para evitar essas "corridas".

Além disso:


  • Os finalizadores são chamados, inclusive para objetos que lançam uma exceção no construtor. Portanto, o código de liberação deve funcionar com objetos parcialmente inicializados.
  • A implementação de um IDisposable em uma classe herdada de CriticalFinalizerObject requer construções não triviais. void Dispose(bool disposing) é um método viral e deve ser executado na região de execução restrita , que requer uma chamada para RuntimeHelpers.PrepareMethod .

Dificuldades IDisposable - Não é adequado para lógica de conclusão


Desligando um objeto - geralmente ocorre em programas em threads paralelos ou assíncronos. Por exemplo, uma classe usa um thread separado e deseja concluí-lo usando ManualResetEvent . Isso pode ser feito em IDisposable.Dispose , mas pode levar a um erro se o código for chamado no finalizador.


Para entender as limitações do finalizador, você precisa entender como o coletor de lixo funciona. Abaixo está um diagrama simplificado no qual muitos detalhes relacionados a gerações, links fracos, recuperação de objetos, coleta de lixo em segundo plano etc. são omitidos.


O coletor de lixo .NET usa o algoritmo de marcação e varredura. Em geral, a lógica é assim:


  1. Pause todos os threads.
  2. Pegue todos os objetos raiz: variáveis ​​na pilha, campos estáticos, objetos GCHandle , fila de finalização. No caso de descarregar o domínio do aplicativo (encerramento do programa), considera-se que as variáveis ​​na pilha e nos campos estáticos não são raízes.
  3. Recursivamente, percorra todos os links dos objetos e marque-os como "alcançáveis".
  4. Percorra todos os outros objetos que possuem destruidores (finalizadores), declare-os acessíveis e coloque-os na fila de finalização ( GC.SuppressFinalize diz ao GC para não fazer isso). Os objetos são enfileirados em uma ordem imprevisível.

Em segundo plano, um fluxo (ou vários) de finalização funciona:


  1. Retira um objeto da fila e inicia seu finalizador. É possível executar vários finalizadores de objetos diferentes ao mesmo tempo.
  2. O objeto é removido da fila e, se mais ninguém se referir a ele, será limpo na próxima coleta de lixo.

Agora deve ficar claro por que é impossível acessar recursos gerenciados do finalizador - você não sabe em que ordem os finalizadores são chamados. Mesmo chamando IDisposable.Dispose outro objeto do finalizador pode levar a um erro, pois o código de liberação do recurso pode funcionar em outro thread.


Existem algumas exceções quando você pode acessar recursos gerenciados de um finalizador:


  1. A finalização de objetos herdados de CriticalFinalizerObject é executada após a finalização de objetos não herdados dessa classe. Isso significa que você pode chamar ManualResetEvent do finalizador até que a classe seja herdada de CriticalFinalizerObject
  2. Alguns objetos e métodos são especiais, como o Console e alguns métodos de Thread. Eles podem ser chamados dos finalizadores, mesmo que o programa termine.

No caso geral, é melhor não acessar recursos gerenciados dos finalizadores. No entanto, a lógica da conclusão é necessária para software não trivial. No Windows.Forms contém a lógica de conclusão no método Application.Exit . Ao desenvolver sua biblioteca de componentes, a melhor coisa a fazer é concluir a lógica de conclusão com IDisposable . Terminação normal em caso de chamar IDisposable.Dispose e emergência caso contrário.


A Microsoft também encontrou esse problema. A classe StreamWriter possui um objeto Stream (dependendo dos parâmetros do construtor na versão mais recente - aprox. Por. ). StreamWriter.Close libera o buffer e chama Stream.Close (também ocorre se envolvido com o using - aprox. Por. ). Se o StreamWriter não StreamWriter fechado, o buffer não será liberado e o bate-papo de dados será perdido. A Microsoft simplesmente não redefiniu o finalizador, "resolvendo" o problema de conclusão. Um ótimo exemplo da necessidade de lógica de conclusão.


Eu recomendo a leitura


Muitas informações sobre o .NET internos neste artigo são fornecidas pelo CLR de Jeffrey Richter via C #. Se você ainda não o tem, compre-o . Sério. Esse é o conhecimento necessário para qualquer programador de C #.


Conclusão do tradutor


A maioria dos programadores .NET nunca encontrará os problemas descritos neste artigo. O .NET evoluirá para aumentar o nível de abstração e reduzir a necessidade de "malabarismo" de recursos não gerenciados. No entanto, este artigo é útil, pois descreve os detalhes profundos de coisas simples e seu impacto no design do código.


A próxima parte será uma discussão detalhada de como trabalhar com recursos gerenciados e não gerenciados no .NET com vários exemplos.

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


All Articles