Détail dynamique: jeux cachés du compilateur, fuite de mémoire, nuances de performances

Foreplay



Considérez le code suivant:

//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 signature de la méthode Marshal.FinalReleaseComObject est la suivante:

 public static int FinalReleaseComObject(Object o) 


Nous créons un simple objet COM, faisons un peu de travail et le libérons immédiatement. Il semblerait que ce qui pourrait mal tourner? Oui, la création d'un objet à l'intérieur d'une boucle infinie n'est pas une bonne pratique, mais le GC prendra tout le sale boulot. La réalité est légèrement différente:



Pour comprendre les fuites de mémoire, vous devez comprendre le fonctionnement de la dynamique . Il existe déjà plusieurs articles sur ce sujet sur Habré, par exemple celui-ci , mais ils n'entrent pas dans les détails de mise en œuvre, nous allons donc mener nos propres recherches.



Tout d'abord, nous examinerons en détail le mécanisme de travail dynamique , puis nous réduirons les connaissances acquises en une seule image et à la fin, nous discuterons des raisons de cette fuite et comment l'éviter. Avant de plonger dans le code, clarifions les données sources: quelle combinaison de facteurs conduit à la fuite?

Les expériences



La création de nombreux objets COM natifs est peut-être une mauvaise idée en soi? Vérifions:

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


Tout va bien cette fois:



Revenons à la version originale du code, mais changeons le type d'objet:

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


Et encore une fois, pas de surprise:



Essayons la troisième option:

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


