IDisposable: que su madre no habló sobre la liberación de recursos. Parte 1

Esta es una traducción de la primera parte del artículo. El artículo fue escrito en 2008. Después de 10 años, casi pierde su relevancia.


Liberación determinista de recursos: una necesidad


En el transcurso de más de 20 años de experiencia en codificación, a veces desarrollé mis propios idiomas para resolver problemas. Varían desde lenguajes simples e imperativos hasta expresiones regulares especializadas para árboles. Al crear idiomas, hay muchas recomendaciones y algunas reglas simples no deben ser violadas. Uno de ellos:


Nunca cree un lenguaje de excepción en el que no haya una liberación determinista de recursos.

¿Adivina qué recomendaciones no sigue el tiempo de ejecución de .NET y, como resultado, todos los lenguajes basados ​​en él?


La razón por la que existe esta regla es que la liberación determinista de recursos es necesaria para crear programas compatibles . La liberación determinada de recursos proporciona un cierto punto en el que el programador está seguro de que el recurso se libera. Hay dos formas de escribir programas confiables: el enfoque tradicional es liberar recursos lo antes posible y el enfoque moderno es liberar recursos por un tiempo indefinido. La ventaja del enfoque moderno es que el programador no necesita liberar recursos explícitamente. La desventaja es que es mucho más difícil escribir una aplicación confiable, hay muchos errores sutiles. Desafortunadamente, el tiempo de ejecución de .NET se creó utilizando un enfoque moderno.


.NET admite la liberación no determinista de recursos utilizando el método Finalize , que tiene un significado especial. Para la liberación determinista de recursos, Microsoft también agregó la interfaz IDisposable (y otras clases, que discutiremos más adelante). Sin embargo, para el tiempo de ejecución, IDisposable es una interfaz normal, como todos los demás. Este estado de "segunda tasa" crea algunas dificultades.


En C #, la "versión determinista para los pobres" se puede implementar usando las try y finally o using (que es casi lo mismo). Microsoft ha estado discutiendo durante mucho tiempo si hacer recuentos de enlaces o no, y me parece que se tomó una decisión incorrecta. Como resultado, para la liberación determinista de recursos, debe usar el torpe finally \ using constructos o una llamada directa a IDisposable.Dispose , que está llena de errores. Para un programador de C ++ que está acostumbrado a usar shared_ptr<T> ambas opciones no son atractivas. (La última oración deja en claro dónde el autor tiene esa relación - aprox.


IDisposable


IDisposable es una solución para la liberación determinista de recursos ofrecidos por Misoftro. Uno es para los siguientes casos:


  • Cualquier tipo que posea recursos gestionados ( IDisposable ). Un tipo debe necesariamente poseer , es decir, administrar el tiempo de vida, los recursos y no solo referirse a ellos.
  • Cualquier tipo que posea recursos no administrados.
  • Cualquier tipo que posea recursos administrados y no administrados.
  • Cualquier tipo heredado de una clase que implemente IDisposable . No recomiendo heredar de clases que poseen recursos no administrados. Mejor usar un archivo adjunto.

IDisposable ayuda a liberar recursos de manera determinista, pero tiene sus propios problemas.


Dificultades IDisposable - Usabilidad


IDisposable objetos IDisposable son IDisposable usar bastante engorroso. El uso de un objeto debe estar envuelto en una construcción de using . La mala noticia es que C # no permite el using con un tipo que no implementa IDisposable . Por lo tanto, el programador debe consultar la documentación cada vez para comprender si es necesario escribir using , o simplemente escribir using todas partes, y luego borrar donde jura el compilador.


C ++ administrado es mucho mejor en este sentido. Admite semántica de pila para tipos de referencia , que funciona como using solo para tipos cuando sea necesario. C # podría beneficiarse de la capacidad de escribir using cualquier tipo.


Este problema se puede resolver con. herramientas de análisis de código. Para empeorar las cosas, si olvida usar, el programa puede pasar las pruebas, pero se bloquea mientras trabaja "en los campos".


En lugar de contar enlaces, IDisposable tiene otro problema: determinar el propietario. Cuando en C ++ la última copia de shared_ptr<T> queda fuera de alcance, los recursos se liberan de inmediato, sin necesidad de pensar quién debería liberar. IDisposable por el contrario, obliga al programador a determinar quién "posee" el objeto y es responsable de liberarlo. A veces, la propiedad es obvia: cuando un objeto encapsula a otro e implementa IDisposable , por lo tanto, es responsable de la liberación de los objetos secundarios. A veces, la vida útil de un objeto está determinada por un bloque de código, y el programador simplemente usa el using alrededor de este bloque. Sin embargo, hay muchos casos en los que se puede usar un objeto en varios lugares y su vida útil es difícil de determinar (aunque en este caso el recuento de referencia estaría bien).


