Detalhes dinâmicos: jogos secretos do compilador, vazamento de memória, nuances de desempenho

Preliminares



Considere o seguinte 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); } 


A assinatura do método Marshal.FinalReleaseComObject é a seguinte:

 public static int FinalReleaseComObject(Object o) 


Criamos um objeto COM simples, fazemos algum trabalho e o liberamos imediatamente. Parece que o que poderia dar errado? Sim, criar um objeto dentro de um loop infinito não é uma boa prática, mas o GC fará todo o trabalho sujo. A realidade é um pouco diferente:



Para entender por que a memória vaza, você precisa entender como a dinâmica funciona. Já existem vários artigos sobre esse assunto em Habré, por exemplo, este , mas eles não entram em detalhes de implementação, portanto, conduziremos nossa própria pesquisa.



Primeiro, examinaremos em detalhes o mecanismo de trabalho dinâmico , reduziremos o conhecimento adquirido em uma única imagem e, no final, discutiremos as razões desse vazamento e como evitá-lo. Antes de mergulhar no código, vamos esclarecer os dados de origem: qual combinação de fatores leva ao vazamento?

Os experimentos



Talvez criar muitos objetos COM nativos seja uma má idéia em si? Vamos verificar:

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


Tudo está bem desta vez:



Vamos voltar à versão original do código, mas altere o 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); } 


E, novamente, sem surpresas:



Vamos tentar a terceira opção:

 //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); } 


Bem, agora, definitivamente devemos ter o mesmo comportamento! Hein? Não :(



Uma imagem semelhante será se você declarar com como um objeto ou se trabalhar com o COM gerenciado . Resuma os resultados experimentais:

  1. Instanciar objetos COM nativos por si só não leva a vazamentos - o GC lida com êxito com a limpeza de memória
  2. Ao trabalhar com qualquer classe gerenciada , não ocorrem vazamentos
  3. Ao converter explicitamente um objeto para outro, tudo está bem também


Olhando para o futuro, podemos adicionar o fato de que trabalhar com objetos dinâmicos (chamar métodos ou trabalhar com propriedades) por si só também não causa vazamentos. A conclusão sugere-se: um vazamento de memória ocorre quando passamos um objeto dinâmico (sem conversão "manual") contendo COM nativo , como parâmetro de método.

Precisamos ir mais fundo



É hora de lembrar o que é essa dinâmica :

Referência rápida
O C # 4.0 fornece um novo tipo de dinâmica . Esse tipo evita a verificação de tipo estático pelo compilador. Na maioria dos casos, funciona como um tipo de objeto . Em tempo de compilação, supõe-se que um elemento declarado como dinâmico suporte qualquer operação. Isso significa que você não precisa pensar sobre a origem do objeto - a partir da API COM, uma linguagem dinâmica como o IronPython, usando reflexão ou de outro lugar. Além disso, se o código for inválido, serão gerados erros em tempo de execução.

Por exemplo, se o método exampleMethod1 no código a seguir tiver exatamente um parâmetro, o compilador reconhecerá que a primeira chamada para o método ec.exampleMethod1 (10, 4) é inválida porque contém dois parâmetros. Isso resultará em um erro de compilação. A segunda chamada de método, dynamic_ec.exampleMethod1 (10, 4) não é verificada pelo compilador, pois dynamic_ec é declarado como dinâmico , portanto. não haverá erros de compilação. No entanto, o erro não passará despercebido para sempre - será detectado em tempo de execução.

 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) { } } 




O código que usa variáveis dinâmicas sofre alterações significativas durante a compilação. Este código:

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


Transforma-se no seguinte:

 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); 


Onde o__0 é a classe estática gerada e p__0 é o campo estático nela:

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


Nota: para cada interação com dinâmica , um campo CallSite é criado. Isso, como veremos mais adiante, é necessário para otimizar o desempenho

Observe que nenhuma menção à dinâmica é deixada - nosso objeto agora está armazenado em uma variável do tipo objeto . Vamos percorrer o código gerado. Primeiro, é criada uma ligação, que descreve o que e o que estamos fazendo:

 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 é uma descrição da nossa operação dinâmica. Deixe-me lembrá-lo de que passamos uma variável dinâmica para o método FinalReleaseComObject .

  • CSharpBinderFlags.ResultDiscarded - o resultado da execução do método não é usado no futuro
  • "FinalReleaseComObject" - o nome do método chamado
  • typeof (Foo) - contexto de operação; o tipo de chamada


CSharpArgumentInfo - descrição dos parâmetros de ligação. No nosso caso:

  • CSharpArgumentInfo.Create (CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.IsStaticType, (string) null) - descrição do primeiro parâmetro - a classe Marshal: é estática e seu tipo deve ser considerado durante a ligação
  • CSharpArgumentInfo.Create (CSharpArgumentInfoFlags.None, (string) null) - descrição do parâmetro do método, normalmente não há informações adicionais.