Eh bien maintenant, nous devrions certainement avoir le même comportement! Hein? Non :(



Une image similaire sera si vous déclarez com en tant qu'objet ou si vous travaillez avec COM géré . Résumez les résultats expérimentaux:

  1. L'instanciation d'objets COM natifs en soi ne conduit pas à des fuites - le GC réussit à faire face à l'effacement de la mémoire
  2. Lorsque vous travaillez avec une classe gérée , aucune fuite ne se produit
  3. Lors de la conversion explicite d'un objet en objet , tout va bien aussi


Pour l'avenir, au premier point, nous pouvons ajouter le fait que travailler avec des objets dynamiques (appeler des méthodes ou travailler avec des propriétés) ne provoque pas en soi de fuites. La conclusion se suggère: une fuite de mémoire se produit lorsque nous passons un objet dynamique (sans conversion de type "manuelle") contenant COM natif , comme paramètre de méthode.

Nous devons aller plus loin



Il est temps de se rappeler en quoi consiste cette dynamique :

Référence rapide
C # 4.0 fournit un nouveau type de dynamique . Ce type évite la vérification de type statique par le compilateur. Dans la plupart des cas, il fonctionne comme un type d' objet . Au moment de la compilation, il est supposé qu'un élément déclaré comme dynamique prend en charge toute opération. Cela signifie que vous n'avez pas besoin de penser à la provenance de l'objet - à partir de l'API COM, d'un langage dynamique comme IronPython, à l'aide de la réflexion, ou d'ailleurs. De plus, si le code n'est pas valide, des erreurs seront lancées lors de l'exécution.

Par exemple, si la méthode exampleMethod1 dans le code suivant a exactement un paramètre, le compilateur reconnaît que le premier appel à la méthode ec.exampleMethod1 (10, 4) n'est pas valide car il contient deux paramètres. Cela entraînera une erreur de compilation. Le deuxième appel de méthode, dynamic_ec.exampleMethod1 (10, 4) n'est pas vérifié par le compilateur, car dynamic_ec est donc déclaré comme dynamique . il n'y aura pas d'erreurs de compilation. Néanmoins, l'erreur ne passera pas inaperçue pour toujours - elle sera détectée lors de l'exécution.

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




Le code qui utilise des variables dynamiques subit des changements importants lors de la compilation. Ce code:

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


Se transforme en ce qui suit:

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


o__0 est la classe statique générée et p__0 est le champ statique qu'il contient :

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


Remarque: pour chaque interaction avec dynamique , un champ CallSite est créé. Ceci, comme nous le verrons plus loin, est nécessaire pour optimiser les performances.

Notez qu'aucune mention de dynamique n'est laissée - notre objet est maintenant stocké dans une variable de type objet . Passons en revue le code généré. Tout d'abord, une liaison est créée, qui décrit ce que nous faisons et ce que nous faisons:

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


Ceci est une description de notre fonctionnement dynamique. Permettez-moi de vous rappeler que nous transmettons une variable dynamique à la méthode FinalReleaseComObject .

  • CSharpBinderFlags.ResultDiscarded - le résultat de l'exécution de la méthode n'est pas utilisé à l'avenir
  • "FinalReleaseComObject" - le nom de la méthode appelée
  • typeof (Foo) - contexte d'opération; le type d'appel


CSharpArgumentInfo - description des paramètres de liaison. Dans notre cas:

  • CSharpArgumentInfo.Create (CSharpArgumentInfoFlags.UseCompileTimeType | CSharpArgumentInfoFlags.IsStaticType, (string) null) - description du premier paramètre - la classe Marshal: elle est statique et son type doit être pris en compte lors de la liaison
  • CSharpArgumentInfo.Create (CSharpArgumentInfoFlags.None, (string) null) - description du paramètre de méthode, généralement il n'y a pas d'informations supplémentaires.


S'il ne s'agissait pas d'appeler une méthode, mais d'appeler, par exemple, une propriété à partir d'un objet dynamique , il n'y aurait alors qu'un seul CSharpArgumentInfo décrivant l'objet dynamique lui-même.

CallSite est un wrapper sur une expression dynamique. Il contient deux domaines importants pour nous:

  • mise à jour T publique
  • public T Target


D'après le code généré, il est clair que lorsqu'une opération est effectuée, Target est appelé avec des paramètres le décrivant:

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


En combinaison avec CSharpArgumentInfo décrit ci-dessus , ce code signifie ce qui suit: vous devez appeler la méthode FinalReleaseComObject sur la classe Marshal statique avec le paramètre d'instance. Au moment du premier appel, le même délégué est stocké dans Target comme dans Update . Le délégué de mise à jour est responsable de deux tâches importantes:

  1. Lier une opération dynamique à une opération statique (le mécanisme d'enchère lui-même dépasse le cadre de cet article)
  2. Formation de cache


Nous nous intéressons au deuxième point. Il convient de noter ici que lorsque vous travaillez avec un objet dynamique, nous devons vérifier la validité de l'opération à chaque fois. Il s'agit d'une tâche plutôt gourmande en ressources, je souhaite donc mettre en cache les résultats de ces vérifications. En ce qui concerne l'appel d'une méthode avec un paramètre, nous devons nous rappeler ce qui suit:

  1. Le type sur lequel la méthode est appelée
  2. Le type d'objet transmis par le paramètre (pour être sûr qu'il peut être converti en type de paramètre)
  3. L'opération est-elle valide


Ensuite, lorsque vous appelez à nouveau Target , nous n'avons pas besoin d'effectuer des liaisons relativement coûteuses: il suffit de comparer les types et, s'ils correspondent, d'appeler la fonction objectif. Pour résoudre ce problème, un ExpressionTree est créé pour chaque opération dynamique, qui stocke les contraintes et la fonction objectif à laquelle l'expression dynamique était liée.

Cette fonction peut être de deux types:

  • Erreur de liaison : par exemple, une méthode est appelée sur un objet dynamique qui n'existe pas ou un objet dynamique ne peut pas être converti dans le type du paramètre auquel il est transmis: vous devez alors lever une exception comme Microsoft.CSharp.RuntimeBinderException: 'NoSuchMember'
  • Le défi est légal: il suffit alors d'effectuer l'action requise


Cet ExpressionTree est formé lors de l'exécution du délégué de mise à jour et stocké dans Target . Cible - Cache L0 , nous parlerons plus en détail du cache plus tard.

Ainsi, Target stocke le dernier ExpressionTree généré via le délégué de mise à jour . Voyons à quoi ressemble cette règle comme un exemple de type Managed passé à la méthode 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: } } 


