Detalle dinámico: juegos encubiertos del compilador, pérdida de memoria, matices de rendimiento

Juegos previos



Considere el siguiente código:

//Any native COM object var comType = Type.GetTypeFromCLSID(new Guid("E13B6688-3F39-11D0-96F6-00A0C9191601")); while (true) { dynamic com = Activator.CreateInstance(comType); //do some work Marshal.FinalReleaseComObject(com); } 


La firma del método Marshal.FinalReleaseComObject es la siguiente:

 public static int FinalReleaseComObject(Object o) 


Creamos un objeto COM simple, hacemos algo de trabajo y lo lanzamos inmediatamente. Parece que lo que podría salir mal? Sí, crear un objeto dentro de un bucle infinito no es una buena práctica, pero el GC asumirá todo el trabajo sucio. La realidad es ligeramente diferente:



Para comprender por qué se pierde la memoria, debe comprender cómo funciona la dinámica . Ya hay varios artículos sobre este tema en Habré, por ejemplo, este , pero no detallan la implementación, por lo que llevaremos a cabo nuestra propia investigación.



Primero, examinaremos en detalle el mecanismo de trabajo dinámico , luego reduciremos el conocimiento adquirido en una sola imagen y al final discutiremos las razones de esta filtración y cómo evitarla. Antes de sumergirnos en el código, aclaremos los datos de origen: ¿qué combinación de factores conduce a la fuga?

Los experimentos



¿Quizás crear muchos objetos COM nativos es una mala idea en sí misma? Vamos a ver:

 //Any native COM object var comType = Type.GetTypeFromCLSID(new Guid("E13B6688-3F39-11D0-96F6-00A0C9191601")); while (true) { dynamic com = Activator.CreateInstance(comType); } 


Todo esta bien esta vez:



Volvamos a la versión original del código, pero cambie el tipo de objeto:

 //Any managed type include managed COM var type = typeof(int); while (true) { dynamic com = Activator.CreateInstance(type); //do some work Marshal.FinalReleaseComObject(com); } 


Y de nuevo, sin sorpresas:



Probemos con la tercera opción:

 //Simple COM object var comType = Type.GetTypeFromCLSID(new Guid("435356F9-F33F-403D-B475-1E4AB512FF95")); while (true) { dynamic com = Activator.CreateInstance(comType); //do some work Marshal.FinalReleaseComObject((object) com); } 


Bueno, ¡definitivamente deberíamos tener el mismo comportamiento! ¿Eh? No :(



Una imagen similar será si declaras com como un objeto o si trabajas con COM administrado . Resuma los resultados experimentales:

  1. La creación de instancias de objetos COM nativos por sí sola no genera fugas: GC se enfrenta con éxito a la eliminación de memoria
  2. Al trabajar con cualquier clase administrada , no se producen fugas
  3. Al lanzar explícitamente un objeto a objeto , todo está bien también


Mirando hacia el futuro, al primer punto podemos agregar el hecho de que trabajar con objetos dinámicos (métodos de llamada o trabajar con propiedades) por sí mismo tampoco causa fugas. La conclusión se sugiere a sí misma: se produce una pérdida de memoria cuando pasamos un objeto dinámico (sin conversión de tipo "manual") que contiene COM nativo , como parámetro de método.

Necesitamos profundizar



Es hora de recordar de qué se trata esta dinámica :

Referencia rápida
C # 4.0 proporciona un nuevo tipo de dinámica . Este tipo evita la comprobación de tipo estático por parte del compilador. En la mayoría de los casos, funciona como un tipo de objeto . En tiempo de compilación, se supone que un elemento declarado como dinámico admite cualquier operación. Esto significa que no necesita pensar de dónde proviene el objeto: desde la API COM, un lenguaje dinámico como IronPython, usando la reflexión, o desde otro lugar. Además, si el código no es válido, se generarán errores en tiempo de ejecución.

Por ejemplo, si el método exampleMethod1 en el siguiente código tiene exactamente un parámetro, el compilador reconoce que la primera llamada al método ec.exampleMethod1 (10, 4) no es válida porque contiene dos parámetros. Esto dará como resultado un error de compilación. El compilador no verifica la segunda llamada de método, dynamic_ec.exampleMethod1 (10, 4) , ya que, por lo tanto, dynamic_ec se declara como dinámico . No habrá errores de compilación. Sin embargo, el error no pasará desapercibido para siempre: se detectará en tiempo de ejecución.

 static void Main(string[] args) { ExampleClass ec = new ExampleClass(); //      ,  exampleMethod1    . //ec.exampleMethod1(10, 4); dynamic dynamic_ec = new ExampleClass(); //      ,  //      dynamic_ec.exampleMethod1(10, 4); //        ,  //  ,      dynamic_ec.someMethod("some argument", 7, null); dynamic_ec.nonexistentMethod(); } 


 class ExampleClass { public ExampleClass() { } public ExampleClass(int v) { } public void exampleMethod1(int i) { } public void exampleMethod2(string str) { } } 




El código que usa variables dinámicas sufre cambios significativos durante la compilación. Este código:

 dynamic com = Activator.CreateInstance(comType); Marshal.FinalReleaseComObject(com); 


Se convierte en lo siguiente:

 object instance = Activator.CreateInstance(typeFromClsid); // ISSUE: reference to a compiler-generated field if (Foo.o__0.p__0 == null) { // ISSUE: reference to a compiler-generated field Foo.o__0.p__0 = CallSite<Action<CallSite, Type, object>>.Create(Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "FinalReleaseComObject", (IEnumerable<Type>) null, typeof (Foo), (IEnumerable<CSharpArgumentInfo>) new CSharpArgumentInfo[2] { CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.IsStaticType, (string) null), CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, (string) null) })); } // ISSUE: reference to a compiler-generated field // ISSUE: reference to a compiler-generated field Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, typeof (Marshal), instance); 


Donde o__0 es la clase estática generada, y p__0 es el campo estático en ella:

 private class o__0 { public static CallSite<Action<CallSite, Type, object>> p__0; } 


Nota: para cada interacción con Dynamic , se crea un campo CallSite. Esto, como se verá más adelante, es necesario para optimizar el rendimiento.

Tenga en cuenta que no queda ninguna mención de dinámica : nuestro objeto ahora se almacena en una variable de tipo objeto . Veamos el código generado. Primero, se crea un enlace, que describe qué y qué estamos haciendo:

 Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "FinalReleaseComObject", (IEnumerable<Type>) null, typeof (Foo), (IEnumerable<CSharpArgumentInfo>) new CSharpArgumentInfo[2] { CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.IsStaticType, (string) null), CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, (string) null) }) 


