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í:
- Pausa todos los hilos.
- 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. - Revise recursivamente todos los enlaces de los objetos y márquelos como "accesibles".
- 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:
- Toma un objeto de la cola y comienza su finalizador. Es posible ejecutar varios finalizadores de diferentes objetos al mismo tiempo.
- 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:
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
- 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.