Se não se tratava de chamar um método, mas de, por exemplo, chamar uma propriedade de um objeto dinâmico , haveria apenas um CSharpArgumentInfo que descreve o próprio objeto dinâmico .

CallSite é um invólucro sobre uma expressão dinâmica. Ele contém dois campos importantes para nós:

  • Atualização pública T
  • público T Target


A partir do código gerado, fica claro que, quando qualquer operação é executada, o Target é chamado com parâmetros que a descrevem:

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


Em conjunto com o CSharpArgumentInfo descrito acima , esse código significa o seguinte: você precisa chamar o método FinalReleaseComObject na classe estática Marshal com o parâmetro de instância No momento da primeira chamada, o mesmo delegado é armazenado no Target e no Update . O delegado da atualização é responsável por duas tarefas importantes:

  1. Vincular uma operação dinâmica a estática (o próprio mecanismo de licitação está além do escopo deste artigo)
  2. Formação de cache


Estamos interessados ​​no segundo ponto. Deve-se notar aqui que, ao trabalhar com um objeto dinâmico, precisamos verificar a validade da operação a cada vez. Essa é uma tarefa que consome muitos recursos, então quero armazenar em cache os resultados dessas verificações. No que diz respeito à chamada de um método com um parâmetro, precisamos lembrar o seguinte:

  1. O tipo no qual o método é chamado
  2. O tipo de objeto transmitido pelo parâmetro (para garantir que ele possa ser convertido para o tipo do parâmetro)
  3. A operação é válida


Então, ao chamar Target novamente, não precisamos executar ligações relativamente caras: basta comparar os tipos e, se corresponderem, chamar a função objetivo. Para resolver esse problema, um ExpressionTree é criado para cada operação dinâmica, que armazena as restrições e a função objetivo à qual a expressão dinâmica foi vinculada.

Esta função pode ser de dois tipos:

  • Erro de ligação : por exemplo, um método é chamado em um objeto dinâmico que não existe ou um objeto dinâmico não pode ser convertido no tipo de parâmetro para o qual é passado: é necessário lançar uma exceção como Microsoft.CSharp.RuntimeBinderException: 'NoSuchMember'
  • O desafio é legal: basta executar a ação necessária


Essa ExpressionTree é formada durante a execução do delegado Update e armazenada no Target . Alvo - cache L0 , falaremos mais sobre o cache mais tarde.

Portanto, o Target armazena o último ExpressionTree gerado pelo delegado Update . Vamos ver como essa regra se parece com um exemplo de um tipo gerenciado passado para o 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: } } 


O bloco mais importante para nós:

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


$$ arg0 e $$ arg1 são os parâmetros com os quais o Target é chamado:
 Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, <b>this</b>, <b>instance</b>); 


Traduzido para humano, isso significa o seguinte:

Já verificamos que, se o primeiro parâmetro for do tipo Foo e o segundo for Int32 , você poderá chamar com segurança o Boo ((objeto) $$ arg1) .

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


Nota: no caso de um erro de ligação, o bloco Label1 se parece com isso:
 .Return #Label1 { .Throw .New Microsoft.CSharp.RuntimeBinderException("NoSuchMember") 


Essas verificações são chamadas de restrições . Existem dois tipos de restrições : por tipo de objeto e por instância específica do objeto (o objeto deve ser exatamente o mesmo). Se pelo menos uma das restrições falhar, teremos que verificar novamente a expressão dinâmica quanto à validade, para isso chamaremos o delegado de Atualização . De acordo com o esquema já conhecido, ele executará a ligação com novos tipos e salvará o novo ExpressionTree no Target .

Cache



Já descobrimos que o destino é um cache L0 . Cada vez que o Target é chamado, a primeira coisa a fazer é passar pelas restrições já armazenadas nele. Se as restrições falharem e uma nova ligação for gerada, a regra antiga será simultaneamente para L1 e L2 . No futuro, quando você perder o cache L0 , as regras de L1 e L2 serão pesquisadas até encontrar uma adequada.

  • L1 : As últimas dez regras que deixaram L0 (armazenadas diretamente no CallSite )
  • L2 : As últimas 128 regras criadas usando uma instância específica do fichário (que é CallSiteBinder , exclusiva para cada CallSite )


Agora, finalmente, podemos adicionar esses detalhes em um único todo e descrever na forma de um algoritmo o que acontece quando Foo.Bar (someDynamicObject) é chamado :

1. É criado um fichário que lembra o contexto e o método chamado no nível de suas assinaturas

2. Na primeira vez que a operação é chamada, ExpressionTree é criado, que armazena:
2.1 Limitações . Nesse caso, haverá duas restrições no tipo de parâmetros de ligação atuais
2.2 Função objetivo : ou lançar alguma exceção (neste caso, é impossível, pois qualquer dinâmica levará com sucesso ao objeto) ou uma chamada ao método Bar

3. Compile e execute o ExpressionTree resultante