Le bloc le plus important pour nous:

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


$$ arg0 et $$ arg1 sont les paramètres avec lesquels Target est appelé:
 Foo.o__0.p__0.Target((CallSite) Foo.o__0.p__0, <b>this</b>, <b>instance</b>); 


Traduit en humain, cela signifie ce qui suit:

Nous avons déjà vérifié que si le premier paramètre est de type Foo et le second est Int32 , alors vous pouvez appeler Boo en toute sécurité ((object) $$ arg1) .

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


Remarque: en cas d'erreur de liaison, le bloc Label1 ressemble à ceci:
 .Return #Label1 { .Throw .New Microsoft.CSharp.RuntimeBinderException("NoSuchMember") 


Ces vérifications sont appelées contraintes . Il existe deux types de restrictions : par type d'objet et par instance spécifique de l'objet (l'objet doit être exactement le même). Si au moins une des restrictions échoue, nous devrons revérifier la validité de l'expression dynamique, pour cela nous appellerons le délégué de mise à jour . Selon le schéma que nous connaissons déjà, il effectuera la liaison avec de nouveaux types et enregistrera le nouvel ExpressionTree dans Target .

Cache



Nous avons déjà découvert que Target est un cache L0 . Chaque fois que Target est appelé, la première chose que nous ferons est de passer par les restrictions qui y sont déjà stockées. Si les restrictions échouent et qu'une nouvelle liaison est générée, l'ancienne règle passe simultanément à L1 et L2 . À l'avenir, lorsque vous manquerez le cache L0 , les règles de L1 et L2 seront recherchées jusqu'à ce que celle qui convient soit trouvée.

  • L1 : Les dix dernières règles qui ont quitté L0 (stockées directement dans CallSite )
  • L2 : Les 128 dernières règles créées à l'aide d'une instance de classeur spécifique (qui est CallSiteBinder , unique à chaque CallSite )


Maintenant, nous pouvons enfin ajouter ces détails dans un seul ensemble et décrire sous la forme d'un algorithme ce qui se passe lorsque Foo.Bar (someDynamicObject) est appelé :

1. Un classeur est créé qui se souvient du contexte et de la méthode appelée au niveau de leurs signatures

2. La première fois que l'opération est appelée, ExpressionTree est créé, qui stocke:
2.1 Limitations . Dans ce cas, il s'agira de deux restrictions sur le type de paramètres de liaison actuels
2.2 Fonction objective : soit lever une exception (dans ce cas c'est impossible, car toute dynamique aboutira avec succès à l'objet) soit un appel à la méthode Bar

3. Compilez et exécutez le ExpressionTree résultant

4. Lorsque vous rappelez l'opération, deux options sont possibles:
4.1 Limitations fonctionnelles : appelez simplement Bar
4.2 Les limitations n'ont pas fonctionné : répétez l'étape 2 pour les nouveaux paramètres de liaison

Ainsi, avec l'exemple du type Géré , il est devenu à peu près clair comment la dynamique fonctionne de l'intérieur. Dans le cas décrit, nous ne manquerons jamais le cache, car les types sont toujours les mêmes *, donc Update sera appelé exactement une fois lorsque CallSite est initialisé. Ensuite, pour chaque appel, seules les restrictions seront vérifiées et la fonction objectif sera appelée immédiatement. Ceci est en excellent accord avec nos observations de mémoire: pas de calcul - pas de fuites.

* Pour cette raison, le compilateur génère ses CallSites pour chacun: la probabilité de manquer le cache L0 est extrêmement réduite

Il est temps de découvrir en quoi ce schéma diffère dans le cas des objets COM natifs . Jetons un coup d'œil à 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: } } 


On peut voir que la différence ne concerne que la deuxième restriction:

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


Si dans le cas du code managé , nous avions deux restrictions sur le type d'objets, alors nous voyons ici que la deuxième restriction vérifie l'équivalence des instances via WeakReference .

Remarque: La restriction d'instance en plus des objets COM est également utilisée pour TransparentProxy

En pratique, sur la base de notre connaissance du fonctionnement du cache, cela signifie que chaque fois que nous recréons un objet COM dans une boucle, nous manquerons le cache L0 (et L1 / L2 aussi, car les anciennes règles avec liens y seront stockées aux anciennes instances). La première hypothèse qui vous demande dans la tête est que le cache de règles coule. Mais le code y est assez simple et tout va bien: les anciennes règles sont supprimées correctement. Dans le même temps, l'utilisation de WeakReference dans ExpressionTree n'empêche pas le GC de collecter des objets inutiles.

Le mécanisme d'enregistrement des règles dans le 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; } 


Alors, quel est le problème? Essayons de clarifier l'hypothèse: une fuite de mémoire se produit quelque part lors de la liaison d'un objet COM .

Expériences, partie 2



Encore une fois, passons des conclusions spéculatives aux expériences. Tout d'abord, répétons ce que le compilateur fait pour nous:

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


Nous vérifions:



La fuite a été conservée. Juste. Mais quelle en est la raison? Après avoir étudié le code des classeurs (que nous laissons derrière les crochets), il est clair que la seule chose qui affecte le type de notre objet est l'option de restriction. Peut-être que ce n'est pas une question d'objets COM , mais un liant? Il n'y a pas beaucoup de choix, provoquons plusieurs liaisons pour le type Géré :

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




Ouah! Il semble que nous l'avons attrapé. Le problème n'est pas du tout avec l' objet COM , comme il nous a semblé au départ, juste en raison des limitations de l'instance, c'est le seul cas où la liaison se produit plusieurs fois à l'intérieur de notre boucle. Dans tous les autres cas, j'ai récupéré le cache L0 et effectué une liaison.

Conclusions



Fuite de mémoire



Si vous travaillez avec des variables dynamiques qui contiennent COM natif ou TransparentProxy , ne les passez jamais en tant que paramètres de méthode. Si vous devez toujours le faire, utilisez la conversion explicite en objet , puis le compilateur sera en retard sur vous.

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


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


Par mesure de précaution supplémentaire, essayez d'instancier ces objets aussi rarement que possible. Réel pour toutes les versions du .NET Framework . (Pour l'instant) n'est pas très pertinent pour. NET Core , car il n'y a pas de prise en charge pour les objets COM dynamiques .

