
Patrón desechable (Principio de diseño desechable)
Creo que casi cualquier programador que use .NET ahora dirá que este patrón es pan comido. Es el patrón más conocido utilizado en la plataforma. Sin embargo, incluso el dominio del problema más simple y conocido tendrá áreas secretas que nunca ha visto. Entonces, describamos todo desde el principio para los principiantes y todo lo demás (para que cada uno pueda recordar lo básico). No te saltes estos párrafos, ¡te estoy mirando!
Si pregunto qué es IDisposable, seguramente dirán que es
public interface IDisposable { void Dispose(); }
¿Cuál es el propósito de la interfaz? Quiero decir, ¿por qué necesitamos aclarar la memoria si tenemos un recolector de basura inteligente que borra la memoria en lugar de nosotros, para que ni siquiera tengamos que pensarlo? Sin embargo, hay algunos pequeños detalles.
Este capítulo fue traducido del ruso conjuntamente por el autor y por traductores profesionales . Puede ayudarnos con la traducción del ruso o el inglés a cualquier otro idioma, principalmente al chino o al alemán.
Además, si quieres agradecernos, la mejor manera de hacerlo es darnos una estrella en Github o bifurcar el repositorio
github / sidristij / dotnetbook .
Existe la idea errónea de que IDisposable
sirve para liberar recursos no administrados. Esto es solo parcialmente cierto y para entenderlo, solo necesita recordar los ejemplos de recursos no administrados. ¿Es la clase File
un recurso no administrado? No ¿Quizás DbContext
es un recurso no administrado? No otra vez Un recurso no administrado es algo que no pertenece al sistema de tipo .NET. Algo que la plataforma no creó, algo que existe fuera de su alcance. Un ejemplo simple es un identificador de archivo abierto en un sistema operativo. Un identificador es un número que identifica de forma exclusiva un archivo abierto, no, no por usted, por un sistema operativo. Es decir, todas las estructuras de control (por ejemplo, la posición de un archivo en un sistema de archivos, fragmentos de archivos en caso de fragmentación y otra información de servicio, los números de un cilindro, una cabeza o un sector de un HDD) están dentro de un sistema operativo pero no Plataforma .NET. El único recurso no administrado que se pasa a la plataforma .NET es el número IntPtr. Este número está envuelto por FileSafeHandle, que a su vez está envuelto por la clase File. Significa que la clase File no es un recurso no administrado por sí solo, sino que usa una capa adicional en forma de IntPtr para incluir un recurso no administrado: el identificador de un archivo abierto. ¿Cómo lees ese archivo? Usando un conjunto de métodos en WinAPI o Linux OS.
Las primitivas de sincronización en programas multiproceso o multiprocesador son el segundo ejemplo de recursos no administrados. Aquí pertenecen las matrices de datos que se pasan a través de P / Invoke y también mutexes o semáforos.
Tenga en cuenta que el sistema operativo no simplemente pasa el identificador de un recurso no administrado a una aplicación. También guarda ese identificador en la tabla de identificadores abiertos por el proceso. Por lo tanto, el sistema operativo puede cerrar correctamente los recursos después de la finalización de la aplicación. Esto garantiza que los recursos se cerrarán de todos modos después de salir de la aplicación. Sin embargo, el tiempo de ejecución de una aplicación puede ser diferente, lo que puede causar un bloqueo prolongado de los recursos.
Ok Ahora cubrimos recursos no administrados. ¿Por qué necesitamos usar IDisposable en estos casos? Porque .NET Framework no tiene idea de lo que sucede fuera de su territorio. Si abre un archivo utilizando la API del sistema operativo, .NET no sabrá nada al respecto. Si asigna un rango de memoria para sus propias necesidades (por ejemplo, usando VirtualAlloc), .NET tampoco sabrá nada. Si no lo sabe, no liberará la memoria ocupada por una llamada VirtualAlloc. O no cerrará un archivo abierto directamente a través de una llamada a la API del sistema operativo. Estos pueden causar consecuencias diferentes e inesperadas. Puede obtener OutOfMemory si asigna demasiada memoria sin liberarla (por ejemplo, simplemente configurando un puntero como nulo). O, si abre un archivo en un recurso compartido de archivos a través del sistema operativo sin cerrarlo, bloqueará el archivo en ese recurso compartido durante mucho tiempo. El ejemplo de compartir archivos es especialmente bueno ya que el bloqueo permanecerá en el lado de IIS incluso después de cerrar una conexión con un servidor. No tiene derechos para liberar el bloqueo y deberá solicitar a los administradores que iisreset
o que cierren los recursos manualmente mediante un software especial.
Este problema en un servidor remoto puede convertirse en una tarea compleja de resolver.
Todos estos casos necesitan un protocolo universal y familiar para la interacción entre un sistema de tipos y un programador. Debe identificar claramente los tipos que requieren cierre forzado. La interfaz IDisposable sirve exactamente para este propósito. Funciona de la siguiente manera: si un tipo contiene la implementación de la interfaz IDisposable, debe llamar a Dispose () después de terminar el trabajo con una instancia de ese tipo.
Entonces, hay dos formas estándar de llamarlo. Por lo general, crea una instancia de entidad para usarla rápidamente dentro de un método o durante la vida útil de la instancia de entidad.
La primera forma es envolver una instancia para using(...){ ... }
. Significa que debe destruir un objeto una vez que finaliza el bloque relacionado con el uso, es decir, llamar a Dispose (). La segunda forma es destruir el objeto, cuando termina su vida útil, con una referencia al objeto que queremos liberar. Pero .NET no tiene más que un método de finalización que implica la destrucción automática de un objeto, ¿verdad? Sin embargo, la finalización no es adecuada ya que no sabemos cuándo se llamará. Mientras tanto, necesitamos liberar un objeto en un momento determinado, por ejemplo, justo después de que terminemos de trabajar con un archivo abierto. Es por eso que también necesitamos implementar IDisposable y llamar a Dispose para liberar todos los recursos que poseemos. Por lo tanto, seguimos el protocolo , y es muy importante. Porque si alguien lo sigue, todos los participantes deberían hacer lo mismo para evitar problemas.
Diferentes formas de implementar IDisposable
Veamos las implementaciones de IDisposable de simple a complicado. El primero y el más simple es usar IDisposable como es:
public class ResourceHolder : IDisposable { DisposableResource _anotherResource = new DisposableResource(); public void Dispose() { _anotherResource.Dispose(); } }
Aquí, creamos una instancia de un recurso que Dispose () lanza aún más. Lo único que hace que esta implementación sea inconsistente es que aún puede trabajar con la instancia después de su destrucción por Dispose()
:
public class ResourceHolder : IDisposable { private DisposableResource _anotherResource = new DisposableResource(); private bool _disposed; public void Dispose() { if(_disposed) return; _anotherResource.Dispose(); _disposed = true; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CheckDisposed() { if(_disposed) { throw new ObjectDisposedException(); } } }
CheckDisposed () debe llamarse como una primera expresión en todos los métodos públicos de una clase. La estructura de clase de ResourceHolder
obtenida se ve bien para destruir un recurso no administrado, que es DisposableResource
. Sin embargo, esta estructura no es adecuada para un recurso no administrado incluido. Veamos el ejemplo con un recurso no administrado.
public class FileWrapper : IDisposable { IntPtr _handle; public FileWrapper(string name) { _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero); } public void Dispose() { CloseHandle(_handle); } [DllImport("kernel32.dll", EntryPoint = "CreateFile", SetLastError = true)] private static extern IntPtr CreateFile(String lpFileName, UInt32 dwDesiredAccess, UInt32 dwShareMode, IntPtr lpSecurityAttributes, UInt32 dwCreationDisposition, UInt32 dwFlagsAndAttributes, IntPtr hTemplateFile); [DllImport("kernel32.dll", SetLastError=true)] private static extern bool CloseHandle(IntPtr hObject); }
¿Cuál es la diferencia en el comportamiento de los dos últimos ejemplos? El primero describe la interacción de dos recursos gestionados. Esto significa que si un programa funciona correctamente, el recurso se liberará de todos modos. Dado que DisposableResource
se administra, .NET CLR lo sabe y liberará la memoria si su comportamiento es incorrecto. Tenga en cuenta que conscientemente no asumo lo que encapsula el tipo DisposableResource
. Puede haber cualquier tipo de lógica y estructura. Puede contener recursos administrados y no administrados. Esto no debería preocuparnos en absoluto . Nadie nos pide que descompilemos las bibliotecas de terceros cada vez y veamos si usan recursos administrados o no administrados. Y si nuestro tipo utiliza un recurso no administrado, no podemos ignorarlo. Hacemos esto en la clase FileWrapper
. Entonces, ¿qué pasa en este caso? Si usamos recursos no administrados, tenemos dos escenarios. El primero es cuando todo está bien y se llama Dispose. La segunda es cuando algo sale mal y Dispose falla.
Digamos de inmediato por qué esto puede salir mal:
- Si usamos el uso
using(obj) { ... }
, puede aparecer una excepción en un bloque de código interno. Esta excepción es atrapada por el bloque finally
, que no podemos ver (este es el azúcar sintáctico de C #). Este bloque llama a Dispose implícitamente. Sin embargo, hay casos en que esto no sucede. Por ejemplo, ni catch
ni finally
capture StackOverflowException
. Siempre debes recordar esto. Porque si algún hilo se vuelve recursivo y se produce StackOverflowException
en algún momento, .NET se olvidará de los recursos que usó pero que no se lanzaron. No sabe cómo liberar recursos no administrados. Permanecerán en la memoria hasta que el sistema operativo los libere, es decir, cuando salga de un programa, o incluso algún tiempo después de la finalización de una aplicación. - Si llamamos a Dispose () desde otro Dispose (). Una vez más, es posible que no lo logremos. Este no es el caso de un desarrollador de aplicaciones distraído, que olvidó llamar a Dispose (). Es la cuestión de las excepciones. Sin embargo, estas no son solo las excepciones que bloquean un subproceso de una aplicación. Aquí hablamos de todas las excepciones que evitarán que un algoritmo llame a un Dispose externo () que llamará a nuestro Dispose ().
Todos estos casos crearán recursos no administrados suspendidos. Esto se debe a que Garbage Collector no sabe que debería recolectarlos. Todo lo que puede hacer en la próxima comprobación es descubrir que se pierde la última referencia a un gráfico de objeto con nuestro tipo FileWrapper
. En este caso, la memoria se reasignará para objetos con referencias. ¿Cómo podemos prevenirlo?
Debemos implementar el finalizador de un objeto. El 'finalizador' se llama así a propósito. No es un destructor como puede parecer debido a formas similares de llamar a finalizadores en C # y destructores en C ++. La diferencia es que se llamará a un finalizador de todos modos , a diferencia de un destructor (así como Dispose()
). Se llama a un finalizador cuando se inicia la recolección de basura (ahora es suficiente saber esto, pero las cosas son un poco más complicadas). Se utiliza para una liberación garantizada de recursos si algo sale mal . Debemos implementar un finalizador para liberar recursos no administrados. Nuevamente, debido a que se llama a un finalizador cuando se inicia GC, no sabemos cuándo sucede esto en general.
Expandamos nuestro código:
public class FileWrapper : IDisposable { IntPtr _handle; public FileWrapper(string name) { _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero); } public void Dispose() { InternalDispose(); GC.SuppressFinalize(this); } private void InternalDispose() { CloseHandle(_handle); } ~FileWrapper() { InternalDispose(); } /// other methods }
Mejoramos el ejemplo con el conocimiento sobre el proceso de finalización y aseguramos la aplicación contra la pérdida de información de recursos si no se llama a Dispose (). También llamamos GC SuppressFinalize para deshabilitar la finalización de la instancia del tipo si Dispose () se llama con éxito. No hay necesidad de liberar el mismo recurso dos veces, ¿verdad? Por lo tanto, también reducimos la cola de finalización al dejar ir una región aleatoria de código que probablemente se ejecutará con la finalización en paralelo, algún tiempo después. Ahora, mejoremos el ejemplo aún más.
public class FileWrapper : IDisposable { IntPtr _handle; bool _disposed; public FileWrapper(string name) { _handle = CreateFile(name, 0, 0, 0, 0, 0, IntPtr.Zero); } public void Dispose() { if(_disposed) return; _disposed = true; InternalDispose(); GC.SuppressFinalize(this); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CheckDisposed() { if(_disposed) { throw new ObjectDisposedException(); } } private void InternalDispose() { CloseHandle(_handle); } ~FileWrapper() { InternalDispose(); } /// other methods }
Ahora nuestro ejemplo de un tipo que encapsula un recurso no administrado parece completo. Desafortunadamente, el segundo Dispose()
es de hecho un estándar de la plataforma y permitimos llamarlo. Tenga en cuenta que las personas a menudo permiten la segunda llamada de Dispose()
para evitar problemas con un código de llamada y esto es incorrecto. Sin embargo, un usuario de su biblioteca que mira la documentación de MS puede no pensarlo y permitirá múltiples llamadas de Dispose (). Llamar a otros métodos públicos destruirá la integridad de un objeto de todos modos. Si destruimos el objeto, ya no podemos trabajar con él. Esto significa que debemos llamar a CheckDisposed
al comienzo de cada método público.
Sin embargo, este código contiene un problema grave que impide que funcione como pretendíamos. Si recordamos cómo funciona la recolección de basura, notaremos una característica. Al recolectar basura, GC finaliza principalmente todo lo heredado directamente de Object . Luego trata con objetos que implementan CriticalFinalizerObject . Esto se convierte en un problema ya que ambas clases que diseñamos heredan Object. No sabemos en qué orden llegarán a la "última milla". Sin embargo, un objeto de nivel superior puede usar su finalizador para finalizar un objeto con un recurso no administrado. Aunque, esto no suena como una gran idea. El orden de finalización sería muy útil aquí. Para configurarlo, el tipo de nivel inferior con un recurso encapsulado no administrado debe heredarse de CriticalFinalizerObject
.
La segunda razón es más profunda. Imagine que se atrevió a escribir una aplicación que no cuida mucho la memoria. Asigna memoria en grandes cantidades, sin cobrar y otras sutilezas. Un día, esta aplicación se bloqueará con OutOfMemoryException. Cuando ocurre, el código se ejecuta específicamente. No puede asignar nada, ya que dará lugar a una excepción repetida, incluso si se detecta la primera. Esto no significa que no debamos crear nuevas instancias de objetos. Incluso una llamada de método simple puede arrojar esta excepción, por ejemplo, la de finalización. Le recuerdo que los métodos se compilan cuando los llama por primera vez. Este es un comportamiento habitual. ¿Cómo podemos prevenir este problema? Muy facilmente. Si su objeto se hereda de CriticalFinalizerObject , todos los métodos de este tipo se compilarán inmediatamente después de cargarlo en la memoria. Además, si marca métodos con el atributo [PrePrepareMethod] , también se precompilarán y será seguro llamarlos en una situación de pocos recursos.
¿Por qué es eso importante? ¿Por qué gastar tanto esfuerzo en los que fallecen? Porque los recursos no administrados pueden suspenderse en un sistema por mucho tiempo. Incluso después de reiniciar una computadora. Si un usuario abre un archivo desde un recurso compartido de archivos en su aplicación, el primero será bloqueado por un host remoto y liberado en el tiempo de espera o cuando libera un recurso al cerrar el archivo. Si su aplicación se bloquea cuando se abre el archivo, no se lanzará incluso después de reiniciar. Tendrá que esperar mucho hasta que el host remoto lo libere. Además, no debe permitir excepciones en finalizadores. Esto conduce a un bloqueo acelerado del CLR y de una aplicación, ya que no puede ajustar la llamada de un finalizador en try ... catch . Quiero decir, cuando intentas liberar un recurso, debes estar seguro de que se puede liberar. El último hecho, pero no menos importante: si el CLR descarga un dominio de forma anormal, también se llamarán los finalizadores de tipos, derivados de CriticalFinalizerObject , a diferencia de los heredados directamente de Object .
Este traductor traducido del ruso como del idioma del autor por traductores profesionales . Puede ayudarnos a crear una versión traducida de este texto a cualquier otro idioma, incluido el chino o el alemán, utilizando las versiones de texto en ruso e inglés como fuente.
Además, si quiere decir "gracias", la mejor manera de elegir es dándonos una estrella en github o bifurcando repositorio
https://github.com/sidristij/dotnetbook