4. Quando você recorda a operação, duas opções são possíveis:
4.1 Limitações trabalhadas : basta chamar Bar
4.2 As limitações não funcionaram : repita a etapa 2 para novos parâmetros de ligação

Portanto, com o exemplo do tipo Gerenciado , ficou aproximadamente claro como a dinâmica funciona por dentro. No caso descrito, nunca perderemos o cache, pois os tipos são sempre os mesmos *, portanto, Update será chamado exatamente uma vez quando o CallSite for inicializado. Então, para cada chamada, apenas restrições serão verificadas e a função objetivo será chamada imediatamente. Isso está de acordo com nossas observações da memória: sem cálculo - sem vazamentos.

* Por esse motivo, o compilador gera seus CallSites para cada um: a probabilidade de perder o cache L0 é extremamente reduzida

É hora de descobrir como esse esquema difere no caso de objetos COM nativos . Vamos dar uma olhada no 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: } } 


Pode-se ver que a diferença está apenas na segunda restrição:

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


Se, no caso do código gerenciado , tivéssemos duas restrições no tipo de objetos, aqui vemos que a segunda restrição verifica a equivalência de instâncias por meio de WeakReference .

Nota: A restrição de instância, além dos objetos COM, também é usada para o TransparentProxy

Na prática, com base no nosso conhecimento da operação do cache, isso significa que toda vez que recriarmos um objeto COM em um loop, perderemos o cache L0 (e L1 / L2 também, porque as regras antigas com links serão armazenadas lá para instâncias antigas). A primeira suposição que você pergunta na cabeça é que o cache de regras está fluindo. Mas o código é bem simples e está tudo bem: as regras antigas são excluídas corretamente. Ao mesmo tempo, o uso de WeakReference no ExpressionTree não impede que o GC colete objetos desnecessários.

O mecanismo para salvar regras no cache 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; } 


Então, qual é o problema? Vamos tentar esclarecer a hipótese: um vazamento de memória ocorre em algum lugar ao vincular um objeto COM .

Experiências, parte 2



Novamente, vamos passar de conclusões especulativas para experimentos. Primeiro, vamos repetir o que o compilador faz por nós:

 //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); } 


Verificamos:



O vazamento foi preservado. Justo. Mas qual o motivo? Depois de estudar o código dos fichários (que deixamos entre colchetes), fica claro que a única coisa que afeta o tipo de nosso objeto é a opção de restrição. Talvez isso não seja uma questão de objetos COM , mas um fichário? Não há muitas opções, vamos provocar várias ligações para o tipo gerenciado :

 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); } 




Uau! Parece que o pegamos. O problema não é de todo com o objeto COM , como nos pareceu inicialmente, apenas por causa das limitações da instância, este é o único caso em que a ligação ocorre muitas vezes dentro do nosso loop. Em todos os outros casos, peguei o cache L0 e vinculei uma vez.

Conclusões



Vazamento de memória



Se você trabalha com variáveis dinâmicas que contêm COM nativo ou TransparentProxy , nunca as passe como parâmetros de método. Se você ainda precisar fazer isso, use a conversão explícita para objetar e o compilador ficará para trás.

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


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


Como precaução adicional, tente instanciar esses objetos o mais raramente possível. Real para todas as versões do .NET Framework . (Por enquanto) não é muito relevante para. NET Core , pois não há suporte para objetos COM dinâmicos .

Desempenho



É do seu interesse que as falhas de cache ocorram o mais raramente possível, pois nesse caso não há necessidade de encontrar uma regra adequada nos caches de alto nível. As falhas no cache L0 ocorrerão principalmente no caso de uma incompatibilidade do tipo de objeto dinâmico com as restrições preservadas.

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


No entanto, na prática, você provavelmente não notará a diferença no desempenho, a menos que o número de chamadas para essa função seja medido em milhões ou se a variabilidade de tipos não for extraordinariamente grande. Os custos em caso de perda no cache L0 são tais, N é o número de tipos:

  • N <10. Se você errar, repita apenas as regras de cache L1 existentes
  • 10 < N <128 . Enumeração de cache L1 e L2 (máximo de 10 e N iterações). Criando e preenchendo uma matriz de 10 elementos
  • N > 128. Repita o cache L1 e L2 . Crie e preencha matrizes de 10 e 128 elementos. Se você perder o cache L2 , volte a ligar


No segundo e terceiro casos, a carga no GC aumentará.

Conclusão



Infelizmente, não encontramos um motivo real para o vazamento de memória, isso exigirá um estudo separado do fichário. Felizmente, o WinDbg fornece uma dica para uma investigação mais aprofundada: algo ruim acontece no DLR . A primeira coluna é o número de objetos



Bônus



Por que a conversão de objetos evita explicitamente um vazamento?
Qualquer tipo pode ser convertido em objeto , portanto, a operação deixa de ser dinâmica.

Por que não há vazamentos ao trabalhar com campos e métodos de um objeto COM?
É assim que o ExpressionTree se parece com o acesso ao 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/pt466657/


All Articles