Dificultades IDisposable - Compatibilidad con versiones anteriores


Agregar IDisposable a la clase y eliminar IDisposable de la lista de interfaces implementadas es un cambio radical. El código de cliente que no espera IDisposable no liberará recursos si agrega IDisposable a una de sus clases aprobadas por referencia a una interfaz o clase base.


Microsoft mismo se encontró con este problema. IEnumerator no se hereda de IDisposable e IEnumerator<T> hereda. Si pasa IEnumerator<T> código que recibe IEnumerator , no se llamará a Dispose .


Este no es el fin del mundo, pero da una esencia secundaria de IDisposable .


Dificultades que no se pueden desechar: diseño de una jerarquía de clases


El mayor inconveniente causado por IDisposable en el campo del diseño de jerarquía es que cada clase e interfaz debe predecir si IDisposable será necesario para sus descendientes.


Si la interfaz no hereda IDisposable , pero las clases que implementan la interfaz también implementan IDisposable , entonces el código final ignorará la versión determinista o debe verificar si el objeto implementa la interfaz IDisposable . Pero para esto, no será posible usar el constructor de uso y tendrá que escribir un try feo y finally .


En resumen, IDisposable complica el desarrollo de software reutilizable. La razón clave es la violación de uno de los principios del diseño orientado a objetos: la separación de la interfaz y la implementación. La liberación de recursos debe ser un detalle de implementación. Microsoft decidió hacer de la liberación determinista de recursos una interfaz de segunda clase.


Una de las soluciones no tan hermosas es hacer que todas las clases implementen IDisposable , pero en la gran mayoría de las clases, IDisposable.Dispose no hará nada. Pero esto no es demasiado hermoso.


Otra dificultad con IDisposable son las colecciones. Algunas colecciones "poseen" objetos en ellos, y otras no. Sin embargo, las colecciones en sí no implementan IDisposable . El programador debe recordar llamar a IDisposable.Dispose en los objetos de la colección, o crear sus propios descendientes de las clases de colección que implementan IDisposable para significar la propiedad.


Dificultades IDisposable - estado "erróneo" adicional


IDisposable se puede llamar explícitamente en cualquier momento, independientemente de la vida útil del objeto. Es decir, se agrega un estado "liberado" a cada objeto, en el que se recomienda lanzar una ObjectDisposedException . Verificar el estado y lanzar excepciones es un gasto adicional.


En lugar de verificar cada estornudo, es mejor considerar acceder al objeto en el estado "liberado" como "comportamiento indefinido" como una llamada a la memoria liberada.


Dificultades IDisposable - sin garantías


IDisposable es solo una interfaz. Una clase que implementa IDisposable admite el lanzamiento determinista, pero no lo garantiza . Para el código del cliente, está bien no llamar a Dispose . Por lo tanto, una clase que implementa IDisposable debe admitir la IDisposable tanto determinista como no determinista.


Complejidades IDisposable - Implementación compleja


Microsoft ofrece un patrón para implementar IDisposable . (Anteriormente había un patrón generalmente terrible, pero relativamente recientemente, después de la aparición de .NET 4, la documentación se corrigió, incluso bajo la influencia de este artículo. En las ediciones antiguas de libros .NET, puede encontrar la versión anterior. - aprox. )


  • IDisposable.Dispose no se puede llamar en absoluto, por lo que la clase debe incluir un finalizador para liberar recursos.
  • IDisposable.Dispose se puede llamar varias veces y debería funcionar sin efectos secundarios visibles. Por lo tanto, es necesario agregar verificación si el método ya se ha llamado o no.
  • Los finalizadores se llaman en un hilo separado y se pueden llamar antes de IDisposable.Dispose . El uso de GC.SuppressFinalize para evitar tales "razas".

Además


  • Se llama a los finalizadores, incluidos los objetos que generan una excepción en el constructor. Por lo tanto, el código de lanzamiento debe funcionar con objetos parcialmente inicializados.
  • Implementar un IDisposable en una clase heredada de CriticalFinalizerObject requiere construcciones no triviales. void Dispose(bool disposing) es un método viral y debe ejecutarse en la Región de ejecución RuntimeHelpers.PrepareMethod , que requiere una llamada a RuntimeHelpers.PrepareMethod .

