Juegos previos
Considere el siguiente código:
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:
Todo esta bien esta vez:

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

Probemos con la tercera opción:
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:
- 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
- Al trabajar con cualquier clase administrada , no se producen fugas
- 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ápidaC # 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();
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);
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:
- 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)
- 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:
- El tipo en el que se llama el método
- El tipo de objeto que pasa el parámetro (para asegurarse de que se puede convertir al tipo del parámetro)
- ¿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
Bar3. Compile y ejecute el ExpressionTree resultante
4. Cuando recuerde la operación, hay dos opciones posibles:
4.1
Limitaciones trabajadas : solo llame a
Bar4.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 extremadamenteEs 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 TransparentProxyEn 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:
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);
Correctamente :
dynamic com = Activator.CreateInstance(comType);
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)) } }