Esta es una descripción de nuestra operación dinámica. Permítame recordarle que pasamos una variable dinámica al método FinalReleaseComObject .

  • CSharpBinderFlags.ResultDiscarded: el resultado de la ejecución del método no se utiliza en el futuro
  • "FinalReleaseComObject": el nombre del método llamado
  • typeof (Foo) - contexto de operación; el tipo de llamada


CSharpArgumentInfo : descripción de los parámetros de enlace. En nuestro caso:

  • CSharpArgumentInfo.Create (CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.IsStaticType, (string) null) - descripción del primer parámetro - la clase Marshal: es estática y su tipo debe tenerse en cuenta al vincular
  • CSharpArgumentInfo.Create (CSharpArgumentInfoFlags.None, (string) null): descripción del parámetro del método, generalmente no hay información adicional.


Si no se trata de llamar a un método, sino de, por ejemplo, llamar a una propiedad desde un objeto dinámico , entonces solo habrá un CSharpArgumentInfo que describa el objeto dinámico en sí.

CallSite es un contenedor sobre una expresión dinámica. Contiene dos campos importantes para nosotros:

  • Actualización T pública
  • Público T Target


Del código generado, está claro que cuando se realiza cualquier operación, se llama a Target con los parámetros que lo describen:

 Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, typeof (Marshal), instance); 


Junto con el CSharpArgumentInfo descrito anteriormente , este código significa lo siguiente: debe llamar al método FinalReleaseComObject en la clase Marshal estática con el parámetro de instancia. En el momento de la primera llamada, el mismo delegado se almacena en Target como en Update . El delegado de actualización es responsable de dos tareas importantes:

  1. Vinculando una operación dinámica a una estática (el mecanismo de licitación en sí está más allá del alcance de este artículo)
  2. Formación de caché


Estamos interesados ​​en el segundo punto. Cabe señalar aquí que cuando trabajamos con un objeto dinámico, necesitamos verificar la validez de la operación cada vez. Esta es una tarea bastante intensiva en recursos, por lo que quiero almacenar en caché los resultados de tales comprobaciones. Con respecto a llamar a un método con un parámetro, debemos recordar lo siguiente:

  1. El tipo en el que se llama el método
  2. El tipo de objeto que pasa el parámetro (para asegurarse de que se puede convertir al tipo del parámetro)
  3. ¿Es válida la operación?


Luego, al volver a llamar a Target , no necesitamos realizar enlaces relativamente caros: solo compare los tipos y, si coinciden, llame a la función objetivo. Para resolver este problema, se crea un ExpressionTree para cada operación dinámica, que almacena las restricciones y la función objetivo a la que estaba vinculada la expresión dinámica.