Dificultades IDisposable - No es adecuado para la lógica de finalización


Apagar un objeto: a menudo ocurre en programas en subprocesos paralelos o asincrónicos. Por ejemplo, una clase usa un hilo separado y quiere completarlo usando ManualResetEvent . Esto se puede hacer en IDisposable.Dispose , pero puede provocar un error si se llama al código en el finalizador.


Para comprender las limitaciones en el finalizador, debe comprender cómo funciona el recolector de basura. A continuación se muestra un diagrama simplificado en el que se omiten muchos detalles relacionados con generaciones, enlaces débiles, reactivación de objetos, recolección de basura de fondo, etc.


El recolector de basura .NET utiliza el algoritmo de marcado y barrido. En general, la lógica se ve así:


  1. Pausa todos los hilos.
  2. Tome todos los objetos raíz: variables en la pila, campos estáticos, objetos GCHandle , cola de finalización. En el caso de descargar el dominio de la aplicación (terminación del programa), se considera que las variables en la pila y los campos estáticos no son raíces.
  3. Revise recursivamente todos los enlaces de los objetos y márquelos como "accesibles".
  4. Revise todos los demás objetos que tienen destructores (finalizadores), declare que son accesibles y colóquelos en la cola de finalización ( GC.SuppressFinalize le dice a GC que no haga esto). Los objetos se ponen en cola en un orden impredecible.

En segundo plano, una secuencia (o varias) de finalización funciona:


  1. Toma un objeto de la cola y comienza su finalizador. Es posible ejecutar varios finalizadores de diferentes objetos al mismo tiempo.
  2. El objeto se elimina de la cola y, si nadie más se refiere a él, se borrará en la próxima recolección de basura.

Ahora debe quedar claro por qué es imposible acceder a los recursos administrados desde el finalizador: no sabe en qué orden se llaman los finalizadores. Incluso llamar a IDisposable.Dispose otro objeto del finalizador puede provocar un error, ya que el código de liberación de recursos puede funcionar en otro hilo.


Existen algunas excepciones cuando puede acceder a los recursos administrados desde un finalizador:


  1. CriticalFinalizerObject finalización de los objetos heredados de CriticalFinalizerObject se realiza después de la finalización de los objetos no heredados de esta clase. Esto significa que puede llamar a ManualResetEvent desde el finalizador hasta que la clase se herede de CriticalFinalizerObject
  2. Algunos objetos y métodos son especiales, como la consola y algunos métodos de subprocesos. Se pueden llamar desde finalizadores incluso si el programa finaliza.

En el caso general, es mejor no acceder a los recursos gestionados de los finalizadores. Sin embargo, la lógica de finalización es necesaria para el software no trivial. En Windows.Forms contiene la lógica de finalización en el método Application.Exit . Cuando desarrolle su biblioteca de componentes, lo mejor que puede hacer es completar la lógica de finalización con IDisposable . Terminación normal en caso de llamar a IDisposable.Dispose . De lo contrario, IDisposable.Dispose emergencia.


Microsoft también se topó con este problema. La clase StreamWriter posee un objeto Stream (dependiendo de los parámetros del constructor en la última versión - aprox. Por. ). StreamWriter.Close el búfer y llama a Stream.Close (también ocurre si se using - aprox. Por. ). Si StreamWriter no StreamWriter cerrado, el búfer no se vacía y se pierde el chat de datos. Microsoft simplemente no redefinió el finalizador, "resolviendo" el problema de finalización. Un gran ejemplo de la necesidad de completar la lógica.


Recomiendo leer


Mucha información sobre los componentes internos de .NET en este artículo proviene del CLR de Jeffrey Richter a través de C #. Si aún no lo tiene, cómprelo . En serio Este es el conocimiento necesario para cualquier programador de C #.


Conclusión del traductor


La mayoría de los programadores .NET nunca se encontrarán con los problemas descritos en este artículo. .NET evolucionará para aumentar el nivel de abstracción y reducir la necesidad de "hacer malabares" con recursos no administrados. Sin embargo, este artículo es útil porque describe los detalles profundos de cosas simples y su impacto en el diseño del código.


La siguiente parte será una discusión detallada de cómo trabajar con recursos administrados y no administrados en .NET con un montón de ejemplos.

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


All Articles