¿Por qué y cómo evitar pérdidas de memoria en el controlador de eventos?
Al leer algunas preguntas y respuestas en StackOverflow, me di cuenta de que agregar controladores de eventos +=
en C# (o supongo, en otros lenguajes .net) puede causar pérdidas de memoria comunes...
He usado controladores de eventos como este en el pasado muchas veces y nunca me di cuenta de que pueden causar, o han causado, pérdidas de memoria en mis aplicaciones.
¿Cómo funciona esto (es decir, por qué esto realmente causa una pérdida de memoria)?
Como puedo solucionar este problema ? ¿ Es suficiente usar -=
el mismo controlador de eventos?
¿Existen patrones de diseño comunes o mejores prácticas para manejar situaciones como esta?
Ejemplo: ¿Cómo se supone que debo manejar una aplicación que tiene muchos subprocesos diferentes y utiliza muchos controladores de eventos diferentes para generar varios eventos en la interfaz de usuario?
¿Existe alguna forma buena y sencilla de monitorear esto de manera eficiente en una gran aplicación ya creada?
La causa es sencilla de explicar: mientras un controlador de eventos está suscrito, el publicador del evento mantiene una referencia al suscriptor a través del delegado del controlador de eventos (asumiendo que el delegado es un método de instancia).
Si el editor vive más que el suscriptor, lo mantendrá vivo incluso cuando no haya otras referencias al suscriptor.
Si se da de baja del evento con un controlador igual, entonces sí, eso eliminará el controlador y la posible fuga. Sin embargo, en mi experiencia, esto rara vez es un problema, porque normalmente encuentro que el editor y el suscriptor tienen vidas más o menos iguales de todos modos.
Es una causa posible ... pero en mi experiencia está bastante exagerada. Tu kilometraje puede variar, por supuesto... sólo debes tener cuidado.
He explicado esta confusión en un blog en https://www.spicelogic.com/Blog/net-event-handler-memory-leak-16 . Intentaré resumirlo aquí para que tengas una idea clara.
Referencia significa "Necesidad":
En primer lugar, debe comprender que, si el objeto A tiene una referencia al objeto B, entonces significará que el objeto A necesita el objeto B para funcionar, ¿verdad? Entonces, el recolector de basura no recolectará el objeto B mientras el objeto A esté vivo en la memoria.
+= Significa inyectar la referencia del objeto del lado derecho al objeto izquierdo:
La confusión proviene del operador C# +=. Este operador no le dice claramente al desarrollador que el lado derecho de este operador en realidad está inyectando una referencia al objeto del lado izquierdo.
Y al hacerlo, el objeto A piensa que necesita el objeto B, aunque, desde su perspectiva, al objeto A no debería importarle si el objeto B vive o no. Como el objeto A cree que el objeto B es necesario, el objeto A protege al objeto B del recolector de basura mientras el objeto A esté vivo. Pero, si no desea que se brinde esa protección al objeto suscriptor del evento, entonces puede decir que se produjo una pérdida de memoria. Para enfatizar esta afirmación, permítanme aclarar que, en el mundo .NET, no existe el concepto de pérdida de memoria como el típico programa no administrado de C++. Pero, como dije, el objeto A protege al objeto B de la recolección de basura y si esa no era su intención, entonces puede decir que ocurrió una pérdida de memoria porque se suponía que el objeto B no vivía en la memoria.
Puede evitar dicha fuga desconectando el controlador de eventos.
¿Cómo tomar una decisión?
Hay muchos eventos y controladores de eventos en todo su código base. ¿Significa que es necesario seguir separando los controladores de eventos en todas partes? La respuesta es No. Si tuvieras que hacerlo, tu código base será realmente feo y detallado.
Más bien, puede seguir un diagrama de flujo simple para determinar si es necesario o no un controlador de eventos desconectado.
La mayoría de las veces, es posible que el objeto suscriptor del evento sea tan importante como el objeto publicador del evento y se supone que ambos viven al mismo tiempo.
Ejemplo de un escenario en el que no debes preocuparte
Por ejemplo, un evento de clic en un botón de una ventana.
Aquí, el editor del evento es el Botón y el suscriptor del evento es la Ventana Principal. Aplicando ese diagrama de flujo, haga una pregunta: ¿se supone que la ventana principal (suscriptor del evento) está muerta antes que el botón (editor del evento)? Obviamente no. ¿Verdad? Eso ni siquiera tendrá sentido. Entonces, ¿por qué preocuparse por desconectar el controlador de eventos de clic?
Un ejemplo en el que la separación del controlador de eventos es OBLIGATORIA.
Proporcionaré un ejemplo en el que se supone que el objeto suscriptor está muerto antes que el objeto editor. Digamos que su ventana principal publica un evento llamado "Algo sucedió" y usted muestra una ventana secundaria desde la ventana principal haciendo clic en un botón. La ventana secundaria se suscribe a ese evento de la ventana principal.
Y la ventana secundaria se suscribe a un evento de la ventana principal.
A partir de este código, podemos entender claramente que hay un botón en la ventana principal. Al hacer clic en ese botón se muestra una ventana secundaria. La ventana secundaria escucha un evento desde la ventana principal. Después de hacer algo, el usuario cierra la ventana secundaria.
Ahora, de acuerdo con el diagrama de flujo que proporcioné, si hace la pregunta "¿Se supone que la ventana secundaria (suscriptor del evento) está muerta antes que el editor del evento (ventana principal)? La respuesta debería ser SÍ. ¿Verdad? Entonces, desconecte el controlador de eventos Normalmente lo hago desde el evento Descargado de la Ventana.
Una regla general: si su vista (es decir, WPF, WinForm, UWP, Xamarin Form, etc.) se suscribe a un evento de ViewModel, recuerde siempre desconectar el controlador de eventos. Porque un ViewModel suele vivir más que una vista. Entonces, si ViewModel no se destruye, cualquier vista que suscriba el evento de ese ViewModel permanecerá en la memoria, lo cual no es bueno.
Prueba del concepto utilizando un perfilador de memoria.
No será muy divertido si no podemos validar el concepto con un perfilador de memoria. He utilizado el generador de perfiles JetBrain dotMemory en este experimento.
Primero, ejecuté MainWindow, que se muestra así:
Luego, tomé una instantánea de mi memoria. Luego hice clic en el botón 3 veces . Aparecieron tres ventanas infantiles. Cerré todas esas ventanas secundarias y hice clic en el botón Forzar GC en el generador de perfiles dotMemory para asegurarme de que se llame al recolector de basura. Luego, tomé otra instantánea de mi memoria y la comparé. ¡Mirad! nuestro miedo era cierto. El recolector de basura no recogió la ventana infantil incluso después de cerrarla. No solo eso, sino que el recuento de objetos filtrados para el objeto ChildWindow también se muestra como " 3 " (hice clic en el botón 3 veces para mostrar 3 ventanas secundarias).
Bien, entonces separé el controlador de eventos como se muestra a continuación.
Luego, realicé los mismos pasos y verifiqué el generador de perfiles de memoria. Esta vez, ¡guau! no más pérdida de memoria.
Sí, -=
es suficiente. Sin embargo, podría resultar bastante difícil realizar un seguimiento de cada evento asignado. (para más detalles, consulte la publicación de Jon). En cuanto al patrón de diseño, eche un vistazo al patrón de evento débil .