Esta función puede ser de dos tipos:

  • Error de enlace : por ejemplo, se llama a un método en un objeto dinámico que no existe o un objeto dinámico no se puede convertir al tipo del parámetro al que se pasa: entonces debe lanzar una excepción como Microsoft.CSharp.RuntimeBinderException: 'NoSuchMember'
  • El desafío es legal: entonces solo realice la acción requerida


Este ExpressionTree se forma cuando el delegado de actualización se ejecuta y se almacena en Target . Objetivo : caché L0 , hablaremos más sobre la caché más adelante.

Entonces, Target almacena el último ExpressionTree generado a través del delegado de actualización . Veamos cómo se ve esta regla como un ejemplo de un tipo administrado pasado al método Boo :

 public class Foo { public void Test() { var type = typeof(int); dynamic instance = Activator.CreateInstance(type); Boo(instance); } public void Boo(object o) { } } 


 .Lambda CallSite.Target<System.Action`3[Actionsss.CallSite,ConsoleApp12.Foo,System.Object]>( Actionsss.CallSite $$site, ConsoleApp12.Foo $$arg0, System.Object $$arg1) { .Block() { .If ($$arg0 .TypeEqual ConsoleApp12.Foo && $$arg1 .TypeEqual System.Int32) { .Return #Label1 { .Block() { .Call $$arg0.Boo((System.Object)((System.Int32)$$arg1)); .Default(System.Object) } } } .Else { .Default(System.Void) }; .Block() { .Constant<Actionsss.Ast.Expression>(IIF((($arg0 TypeEqual Foo) AndAlso ($arg1 TypeEqual Int32)), returnUnamedLabel_0 ({ ... }) , default(Void))); .Label .LabelTarget CallSiteBinder.UpdateLabel: }; .Label .If ( .Call Actionsss.CallSiteOps.SetNotMatched($$site) ) { .Default(System.Void) } .Else { .Invoke (((Actionsss.CallSite`1[System.Action`3[Actionsss.CallSite,ConsoleApp12.Foo,System.Object]])$$site).Update)( $$site, $$arg0, $$arg1) } .LabelTarget #Label1: } } 


El bloque más importante para nosotros:

 .If ($$arg0 .TypeEqual ConsoleApp12.Foo && $$arg1 .TypeEqual System.Int32) 


$$ arg0 y $$ arg1 son los parámetros con los que se llama Target :
 Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, <b>this</b>, <b>instance</b>); 


Traducido al ser humano, esto significa lo siguiente:

Ya hemos verificado que si el primer parámetro es del tipo Foo y el segundo es Int32 , puede llamar a Boo ((objeto) $$ arg1) de forma segura.

 .Return #Label1 { .Block() { .Call $$arg0.Boo((System.Object)((System.Int32)$$arg1)); .Default(System.Object) } 


Nota: en caso de un error de enlace, el bloque Label1 se ve así:
 .Return #Label1 { .Throw .New Microsoft.CSharp.RuntimeBinderException("NoSuchMember") 


Estas comprobaciones se llaman restricciones . Hay dos tipos de restricciones : por tipo de objeto y por instancia específica del objeto (el objeto debe ser exactamente el mismo). Si falla al menos una de las restricciones, tendremos que volver a verificar la validez de la expresión dinámica, para esto llamaremos al delegado de Actualización . Según el esquema que ya conocemos, realizará un enlace con nuevos tipos y guardará el nuevo ExpressionTree en Target .

Caché



Ya descubrimos que Target es un caché L0 . Cada vez que se llama a Target , lo primero que haremos es revisar las restricciones ya almacenadas en él. Si las restricciones fallan y se genera un nuevo enlace, la regla anterior va simultáneamente a L1 y L2 . En el futuro, cuando pierda el caché L0 , se buscarán las reglas de L1 y L2 hasta que se encuentre una adecuada.

  • L1 : Las últimas diez reglas que han dejado L0 (almacenadas directamente en CallSite )
  • L2 : las últimas 128 reglas creadas utilizando una instancia de carpeta específica (que es CallSiteBinder , única para cada CallSite )


Ahora finalmente podemos agregar estos detalles en un solo conjunto y describir en forma de algoritmo lo que sucede cuando se llama a Foo.Bar (someDynamicObject) :

1. Se crea una carpeta que recuerda el contexto y el método llamado a nivel de sus firmas

2. La primera vez que se llama a la operación, se crea ExpressionTree , que almacena:
2.1 Limitaciones . En este caso, serán dos restricciones sobre el tipo de parámetros de enlace actuales
2.2 Función objetivo : lanzar alguna excepción (en este caso es imposible, ya que cualquier dinámica conducirá con éxito al objeto) o una llamada al método Bar

3. Compile y ejecute el ExpressionTree resultante

4. Cuando recuerde la operación, hay dos opciones posibles:
4.1 Limitaciones trabajadas : solo llame a Bar
4.2 Las limitaciones no funcionaron : repita el paso 2 para los nuevos parámetros de enlace

Entonces, con el ejemplo del tipo Gestionado , quedó aproximadamente claro cómo funciona la dinámica desde adentro. En el caso descrito, nunca perderemos el caché, ya que los tipos son siempre iguales *, por lo tanto, Update se llamará exactamente una vez cuando CallSite se inicialice. Luego, para cada llamada, solo se verificarán las restricciones y la función objetivo se llamará de inmediato. Esto está en excelente acuerdo con nuestras observaciones de memoria: sin cálculo, sin fugas.

* Por esta razón, el compilador genera sus CallSites para cada uno: la probabilidad de perder el caché L0 se reduce extremadamente

Es hora de descubrir cómo este esquema difiere en el caso de los objetos COM nativos . Echemos un vistazo a ExpressionTree :

 .Lambda CallSite.Target<System.Action`3[Actionsss.CallSite,ConsoleApp12.Foo,System.Object]>( Actionsss.CallSite $$site, ConsoleApp12.Foo $$arg0, System.Object $$arg1) { .Block() { .If ($$arg0 .TypeEqual ConsoleApp12.Foo && .Block(System.Object $var1) { $var1 = .Constant<System.WeakReference>(System.WeakReference).Target; $var1 != null && (System.Object)$$arg1 == $var1 }) { .Return #Label1 { .Block() { .Call $$arg0.Boo((System.__ComObject)$$arg1); .Default(System.Object) } } } .Else { .Default(System.Void) }; .Block() { .Constant<Actionsss.Ast.Expression>(IIF((($arg0 TypeEqual Foo) AndAlso {var Param_0; ... }), returnUnamedLabel_1 ({ ... }) , default(Void))); .Label .LabelTarget CallSiteBinder.UpdateLabel: }; .Label .If ( .Call Actionsss.CallSiteOps.SetNotMatched($$site) ) { .Default(System.Void) } .Else { .Invoke (((Actionsss.CallSite`1[System.Action`3[Actionsss.CallSite,ConsoleApp12.Foo,System.Object]])$$site).Update)( $$site, $$arg0, $$arg1) } .LabelTarget #Label1: } } 


Se puede ver que la diferencia está solo en la segunda restricción:

 .If ($$arg0 .TypeEqual ConsoleApp12.Foo && .Block(System.Object $var1) { $var1 = .Constant<System.WeakReference>(System.WeakReference).Target; $var1 != null && (System.Object)$$arg1 == $var1 }) 


Si en el caso del código administrado tuvimos dos restricciones en el tipo de objetos, entonces aquí vemos que la segunda restricción verifica la equivalencia de instancias a través de WeakReference .

Nota: La restricción de instancias además de los objetos COM también se usa para TransparentProxy

En la práctica, según nuestro conocimiento del funcionamiento de la memoria caché, esto significa que cada vez que volvamos a crear un objeto COM en un bucle, perderemos la memoria caché L0 (y también L1 / L2 , porque las viejas reglas con enlaces se almacenarán allí). a instancias antiguas). La primera suposición que le pregunta en la cabeza es que el caché de reglas está fluyendo. Pero el código allí es bastante simple y todo está bien allí: las viejas reglas se eliminan correctamente. Al mismo tiempo, usar WeakReference en ExpressionTree no impide que el GC recopile objetos innecesarios.

El mecanismo para guardar reglas en el caché L1:

 const int MaxRules = 10; internal void AddRule(T newRule) { T[] rules = Rules; if (rules == null) { Rules = new[] { newRule }; return; } T[] temp; if (rules.Length < (MaxRules - 1)) { temp = new T[rules.Length + 1]; Array.Copy(rules, 0, temp, 1, rules.Length); } else { temp = new T[MaxRules]; Array.Copy(rules, 0, temp, 1, MaxRules - 1); } temp[0] = newRule; Rules = temp; } 


Entonces, ¿cuál es el trato? Intentemos aclarar la hipótesis: se produce una pérdida de memoria en algún lugar al vincular un objeto COM .

Experimentos, parte 2



Nuevamente, pasemos de las conclusiones especulativas a los experimentos. Primero, repitamos lo que el compilador hace por nosotros:

 //Simple COM object var comType = Type.GetTypeFromCLSID(new Guid("435356F9-F33F-403D-B475-1E4AB512FF95")); var autogeneratedBinder = Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "Boo", null, typeof(Foo), new CSharpArgumentInfo[2] { CSharpArgumentInfo.Create( CSharpArgumentInfoFlags.UseCompileTimeType, null), CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null) }); var callSite = CallSite<Action<CallSite, Foo, object>>.Create(autogeneratedBinder); while (true) { object instance = Activator.CreateInstance(comType); callSite.Target(callSite, this, instance); } 


Comprobamos:



La fuga ha sido preservada. Justo Pero cual es la razon? Después de estudiar el código de las carpetas (que dejamos entre paréntesis), está claro que lo único que afecta el tipo de nuestro objeto es la opción de restricción. ¿Quizás no se trata de objetos COM , sino de una carpeta? No hay muchas opciones, provoquemos enlaces múltiples para el tipo Gestionado :

 while (true) { object instance = Activator.CreateInstance(typeof(int)); var autogeneratedBinder = Binder.InvokeMember(CSharpBinderFlags.ResultDiscarded, "Boo", null, typeof(Foo), new CSharpArgumentInfo[2] { CSharpArgumentInfo.Create( CSharpArgumentInfoFlags.UseCompileTimeType, null), CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null) }); var callSite = CallSite<Action<CallSite, Foo, object>>.Create(autogeneratedBinder); callSite.Target(callSite, this, instance); } 




Wow! Parece que lo atrapamos. El problema no está en absoluto con el objeto COM , como nos pareció inicialmente, solo debido a las limitaciones de la instancia, este es el único caso en el que el enlace ocurre muchas veces dentro de nuestro bucle. En todos los demás casos, levanté el caché L0 y lo vinculé una vez.

Conclusiones



Pérdida de memoria



Si trabaja con variables dinámicas que contienen COM nativo o TransparentProxy , nunca las pase como parámetros de método. Si aún necesita hacer esto, use la conversión explícita para objetar y luego el compilador se retrasará.

Equivocado :
 dynamic com = Activator.CreateInstance(comType); //do some work Marshal.FinalReleaseComObject(com); 


Correctamente :
 dynamic com = Activator.CreateInstance(comType); //do some work Marshal.FinalReleaseComObject((object) com); 


Como precaución adicional, intente instanciar tales objetos tan raramente como sea posible. Actual para todas las versiones de .NET Framework . (Por ahora) no es muy relevante para. NET Core , ya que no hay soporte para objetos COM dinámicos .

Rendimiento



Es de su interés que las fallas de caché ocurran tan raramente como sea posible, ya que en este caso no hay necesidad de encontrar una regla adecuada en cachés de alto nivel. Las fallas en el caché L0 ocurrirán principalmente en el caso de una falta de coincidencia del tipo del objeto dinámico con las restricciones preservadas.

 dynamic com = GetSomeObject(); public object GetSomeObject() { //:      //:         } 


Sin embargo, en la práctica, probablemente no notará la diferencia en el rendimiento a menos que el número de llamadas a esta función se mida en millones o si la variabilidad de los tipos no es inusualmente grande. Los costos en caso de fallo en el caché L0 son tales, N es el número de tipos:

  • N <10. Si falla, solo repita las reglas de caché L1 existentes
  • 10 < N <128 . Enumeración de caché L1 y L2 (máximo 10 y N iteraciones). Crear y completar una matriz de 10 elementos.
  • N > 128. Iterar sobre caché L1 y L2 . Crear y completar matrices de 10 y 128 elementos. Si pierde el caché L2 , vuelva a vincular


En el segundo y tercer casos, la carga en el GC aumentará.

Conclusión



Desafortunadamente, no encontramos una razón real para la pérdida de memoria, esto requerirá un estudio separado de la carpeta. Afortunadamente, WinDbg proporciona una pista para una mayor investigación: algo malo sucede en DLR . La primera columna es el número de objetos.



Bono



¿Por qué la conversión al objeto evita explícitamente una fuga?
Cualquier tipo puede convertirse en objeto , por lo que la operación deja de ser dinámica.

¿Por qué no hay fugas cuando se trabaja con campos y métodos de un objeto COM?
Así es como se ve ExpressionTree para el acceso de campo:

 .If ( .Call System.Dynamic.ComObject.IsComObject($$arg0) ) { .Return #Label1 { .Dynamic GetMember ComMarks(.Call System.Dynamic2.ComObject.ObjectToComObject($$arg0)) } } 

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


All Articles