Performances



Il est dans votre intérêt que les échecs de cache se produisent aussi rarement que possible, car dans ce cas, il n'est pas nécessaire de trouver une règle appropriée dans les caches de haut niveau. Des échecs dans le cache L0 se produiront principalement en cas de non-concordance du type de l'objet dynamique avec les restrictions préservées.

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


Cependant, dans la pratique, vous ne remarquerez probablement pas la différence de performances sauf si le nombre d'appels à cette fonction est mesuré en millions ou si la variabilité des types n'est pas inhabituellement grande. Les coûts en cas de manque sur le cache L0 sont tels, N est le nombre de types:

  • N <10. Si vous manquez, parcourez uniquement les règles de cache L1 existantes
  • 10 < N <128 . Énumération du cache L1 et L2 (maximum 10 et N itérations). Création et remplissage d'un tableau de 10 éléments
  • N > 128. Itérer sur le cache L1 et L2 . Créez et remplissez des tableaux de 10 et 128 éléments. Si vous manquez le cache L2, reliez à nouveau


Dans les deuxième et troisième cas, la charge sur le CPG augmentera.

Conclusion



Malheureusement, nous n'avons pas trouvé de véritable raison de la fuite de mémoire, cela nécessitera une étude séparée du liant. Heureusement, WinDbg fournit un indice pour une enquête plus approfondie: quelque chose de mauvais se produit dans le DLR . La première colonne est le nombre d'objets



Bonus



Pourquoi la conversion en objet empêche-t-elle explicitement une fuite?
N'importe quel type peut être converti en objet , de sorte que l'opération cesse d'être dynamique.

Pourquoi n'y a-t-il pas de fuites lors de l'utilisation des champs et des méthodes d'un objet COM?
Voici à quoi ressemble ExpressionTree pour l'accès aux champs:

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

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


All Articles