Partager via


Considérations relatives aux performances pour les technologies Run-Time dans le .NET Framework

 

Emmanuel Schanzer
Microsoft Corporation

Août 2001

Résumé: Cet article comprend une enquête sur les différentes technologies au travail dans le monde managé et une explication technique de leur impact sur les performances. Découvrez le fonctionnement du garbage collection, du JIT, de la communication à distance, de ValueTypes, de la sécurité et bien plus encore. (27 pages imprimées)

Contenu

Vue d’ensemble
Garbage collection
Pool de threads
The JIT
AppDomains
Sécurité
Communication à distance
ValueTypes
Ressources supplémentaires
Annexe : Hébergement de l’exécution du serveur

Vue d’ensemble

L’exécution de .NET introduit plusieurs technologies avancées destinées à la sécurité, à la facilité de développement et aux performances. En tant que développeur, il est important de comprendre chacune des technologies et de les utiliser efficacement dans votre code. Les outils avancés fournis par l’exécution facilitent la création d’une application robuste, mais rendre cette application rapide est (et a toujours été) la responsabilité du développeur.

Ce livre blanc doit vous fournir une compréhension plus approfondie des technologies à l’œuvre dans .NET et vous aider à ajuster votre code pour plus de rapidité. Remarque : il ne s’agit pas d’une feuille de spécification. Il y a déjà beaucoup d’informations techniques solides là-bas. L’objectif ici est de fournir les informations avec une forte inclinaison vers les performances, et peut ne pas répondre à toutes les questions techniques que vous avez. Je vous recommande d’aller plus loin dans MSDN Online Library si vous ne trouvez pas les réponses que vous recherchez ici.

Je vais aborder les technologies suivantes, en fournissant une vue d’ensemble générale de leur objectif et de la raison pour laquelle elles affectent les performances. Ensuite, je vais examiner quelques détails d’implémentation de niveau inférieur et utiliser un exemple de code pour illustrer les façons de tirer le meilleur parti de chaque technologie.

Garbage collection

Concepts de base

Le garbage collection (GC) libère le programmeur des erreurs courantes et difficiles à déboguer en libérant de la mémoire pour les objets qui ne sont plus utilisés. Le chemin d’accès général suivi pour la durée de vie d’un objet est le suivant, tant dans le code managé que dans le code natif :

Foo a = new Foo();      // Allocate memory for the object and initialize
...a...                  // Use the object   
delete a;               // Tear down the state of the object, clean up
                        // and free the memory for that object

Dans le code natif, vous devez effectuer toutes ces opérations vous-même. L’absence des phases d’allocation ou de nettoyage peut entraîner un comportement totalement imprévisible et difficile à déboguer, et l’oubli de libérer des objets peut entraîner des fuites de mémoire. Le chemin d’allocation de mémoire dans le Common Language Runtime (CLR) est très proche du chemin que nous venons de couvrir. Si nous ajoutons les informations spécifiques au GC, nous nous trouvons avec quelque chose qui ressemble beaucoup.

Foo a = new Foo();      // Allocate memory for the object and initialize
...a...                  // Use the object (it is strongly reachable)
a = null;               // A becomes unreachable (out of scope, nulled, etc)
                        // Eventually a collection occurs, and a's resources
                        // are torn down and the memory is freed

Jusqu’à ce que l’objet puisse être libéré, les mêmes étapes sont effectuées dans les deux mondes. Dans le code natif, vous devez vous rappeler de libérer l’objet lorsque vous en avez terminé. Dans le code managé, une fois que l’objet n’est plus accessible, le GC peut le collecter. Bien sûr, si votre ressource nécessite une attention particulière pour être libérée (par exemple, la fermeture d’un socket), le GC peut avoir besoin d’aide pour la fermer correctement. Le code que vous avez écrit précédemment pour propre une ressource avant de la libérer s’applique toujours, sous la forme des méthodes Dispose() et Finalize(). Je parlerai des différences entre ces deux-là plus tard.

Si vous conservez un pointeur vers une ressource, le gc n’a aucun moyen de savoir si vous envisagez de l’utiliser à l’avenir. Cela signifie que toutes les règles que vous avez utilisées dans le code natif pour libérer explicitement des objets s’appliquent toujours, mais la plupart du temps, le gc gère tout pour vous. Au lieu de vous soucier de la gestion de la mémoire cent pour cent du temps, vous n’avez à vous en soucier que cinq pour cent du temps.

Le récupérateur de mémoire CLR est un collecteur générationnel, marqué et compact. Il suit plusieurs principes qui lui permettent d’atteindre d’excellentes performances. Tout d’abord, il y a l’idée que les objets de courte durée ont tendance à être plus petits et sont souvent accessibles. Le GC divise le graphique d’allocation en plusieurs sous-graphiques, appelés générations, qui lui permettent de passer le moins de temps possible à collecter*.* Gen 0 contient de jeunes objets fréquemment utilisés. Cela a également tendance à être le plus petit, et prend environ 10 millisecondes à collecter. Étant donné que le GC peut ignorer les autres générations au cours de cette collection, il offre des performances beaucoup plus élevées. G1 et G2 sont destinés aux objets plus grands et plus anciens et sont collectés moins fréquemment. Lorsqu’une collection G1 se produit, G0 est également collecté. Une collection G2 est une collection complète et est la seule fois où le GC traverse l’intégralité du graphique. Il utilise également intelligemment les caches du processeur, qui peuvent régler le sous-système de mémoire pour le processeur spécifique sur lequel il s’exécute. Il s’agit d’une optimisation qui n’est pas facilement disponible dans l’allocation native et qui peut aider votre application à améliorer les performances.

Quand une collection se produit-elle ?

Lorsqu’une allocation de temps est effectuée, le gc vérifie si une collection est nécessaire. Il examine la taille de la collection, la quantité de mémoire restante et les tailles de chaque génération, puis utilise une heuristique pour prendre la décision. Jusqu’à ce qu’une collection se produise, la vitesse d’allocation d’objets est généralement aussi rapide (ou plus rapide) que C ou C++.

Que se passe-t-il lorsqu’une collection se produit ?

Passons en revue les étapes effectuées par un récupérateur de mémoire lors d’un regroupement. Le GC conserve une liste de racines, qui pointent vers le tas GC. Si un objet est actif, il existe une racine à son emplacement dans le tas. Les objets du tas peuvent également pointer les uns vers les autres. Ce graphique de pointeurs est ce que le GC doit rechercher pour libérer de l’espace. L’ordre des événements est le suivant :

  1. Le tas managé conserve tout son espace d’allocation dans un bloc contigu, et lorsque ce bloc est inférieur à la quantité demandée, le gc est appelé.

  2. Le GC suit chaque racine et tous les pointeurs qui suivent, en conservant une liste des objets qui ne sont pas accessibles.

  3. Chaque objet non accessible à partir d’une racine est considéré comme collectable et est marqué pour la collection.

    Figure 1. Before Collection : Notez que tous les blocs ne sont pas accessibles à partir de racines !

  4. La suppression d’objets du graphique d’accessibilité rend la plupart des objets collectables. Toutefois, certaines ressources doivent être gérées spécialement. Lorsque vous définissez un objet, vous avez la possibilité d’écrire une méthode Dispose() ou une méthode Finalize() (ou les deux). Je vais parler des différences entre les deux, et quand les utiliser plus tard.

  5. La dernière étape d’une collection est la phase de compactage. Tous les objets utilisés sont déplacés dans un bloc contigu, et tous les pointeurs et racines sont mis à jour.

  6. En compactant les objets en direct et en mettant à jour l’adresse de début de l’espace libre, le GC maintient que tout l’espace libre est contigu. S’il y a suffisamment d’espace pour allouer l’objet, le gc retourne le contrôle au programme. Si ce n’est pas le cas, elle déclenche un OutOfMemoryException.

    Figure 2 : Après la collection : les blocs accessibles ont été compactés. Plus d’espace libre!

Pour plus d’informations techniques sur la gestion de la mémoire, consultez le chapitre 3 de programmation d’applications pour Microsoft Windows par Jeffrey Richter (Microsoft Press, 1999).

Nettoyage d’objet

Certains objets nécessitent une gestion spéciale avant que leurs ressources puissent être retournées. Voici quelques exemples de ces ressources : fichiers, sockets réseau ou connexions aux bases de données. Libérer simplement la mémoire sur le tas ne sera pas suffisant, car vous souhaitez que ces ressources soient fermées normalement. Pour effectuer un nettoyage d’objet, vous pouvez écrire une méthode Dispose(), une méthode Finalize() ou les deux.

Une méthode Finalize() :

  • Est appelé par le gc
  • Il n’est pas garanti d’être appelé dans n’importe quel ordre, ou à un moment prévisible
  • Après avoir été appelé, libère de la mémoire après le gc suivant
  • Maintient tous les objets enfants actifs jusqu’au prochain GC

Une méthode Dispose() :

  • Est appelé par le programmeur
  • Est ordonné et planifié par le programmeur
  • Retourne des ressources à l’achèvement de la méthode

Les objets managés qui contiennent uniquement des ressources managées ne nécessitent pas ces méthodes. Votre programme n’utilisera probablement que quelques ressources complexes, et il est probable que vous sachiez ce qu’elles sont et quand vous en avez besoin. Si vous connaissez ces deux choses, il n’y a aucune raison de s’appuyer sur les finaliseurs, car vous pouvez effectuer le nettoyage manuellement. Il existe plusieurs raisons pour lesquelles vous souhaitez le faire, et elles ont toutes à voir avec la file d’attente du finaliseur.

Dans le GC, lorsqu’un objet qui a un finaliseur est marqué comme pouvant être collecté, lui et tous les objets vers 2000 sont placés dans une file d’attente spéciale. Un thread distinct guide cette file d’attente en appelant la méthode Finalize() de chaque élément de la file d’attente. Le programmeur n’a aucun contrôle sur ce thread ni sur l’ordre des éléments placés dans la file d’attente. Le GC peut retourner le contrôle au programme, sans avoir finalisé les objets de la file d’attente. Ces objets peuvent rester en mémoire, caché dans la file d’attente pendant une longue période. Les appels à finaliser sont effectués automatiquement et l’appel lui-même n’a aucun impact direct sur les performances. Toutefois, le modèle non déterministe de finalisation peut certainement avoir d’autres conséquences indirectes :

  • Dans un scénario où vous avez des ressources qui doivent être publiées à un moment spécifique, vous perdez le contrôle avec les finaliseurs. Supposons que vous avez un fichier ouvert et qu’il doit être fermé pour des raisons de sécurité. Même lorsque vous définissez l’objet sur null et que vous forcez immédiatement un gc, le fichier reste ouvert jusqu’à ce que sa méthode Finalize() soit appelée, et vous ne savez pas quand cela peut se produire.
  • N objets qui nécessitent une élimination dans un certain ordre peuvent ne pas être gérés correctement.
  • Un objet énorme et ses enfants peuvent prendre beaucoup trop de mémoire, nécessiter des collections supplémentaires et nuire aux performances. Ces objets peuvent ne pas être collectés pendant une longue période.
  • Un petit objet à finaliser peut avoir des pointeurs vers des ressources volumineuses qui peuvent être libérées à tout moment. Ces objets ne seront pas libérés tant que l’objet à finaliser n’est pas pris en charge, ce qui crée une pression mémoire inutile et force des collections fréquentes.

Le diagramme d’état de la figure 3 illustre les différents chemins que votre objet peut prendre en termes de finalisation ou de suppression.

Figure 3. Chemins d’élimination et de finalisation qu’un objet peut prendre

Comme vous pouvez le voir, la finalisation ajoute plusieurs étapes à la durée de vie de l’objet. Si vous disposez vous-même d’un objet, l’objet peut être collecté et la mémoire vous est retournée dans le gc suivant. Lorsque la finalisation doit se produire, vous devez attendre que la méthode réelle soit appelée. Étant donné que vous n’avez aucune garantie sur le moment où cela se produit, vous pouvez avoir beaucoup de mémoire attachée et être à la merci de la file d’attente de finalisation. Cela peut être extrêmement problématique si votre objet est connecté à une arborescence entière d’objets et qu’ils restent tous en mémoire jusqu’à ce que la finalisation se produise.

Choix du récupérateur de mémoire à utiliser

Le CLR a deux contrôleurs de groupe différents : Station de travail (mscorwks.dll) et Serveur (mscorsvr.dll). Lors de l’exécution en mode Station de travail, la latence est plus un problème que l’espace ou l’efficacité. Un serveur avec plusieurs processeurs et clients connectés sur un réseau peut se permettre une certaine latence, mais le débit est désormais une priorité absolue. Plutôt que de transformer ces deux scénarios dans un schéma gc unique, Microsoft a inclus deux récupérateurs de mémoire adaptés à chaque situation.

GC serveur :

  • Multiprocesseur (MP) Scalable, Parallel
  • Un thread GC par processeur
  • Programme suspendu pendant le marquage

GC de station de travail :

  • Réduit les pauses en s’exécutant simultanément pendant les regroupements complets

Le gc du serveur est conçu pour un débit maximal et est mis à l’échelle avec des performances très élevées. La fragmentation de la mémoire sur les serveurs est un problème beaucoup plus grave que sur les stations de travail, ce qui fait du garbage collection une proposition intéressante. Dans un scénario de monoprocesseur, les deux collecteurs fonctionnent de la même façon : mode station de travail, sans regroupement simultané. Sur un ordinateur MP, le GC de station de travail utilise le deuxième processeur pour exécuter la collection simultanément, ce qui réduit les retards tout en diminuant le débit. Le gc de serveur utilise plusieurs segments de mémoire et threads de collection pour optimiser le débit et améliorer la mise à l’échelle.

Vous pouvez choisir le gc à utiliser lorsque vous hébergez l’heure d’exécution. Lorsque vous chargez l’exécution dans un processus, vous spécifiez le collecteur à utiliser. Le chargement de l’API est décrit dans le Guide du développeur .NET Framework. Pour obtenir un exemple de programme simple qui héberge l’exécution et sélectionne le gc du serveur, consultez l’Annexe.

Mythe : Le garbage collection est toujours plus lent que de le faire à la main

En fait, jusqu’à ce qu’une collection soit appelée, le GC est beaucoup plus rapide que de le faire manuellement en C. Cela surprend beaucoup de gens, donc ça vaut la peine d’être expliqué. Tout d’abord, notez que la recherche d’espace libre se produit en temps constant. Étant donné que tout l’espace libre est contigu, le GC suit simplement le pointeur et vérifie s’il y a suffisamment d’espace. En C, un appel à malloc() entraîne généralement une recherche dans une liste liée de blocs libres. Cela peut prendre du temps, surtout si votre tas est mal fragmenté. Pour aggraver les choses, plusieurs implémentations de l’exécution C verrouillent le tas pendant cette procédure. Une fois la mémoire allouée ou utilisée, la liste doit être mise à jour. Dans un environnement de nettoyage de la mémoire, l’allocation est gratuite et la mémoire est libérée pendant la collecte. Les programmeurs plus avancés réservent de gros blocs de mémoire et gèrent eux-mêmes l’allocation au sein de ce bloc. Le problème avec cette approche est que la fragmentation de la mémoire devient un problème énorme pour les programmeurs, et qu’elle les oblige à ajouter beaucoup de logique de gestion de la mémoire à leurs applications. En fin de compte, un récupérateur de mémoire n’ajoute pas beaucoup de surcharge. L’allocation est aussi rapide ou plus rapide, et le compactage est géré automatiquement, ce qui permet aux programmeurs de se concentrer sur leurs applications.

À l’avenir, les récupérateurs de mémoire pourraient effectuer d’autres optimisations qui le rendent encore plus rapide. L’identification des zones réactives et une meilleure utilisation du cache sont possibles et peuvent faire d’énormes différences de vitesse. Un GC plus intelligent peut empaquetage des pages plus efficacement, réduisant ainsi le nombre d’extractions de pages qui se produisent pendant l’exécution. Tous ces éléments peuvent rendre un environnement de nettoyage des déchets plus rapide que de faire les choses à la main.

Certaines personnes peuvent se demander pourquoi GC n’est pas disponible dans d’autres environnements, comme C ou C++. La réponse est de type. Ces langages permettent le cast de pointeurs vers n’importe quel type, ce qui rend extrêmement difficile de savoir à quoi fait référence un pointeur. Dans un environnement managé comme le CLR, nous pouvons garantir suffisamment de pointeurs pour rendre gc possible. Le monde managé est également le seul endroit où nous pouvons arrêter en toute sécurité l’exécution de threads pour effectuer un GC : en C++, ces opérations sont soit dangereuses, soit très limitées.

Réglage de la vitesse

La plus grande préoccupation pour un programme dans le monde managé est la conservation de la mémoire. Certains des problèmes que vous trouverez dans les environnements non managés ne sont pas un problème dans le monde managé : les fuites de mémoire et les pointeurs non résolus ne sont pas vraiment un problème ici. Au lieu de cela, les programmeurs doivent faire attention à laisser les ressources connectées lorsqu’ils n’en ont plus besoin.

L’heuristique la plus importante pour les performances est également la plus facile à apprendre pour les programmeurs habitués à écrire du code natif : effectuez le suivi des allocations à effectuer et libérez-les lorsque vous avez terminé. Le GC n’a aucun moyen de savoir que vous n’allez pas utiliser une chaîne de 20 Ko que vous avez créée si elle fait partie d’un objet qui est conservé. Supposons que vous ayez cet objet caché dans un vecteur quelque part et que vous n’avez jamais l’intention de réutiliser cette chaîne. La définition du champ sur null permet au GC de collecter ces 20 Ko plus tard, même si vous avez toujours besoin de l’objet à d’autres fins. Si vous n’avez plus besoin de l’objet, vérifiez que vous ne conservez pas de références à celui-ci. (Comme dans le code natif.) Pour les objets plus petits, il s’agit moins d’un problème. Tout programmeur familiarisé avec la gestion de la mémoire dans le code natif n’aura aucun problème ici : les mêmes règles de bon sens s’appliquent. Vous n’avez pas besoin d’être si paranoïaque à leur sujet.

La deuxième préoccupation importante en matière de performances concerne le nettoyage des objets. Comme je l’ai mentionné précédemment, la finalisation a de profondes répercussions sur le rendement. L’exemple le plus courant est celui d’un gestionnaire managé sur une ressource non managée : vous devez implémenter un type de méthode de nettoyage, et c’est là que les performances deviennent un problème. Si vous dépendez de la finalisation, vous vous ouvrez aux problèmes de performances que j’ai répertoriés précédemment. Une autre chose à garder à l’esprit est que le GC n’est en grande partie pas conscient de la pression de la mémoire dans le monde natif. Vous pouvez donc utiliser une tonne de ressources non managées simplement en gardant un pointeur autour dans le tas managé. Un pointeur unique n’occupe pas beaucoup de mémoire, ce qui peut prendre un certain temps avant qu’une collection ne soit nécessaire. Pour contourner ces problèmes de performances, tout en étant sûr quand il s’agit de la conservation de la mémoire, vous devez choisir un modèle de conception à utiliser pour tous les objets qui nécessitent un nettoyage spécial.

Le programmeur dispose de quatre options pour gérer le nettoyage des objets :

  1. Implémenter les deux

    Il s’agit de la conception recommandée pour le nettoyage d’objets. Il s’agit d’un objet avec une certaine combinaison de ressources managées et non managées. Par exemple, System.Windows.Forms.Control. Il s’agit d’une ressource non managée (HWND) et de ressources potentiellement managées (DataConnection, etc.). Si vous ne savez pas quand vous utilisez des ressources non managées, vous pouvez ouvrir le manifeste de votre programme dans ILDASM`` et case activée pour les références aux bibliothèques natives. Une autre alternative consiste à utiliser vadump.exe pour voir quelles ressources sont chargées avec votre programme. Ces deux éléments peuvent vous fournir des informations sur le type de ressources natives que vous utilisez.

    Le modèle ci-dessous offre aux utilisateurs une méthode recommandée unique au lieu de remplacer la logique de nettoyage (remplacer Dispose(bool)). Cela offre une flexibilité maximale, ainsi qu’un fourre-tout au cas où Dispose() n’est jamais appelé. La combinaison de vitesse maximale et de flexibilité, ainsi que l’approche de filet de sécurité en font la meilleure conception à utiliser.

    Exemple :

    public class MyClass : IDisposable {
      public void Dispose() {
        Dispose(true);
        GC.SuppressFinalizer(this);
      }
      protected virtual void Dispose(bool disposing) {
        if (disposing) {
          ...
        }
          ...
      }
      ~MyClass() {
        Dispose(false);
      }
    }
    
  2. Implémenter Dispose() uniquement

    Il s’agit d’un objet qui n’a que des ressources managées et que vous souhaitez vous assurer que son nettoyage est déterministe. System.Web.UI.Control en est un exemple.

    Exemple :

    public class MyClass : IDisposable {
      public virtual void Dispose() {
        ...
      }
    
  3. Implémenter Finalize() uniquement

    Cela est nécessaire dans des situations extrêmement rares, et je recommande vivement de ne pas le faire. L’implication d’un objet Finalize() uniquement est que le programmeur n’a aucune idée du moment où l’objet va être collecté, mais utilise une ressource suffisamment complexe pour nécessiter un nettoyage spécial. Cette situation ne devrait jamais se produire dans un projet bien conçu, et si vous vous y trouvez, vous devriez revenir en arrière et découvrir ce qui s’est passé.

    Exemple :

    public class MyClass {
      ...
      ~MyClass() {
        ...
      }
    
  4. Implémenter ni l’un ni l'

    Il s’agit d’un objet managé qui pointe uniquement vers d’autres objets managés qui ne sont pas jetables ni à finaliser.

Recommandation

Les recommandations relatives à la gestion de la mémoire doivent être familières : libérer des objets lorsque vous en avez terminé, et garder un œil sur la non-utilisation des pointeurs vers les objets. En ce qui concerne le nettoyage d’objets, implémentez une méthode Finalize() et Dispose() pour les objets avec des ressources non managées. Cela permettra d’éviter un comportement inattendu ultérieurement et d’appliquer les bonnes pratiques de programmation

L’inconvénient ici est que vous forcez les gens à appeler Dispose(). Il n’y a pas de perte de performances ici, mais certaines personnes peuvent trouver frustrant d’avoir à penser à se débarrasser de leurs objets. Cependant, je pense qu’il est intéressant d’utiliser un modèle qui a du sens. En outre, cela oblige les gens à être plus attentifs aux objets qu’ils allouent, car ils ne peuvent pas faire confiance aveuglément au GC pour toujours s’en occuper. Pour les programmeurs qui proviennent d’un arrière-plan C ou C++, forcer un appel à Dispose() sera probablement bénéfique, car c’est le genre de chose avec lequel ils sont plus familiers.

Dispose() doit être pris en charge sur les objets qui conservent des ressources non managées n’importe où dans l’arborescence d’objets sous celle-ci ; Toutefois, Finalize() doit uniquement être placé sur les objets qui conservent spécifiquement ces ressources, tels qu’un handle de système d’exploitation ou une allocation de mémoire non managée. Je suggère de créer de petits objets managés en tant que « wrappers » pour implémenter Finalize() en plus de prendre en charge Dispose(), qui serait appelé par dispose () de l’objet parent. Étant donné que les objets parents n’ont pas de finaliseur, l’arborescence entière des objets ne survivra pas à une collection, que Dispose() ait été appelée ou non.

Une bonne règle de base pour les finaliseurs consiste à les utiliser uniquement sur l’objet le plus primitif qui nécessite une finalisation. Supposons que j’ai une ressource managée volumineuse qui inclut une connexion de base de données : je rends possible la finalisation de la connexion elle-même, mais je rends le reste de l’objet jetable. De cette façon, je peux appeler Dispose() et libérer immédiatement les parties gérées de l’objet, sans avoir à attendre que la connexion soit finalisée. N’oubliez pas : utilisez Finalize() uniquement là où vous le devez, quand vous le devez.

Note Programmeurs C et C++ : la sémantique destructeur en C# crée un finaliseur, et non une méthode de destruction !

Pool de threads

Concepts de base

Le pool de threads du CLR est similaire au pool de threads NT à bien des égards et ne nécessite presque aucune nouvelle compréhension de la part du programmeur. Il a un thread d’attente, qui peut gérer les blocs pour d’autres threads et les avertir quand ils doivent revenir, ce qui les libère d’effectuer d’autres tâches. Il peut générer de nouveaux threads et en bloquer d’autres pour optimiser l’utilisation du processeur au moment de l’exécution, garantissant ainsi la plus grande quantité de travail utile. Il recycle également les threads lorsqu’ils sont terminés, les remarrant sans la surcharge de tuer et d’en générer de nouveaux. Il s’agit d’une amélioration substantielle des performances par rapport à la gestion manuelle des threads, mais ce n’est pas un fourre-tout. Il est essentiel de savoir quand utiliser le pool de threads lors du réglage d’une application threaded.

Ce que vous savez du pool de threads NT :

  • Le pool de threads gère la création et le nettoyage des threads.
  • Il fournit un port d’achèvement pour les threads d’E/S (plateformes NT uniquement).
  • Le rappel peut être lié à des fichiers ou à d’autres ressources système.
  • Les API de minuteur et d’attente sont disponibles.
  • Le pool de threads détermine le nombre de threads à activer en utilisant des heuristiques telles que le délai depuis la dernière injection, le nombre de threads actuels et la taille de la file d’attente.
  • Flux de threads à partir d’une file d’attente partagée.

Différences dans .NET :

  • Il est conscient du blocage des threads dans le code managé (par exemple, en raison du garbage collection, de l’attente managée) et peut ajuster sa logique d’injection de threads en conséquence.
  • Il n’existe aucune garantie de service pour les threads individuels.

Quand gérer les threads vous-même

L’utilisation efficace du pool de threads est étroitement liée à savoir ce dont vous avez besoin de vos threads. Si vous avez besoin d’une garantie de service, vous devez la gérer vous-même. Dans la plupart des cas, l’utilisation du pool vous offre des performances optimales. Si vous avez des restrictions strictes et que vous avez besoin d’un contrôle étroit de vos threads, il est probablement plus judicieux d’utiliser des threads natifs de toute façon, alors méfiez-vous de la gestion des threads managés vous-même. Si vous décidez d’écrire du code managé et de gérer le thread par vous-même, veillez à ne pas générer de threads par connexion : cela ne fera que nuire aux performances. En règle générale, vous devez choisir de gérer les threads vous-même uniquement dans le monde managé dans des scénarios très spécifiques où il existe une tâche volumineuse et fastidieuse qui est rarement effectuée. Un exemple peut être le remplissage d’un cache volumineux en arrière-plan ou l’écriture d’un fichier volumineux sur le disque.

Réglage de la vitesse

Le pool de threads définit une limite sur le nombre de threads qui doivent être actifs, et si un grand nombre d’entre eux bloquent, le pool sera affamé. Dans l’idéal, vous devez utiliser le pool de threads pour les threads de courte durée et non bloquants. Dans les applications serveur, vous souhaitez répondre à chaque requête rapidement et efficacement. Si vous créez un nouveau thread pour chaque requête, vous faites face à une surcharge importante. La solution consiste à recycler vos threads, en prenant soin de propre et de retourner l’état de chaque thread à l’achèvement. Ce sont les scénarios où le pool de threads est une victoire majeure en matière de performances et de conception, et où vous devriez faire un bon usage de la technologie. Le pool de threads gère le nettoyage d’état à votre place et s’assure que le nombre optimal de threads est utilisé à un moment donné. Dans d’autres situations, il peut être plus judicieux de gérer le thread par vous-même.

Bien que le CLR puisse utiliser la sécurité de type pour apporter des garanties sur les processus afin de s’assurer que les AppDomains peuvent partager le même processus, aucune garantie de ce type n’existe avec les threads. Le programmeur est responsable de l’écriture de threads bien comportementés, et toutes vos connaissances du code natif s’appliquent toujours.

Vous trouverez ci-dessous un exemple d’application simple qui tire parti du pool de threads. Il crée un ensemble de threads de travail, puis leur fait effectuer une tâche simple avant de les fermer. J’ai effectué une vérification des erreurs, mais il s’agit du même code que celui qui se trouve dans le dossier du Kit de développement logiciel (SDK) Framework sous « Samples\Threading\Threadpool ». Dans cet exemple, nous avons du code qui crée un élément de travail simple et utilise le pool de threads pour que plusieurs threads gèrent ces éléments sans que le programmeur ait à les gérer. Pour plus d’informations, consultez le fichier ReadMe.html.

using System;
using System.Threading;

public class SomeState{
  public int Cookie;
  public SomeState(int iCookie){
    Cookie = iCookie;
  }
};


public class Alpha{
  public int [] HashCount;
  public ManualResetEvent eventX;
  public static int iCount = 0;
  public static int iMaxCount = 0;
  public Alpha(int MaxCount) {
    HashCount = new int[30];
    iMaxCount = MaxCount;
  }


   //   The method that will be called when the Work Item is serviced
   //   on the Thread Pool
   public void Beta(Object state){
     Console.WriteLine(" {0} {1} :", 
               Thread.CurrentThread.GetHashCode(), ((SomeState)state).Cookie);
     Interlocked.Increment(ref HashCount[Thread.CurrentThread.GetHashCode()]);

     //   Do some busy work
     int iX = 10000;
     while (iX > 0){ iX--;}
     if (Interlocked.Increment(ref iCount) == iMaxCount) {
       Console.WriteLine("Setting EventX ");
       eventX.Set();
     }
  }
};

public class SimplePool{
  public static int Main(String[] args)   {
    Console.WriteLine("Thread Simple Thread Pool Sample");
    int MaxCount = 1000;
    ManualResetEvent eventX = new ManualResetEvent(false);
    Console.WriteLine("Queuing {0} items to Thread Pool", MaxCount);
    Alpha oAlpha = new Alpha(MaxCount);
    oAlpha.eventX = eventX;
    Console.WriteLine("Queue to Thread Pool 0");
    ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),new SomeState(0));
       for (int iItem=1;iItem < MaxCount;iItem++){
         Console.WriteLine("Queue to Thread Pool {0}", iItem);
         ThreadPool.QueueUserWorkItem(new WaitCallback(oAlpha.Beta),
                                   new SomeState(iItem));
       }
    Console.WriteLine("Waiting for Thread Pool to drain");
    eventX.WaitOne(Timeout.Infinite,true);
    Console.WriteLine("Thread Pool has been drained (Event fired)");
    Console.WriteLine("Load across threads");
    for(int iIndex=0;iIndex<oAlpha.HashCount.Length;iIndex++)
      Console.WriteLine("{0} {1}", iIndex, oAlpha.HashCount[iIndex]);
    }
    return 0;
  }
}

The JIT

Concepts de base

Comme avec n’importe quelle machine virtuelle, le CLR a besoin d’un moyen de compiler le langage intermédiaire en code natif. Lorsque vous compilez un programme à exécuter dans le CLR, votre compilateur extrait votre source d’un langage de haut niveau vers une combinaison de MSIL (Microsoft Intermediate Language) et de métadonnées. Ceux-ci sont fusionnés dans un fichier PE, qui peut ensuite être exécuté sur n’importe quel ordinateur compatible CLR. Lorsque vous exécutez cet exécutable, le JIT commence à compiler l’il en code natif et à exécuter ce code sur l’ordinateur réel. Cette opération est effectuée par méthode, de sorte que le délai pour JITing est seulement aussi long que nécessaire pour le code que vous souhaitez exécuter.

L’accès JIT est assez rapide et génère un très bon code. Certaines des optimisations qu’il effectue (et certaines explications de chacune) sont décrites ci-dessous. Gardez à l’esprit que la plupart de ces optimisations ont des limites imposées pour s’assurer que le JIT ne passe pas trop de temps.

  • Pliage constant : calculez les valeurs constantes au moment de la compilation.

    Avant Après
    x = 5 + 7 x = 12
  • Constante et Propagation de copie : remplacez les variables libres précédemment par les variables libres.

    Avant Après
    x = a x = a
    y = x y = a
    z = 3 + y z = 3 + a
  • Inlining de méthode : remplacez les arguments par les valeurs passées au moment de l’appel et éliminez l’appel. De nombreuses autres optimisations peuvent ensuite être effectuées pour supprimer du code mort. Pour des raisons de vitesse, le JIT actuel a plusieurs limites sur ce qu’il peut inliner. Par exemple, seules les petites méthodes sont incluses (taille d’il inférieure à 32) et l’analyse du contrôle de flux est assez primitive.

    Avant Après
    ...

    x=foo(4, true);

    ...

    }

    foo(int a, bool b){

    if(b){

    return a + 5;

    } else {

    return 2a + bar();

    }

    ...

    x = 9

    ...

    }

    foo(int a, bool b){

    if(b){

    return a + 5;

    } else {

    return 2a + bar();

    }

  • Code Hoisting et Dominators : supprimez le code des boucles internes s’il est dupliqué à l’extérieur. L’exemple « avant » ci-dessous est en fait ce qui est généré au niveau de l’il, car tous les index tableaux doivent être vérifiés.

    Avant Après
    for(i=0; i< a.length;i++){

    if(i < a.length()){

    a[i] = null

    } else {

    raise IndexOutOfBounds;

    }

    }

    for(int i=0; i<a.length; i++){

    a[i] = null;

    }

  • Unrolling de boucle : la surcharge liée à l’incrémentation des compteurs et à l’exécution du test peut être supprimée et le code de la boucle peut être répété. Pour les boucles extrêmement serrées, cela se traduit par une victoire des performances.

    Avant Après
    for(i=0; i< 3; i++){

    print("flaming monkeys!");

    }

    print("flaming monkeys!");

    print("flaming monkeys!");

    print("flaming monkeys!");

  • Élimination des sous-expressions courantes : si une variable dynamique contient toujours les informations en cours de calcul, utilisez-la à la place.

    Avant Après
    x = 4 + y

    z = 4 + y

    x = 4 + y

    z = x

  • Enregistration : il n’est pas utile de donner un exemple de code ici. Une explication devra donc suffire. Cette optimisation peut passer du temps à examiner la façon dont les locaux et les temps sont utilisés dans une fonction, et essayer de gérer l’affectation d’inscription aussi efficacement que possible. Il peut s’agir d’une optimisation extrêmement coûteuse, et le JIT CLR actuel ne prend en compte qu’un maximum de 64 variables locales pour l’enregistrement. Les variables qui ne sont pas prises en compte sont placées dans le cadre de pile. Il s’agit d’un exemple classique des limitations de JITing : bien que ce soit très bien 99 % du temps, les fonctions très inhabituelles qui ont plus de 100 locaux seront mieux optimisées à l’aide d’une pré-compilation traditionnelle et fastidieuse.

  • Misc : d’autres optimisations simples sont effectuées, mais la liste ci-dessus est un bon exemple. Le JIT effectue également des passes pour le code mort et d’autres optimisations de peephole.

Quand le code obtient-il JITed ?

Voici le chemin d’accès que votre code passe lorsqu’il est exécuté :

  1. Votre programme est chargé et une table de fonctions est initialisée avec des pointeurs référençant l’il.
  2. La méthode Main est JITed en code natif, qui est ensuite exécuté. Les appels aux fonctions sont compilés en appels de fonction indirects via la table.
  3. Lorsqu’une autre méthode est appelée, l’exécution examine la table pour voir si elle pointe dans le code JITed.
    1. S’il l’a (peut-être a été appelé à partir d’un autre site d’appel, ou a été précompilé), le flux de contrôle continue.
    2. Si ce n’est pas le cas, la méthode est JITed et la table est mise à jour.
  4. Comme elles sont appelées, de plus en plus de méthodes sont compilées dans du code natif, et plus d’entrées dans le tableau pointent dans le pool croissant d’instructions x86.
  5. À mesure que le programme s’exécute, le JIT est appelé de moins en moins souvent jusqu’à ce que tout soit compilé.
  6. Une méthode n’est pas JITed tant qu’elle n’est pas appelée, puis elle n’est plus jamais JITed pendant l’exécution du programme. Vous payez uniquement pour ce que vous utilisez.

Mythe : Les programmes JITed s’exécutent plus lentement que les programmes précompilés

Or, ce n’est que rarement le cas. La surcharge associée à JITing de quelques méthodes est mineure par rapport au temps passé à lire en quelques pages à partir du disque, et les méthodes sont JITed uniquement si elles sont nécessaires. Le temps passé dans le JIT est si mineur qu’il n’est presque jamais perceptible, et une fois qu’une méthode a été JITed, vous n’encourez plus jamais le coût pour cette méthode. J’en parlerai plus dans la section Code de précompilation.

Comme mentionné ci-dessus, le JIT version1 (v1) effectue la plupart des optimisations qu’un compilateur effectue, et n’obtiendra plus rapidement que dans la version suivante (vNext), à mesure que des optimisations plus avancées sont ajoutées. Plus important encore, le JIT peut effectuer certaines optimisations qu’un compilateur normal ne peut pas, comme des optimisations spécifiques au processeur et le réglage du cache.

Optimisations JIT-Only

Étant donné que le JIT est activé au moment de l’exécution, un compilateur n’a pas connaissance de nombreuses informations. Cela lui permet d’effectuer plusieurs optimisations qui ne sont disponibles qu’au moment de l’exécution :

  • Optimisations spécifiques au processeur : au moment de l’exécution, le JIT sait s’il peut ou non utiliser les instructions SSE ou 3DNow. Votre exécutable sera compilé spécialement pour P4, Athlon ou toute future famille de processeurs. Vous déployez une fois, et le même code s’améliorera avec le JIT et l’ordinateur de l’utilisateur.
  • Optimisation des niveaux d’indirection éloignés, car la fonction et l’emplacement de l’objet sont disponibles au moment de l’exécution.
  • Le JIT peut effectuer des optimisations entre les assemblys, ce qui offre de nombreux avantages lors de la compilation d’un programme avec des bibliothèques statiques, tout en conservant la flexibilité et l’encombrement réduit de l’utilisation de bibliothèques dynamiques.
  • Fonctions inline de manière agressive qui sont appelées plus souvent, car elle est consciente du flux de contrôle pendant l’exécution. Les optimisations peuvent fournir une augmentation de vitesse substantielle, et il y a beaucoup de place pour des améliorations supplémentaires dans vNext.

Ces améliorations du temps d’exécution sont effectuées au détriment d’un petit coût de démarrage ponctuel et peuvent plus que compenser le temps passé dans le JIT.

Précompilation du code (à l’aide de ngen.exe)

Pour un fournisseur d’application, la possibilité de précompiler du code pendant l’installation est une option intéressante. Microsoft fournit cette option sous la forme ngen.exe, ce qui vous permet d’exécuter le compilateur JIT normal sur l’ensemble de votre programme une fois et d’enregistrer le résultat. Étant donné que les optimisations du temps d’exécution uniquement ne peuvent pas être effectuées pendant la précompilation, le code généré n’est généralement pas aussi bon que celui généré par un JIT normal. Toutefois, sans avoir à utiliser les méthodes JIT à la volée, le coût de démarrage est beaucoup plus faible et certains programmes seront lancés plus rapidement. À l’avenir, ngen.exe peut faire plus que simplement exécuter le même JIT d’exécution : optimisations plus agressives avec des limites plus élevées que la durée d’exécution, exposition à l’optimisation de l’ordre de charge pour les développeurs (optimisation de la façon dont le code est empaqueté dans les pages de machine virtuelle) et optimisations plus complexes et chronophages qui peuvent tirer parti du temps pendant la précompilation.

La réduction du temps de démarrage est utile dans deux cas, et pour tout le reste, elle ne concurrence pas les optimisations de temps d’exécution uniquement que peut faire JITing standard. La première situation est où vous appelez un grand nombre de méthodes au début de votre programme. Vous devrez JIT de nombreuses méthodes à l’avance, ce qui entraîne un temps de chargement inacceptable. Ce ne sera pas le cas pour la plupart des gens, mais la pré-JITing peut avoir un sens si cela vous affecte. La précompilation est également logique dans le cas des bibliothèques partagées volumineuses, car vous payez le coût de chargement de ces bibliothèques beaucoup plus souvent. Microsoft précompile les frameworks pour le CLR, car la plupart des applications les utilisent.

Il est facile d’utiliser ngen.exe pour voir si la précompilation est la réponse pour vous, donc je vous recommande de l’essayer. Toutefois, la plupart du temps, il est préférable d’utiliser le JIT normal et de tirer parti des optimisations de l’exécution. Ils ont un énorme paiement et compenseront plus que le coût de démarrage ponctuel dans la plupart des situations.

Réglage de la vitesse

Pour le programmeur, il n’y a vraiment que deux choses à noter. Tout d’abord, le JIT est très intelligent. N’essayez pas de déjouer le compilateur. Codez comme vous le feriez normalement. Par exemple, supposons que vous avez le code suivant :

...

for(int i = 0; i < myArray.length; i++){

...

}

...

...

int l = myArray.length;

for(int i = 0; i < l; i++){

...

}

...

Certains programmeurs croient qu’ils peuvent obtenir un gain de vitesse en déplaçant le calcul de longueur et en l’enregistrant dans un temp, comme dans l’exemple à droite.

En vérité, des optimisations comme celle-ci n’ont pas été utiles depuis près de 10 ans : les compilateurs modernes sont plus que capables d’effectuer cette optimisation pour vous. En fait, parfois, des choses comme celle-ci peuvent réellement nuire aux performances. Dans l’exemple ci-dessus, un compilateur case activée probablement pour voir que la longueur de myArray est constante et insérer une constante dans la comparaison de la boucle for. Mais le code à droite peut amener le compilateur à penser que cette valeur doit être stockée dans un registre, car l elle est active tout au long de la boucle. L’essentiel est : écrivez le code qui est le plus lisible et qui est le plus logique. Cela ne va pas aider à essayer de déjouer le compilateur, et parfois cela peut faire mal.

La deuxième chose dont il faut parler, ce sont les appels de fin. À l’heure actuelle, les compilateurs C# et Microsoft® Visual Basic® ne vous permettent pas de spécifier qu’un appel de fin doit être utilisé. Si vous avez vraiment besoin de cette fonctionnalité, une option consiste à ouvrir le fichier PE dans un désassembleur et à utiliser l’instruction MSIL .tail à la place. Il ne s’agit pas d’une solution élégante, mais les appels de fin ne sont pas aussi utiles dans C# et Visual Basic que dans des langages tels que Scheme ou ML. Personnes l’écriture de compilateurs pour les langages qui tirent vraiment parti des appels de fin doit être sûr d’utiliser cette instruction. La réalité pour la plupart des gens est que même ajuster manuellement l’il pour utiliser les appels de fin ne fournit pas un énorme avantage de vitesse. Parfois, le temps d’exécution les revient en appels réguliers, pour des raisons de sécurité ! Peut-être que dans les versions futures, davantage d’efforts seront déployés pour prendre en charge les appels de fin, mais à l’heure actuelle, le gain de performances est insuffisant pour le justifier, et très peu de programmeurs voudront en tirer parti.

AppDomains

Concepts de base

La communication interprocessus devient de plus en plus courante. Pour des raisons de stabilité et de sécurité, le système d’exploitation conserve les applications dans des espaces d’adressage distincts. Un exemple simple est la façon dont toutes les applications 16 bits sont exécutées dans NT : si elles s’exécutent dans un processus distinct, une application ne peut pas interférer avec l’exécution d’une autre. Le problème ici est le coût du commutateur de contexte et l’ouverture d’une connexion entre les processus. Cette opération est très coûteuse, et nuit beaucoup aux performances. Dans les applications serveur, qui hébergent souvent plusieurs applications web, il s’agit d’un impact majeur sur les performances et la scalabilité.

Le CLR introduit le concept d’un AppDomain, qui est similaire à un processus en ce qu’il s’agit d’un espace autonome pour une application. Toutefois, les AppDomains ne sont pas limités à un par processus. Il est possible d’exécuter deux AppDomains complètement indépendants dans le même processus, grâce à la sécurité de type fournie par le code managé. L’amélioration des performances ici est énorme pour les situations où vous consacrez normalement une grande partie de votre temps d’exécution à la surcharge de communication entre les processus : l’IPC entre les assemblys est cinq fois plus rapide qu’entre les processus en NT. En réduisant considérablement ce coût, vous bénéficiez à la fois d’une augmentation de la vitesse et d’une nouvelle option lors de la conception du programme : il est maintenant judicieux d’utiliser des processus distincts où, auparavant, il était peut-être beaucoup trop coûteux. La possibilité d’exécuter plusieurs programmes dans le même processus avec la même sécurité qu’auparavant a d’énormes implications pour la scalabilité et la sécurité.

La prise en charge d’AppDomains n’est pas présente dans le système d’exploitation. Les appDomains sont gérés par un hôte CLR, comme ceux présents dans ASP.NET, un exécutable d’interpréteur de commandes ou microsoft Internet Explorer. Vous pouvez également écrire les vôtres. Chaque hôte spécifie un domaine par défaut, qui est chargé lors du premier lancement de l’application et n’est fermé qu’à la fin du processus. Lorsque vous chargez d’autres assemblys dans le processus, vous pouvez spécifier qu’ils sont chargés dans un AppDomain spécifique et définir des stratégies de sécurité différentes pour chacun d’eux. Cela est décrit plus en détail dans la documentation du Kit de développement logiciel (SDK) Microsoft .NET Framework.

Réglage de la vitesse

Pour utiliser efficacement AppDomains, vous devez réfléchir au type d’application que vous écrivez et au type de travail qu’elle doit effectuer. En règle générale, les AppDomains sont plus efficaces lorsque votre application répond à certaines des caractéristiques suivantes :

  • Il génère souvent une nouvelle copie de lui-même.
  • Il fonctionne avec d’autres applications pour traiter des informations (requêtes de base de données à l’intérieur d’un serveur web, par exemple).
  • Il passe beaucoup de temps dans IPC avec des programmes qui fonctionnent exclusivement avec votre application.
  • Il ouvre et ferme d’autres programmes.

Un exemple de situation dans laquelle les AppDomains sont utiles peut être vu dans une application ASP.NET complexe. Supposons que vous souhaitiez appliquer l’isolation entre différentes vRoots : dans l’espace natif, vous devez placer chaque vRoot dans un processus distinct. Cela est assez coûteux, et le basculement de contexte entre eux est beaucoup de surcharge. Dans le monde managé, chaque vRoot peut être un AppDomain distinct. Cela permet de préserver l’isolation requise tout en réduisant considérablement la surcharge.

Les appDomains sont des éléments que vous devez utiliser uniquement si votre application est suffisamment complexe pour nécessiter une collaboration étroite avec d’autres processus ou d’autres instances. Bien que la communication iter-AppDomain soit beaucoup plus rapide que la communication entre processus, le coût de démarrage et de fermeture d’un AppDomain peut en fait être plus coûteux. Les appDomains peuvent finir par nuire aux performances lorsqu’ils sont utilisés pour des raisons incorrectes. Assurez-vous donc de les utiliser dans les bonnes situations. Notez que seul le code managé peut être chargé dans un AppDomain, car le code non managé ne peut pas être garanti sécurisé.

Les assemblys partagés entre plusieurs AppDomains doivent être JITed pour chaque domaine, afin de préserver l’isolation entre les domaines. Cela entraîne beaucoup de création de code en double et une perte de mémoire. Considérez le cas d’une application qui répond aux demandes avec une sorte de service XML. Si certaines demandes doivent être conservées isolées les unes des autres, vous devez les router vers différents AppDomains. Le problème ici est que chaque AppDomain nécessite désormais les mêmes bibliothèques XML et que le même assembly sera chargé plusieurs fois.

L’un des moyens de contourner ce problème consiste à déclarer un assembly comme étant neutre dans le domaine, ce qui signifie qu’aucune référence directe n’est autorisée et que l’isolation est appliquée via l’indirection. Cela permet de gagner du temps, car l’assembly n’est JITed qu’une seule fois. Il enregistre également de la mémoire, car rien n’est dupliqué. Malheureusement, il y a un succès de performance en raison de l’indirection requise. La déclaration d’un assembly comme étant neutre dans le domaine entraîne une victoire des performances lorsque la mémoire est un problème ou lorsque le code JITing est perdu trop de temps. De tels scénarios sont courants dans le cas d’un assembly volumineux partagé par plusieurs domaines.

Sécurité

Concepts de base

La sécurité d’accès au code est une fonctionnalité puissante et extrêmement utile. Il offre aux utilisateurs une exécution sécurisée de code semi-fiable, protège contre les logiciels malveillants et plusieurs types d’attaques, et permet un accès contrôlé et basé sur l’identité aux ressources. Dans le code natif, la sécurité est extrêmement difficile à fournir, car il existe peu de sécurité de type et le programmeur gère la mémoire. Dans le CLR, le temps d’exécution en sait suffisamment sur l’exécution du code pour ajouter une prise en charge forte de la sécurité, une fonctionnalité qui est nouvelle pour la plupart des programmeurs.

La sécurité affecte à la fois la vitesse et la taille de l’ensemble de travail d’une application. Et, comme dans la plupart des domaines de la programmation, la façon dont le développeur utilise la sécurité peut grandement déterminer son impact sur les performances. Le système de sécurité est conçu avec des performances à l’esprit et doit, dans la plupart des cas, fonctionner correctement avec peu ou pas de réflexion donnée par le développeur d’application. Toutefois, vous pouvez faire plusieurs choses pour optimiser les performances du système de sécurité.

Réglage de la vitesse

L’exécution d’un case activée de sécurité nécessite généralement une procédure de pile pour s’assurer que le code appelant la méthode actuelle dispose des autorisations appropriées. Le temps d’exécution a plusieurs optimisations qui l’aident à éviter de parcourir toute la pile, mais il existe plusieurs choses que le programmeur peut faire pour vous aider. Cela nous amène à la notion de sécurité impérative par rapport à la sécurité déclarative : la sécurité déclarative orne un type ou ses membres avec diverses autorisations, tandis que la sécurité impérative crée un objet de sécurité et effectue des opérations sur celui-ci.

  • La sécurité déclarative est le moyen le plus rapide pour Assert, Deny et PermitOnly. Ces opérations nécessitent généralement une procédure de pile pour localiser le cadre d’appel correct, mais cela peut être évité si vous déclarez explicitement ces modificateurs. Les demandes sont plus rapides si elles sont effectuées de manière impérative.
  • Lorsque vous effectuez une interopérabilité avec du code non managé, vous pouvez supprimer les vérifications de sécurité au moment de l’exécution à l’aide de l’attribut SuppressUnmanagedCodeSecurity. Cela déplace le case activée dans le temps de liaison, ce qui est beaucoup plus rapide. Par précaution, assurez-vous que le code n’expose aucune faille de sécurité à un autre code, ce qui pourrait exploiter les case activée supprimés dans du code non sécurisé.
  • Les vérifications d’identité sont plus coûteuses que les vérifications de code. Vous pouvez utiliser LinkDemand pour effectuer ces vérifications au moment du lien à la place.

Il existe deux façons d’optimiser la sécurité :

  • Effectuez des vérifications au moment du lien plutôt qu’au moment de l’exécution.
  • Rendre les vérifications de sécurité déclaratives plutôt qu’impératives.

La première chose sur laquelle vous devez vous concentrer est de déplacer autant de vérifications que possible pour lier le temps. Gardez à l’esprit que cela peut avoir un impact sur la sécurité de votre application. Veillez donc à ne pas déplacer les vérifications dans l’éditeur de liens qui dépendent de l’état d’exécution. Une fois que vous avez déplacé autant que possible dans l’heure des liens, vous devez optimiser les vérifications au moment de l’exécution en utilisant une sécurité déclarative ou impérative : choisissez qui est optimal pour le type de case activée que vous utilisez.

Communication à distance

Concepts de base

La technologie de communication à distance dans .NET étend le système de type riche et les fonctionnalités du CLR sur le réseau. À l’aide de XML, SOAP et HTTP, vous pouvez appeler des procédures et passer des objets à distance, comme s’ils étaient hébergés sur le même ordinateur. Vous pouvez considérer cela comme la version .NET de DCOM ou CORBA, en ce qu’elle fournit un sur-ensemble de leurs fonctionnalités.

Cela est particulièrement utile dans un environnement serveur, lorsque vous avez plusieurs serveurs hébergeant différents services, tous en se parlant entre eux pour lier ces services en toute transparence. La scalabilité est également améliorée, car les processus peuvent être rompus physiquement sur plusieurs ordinateurs sans perdre de fonctionnalités.

Réglage de la vitesse

Étant donné que la communication à distance entraîne souvent une pénalité en termes de latence réseau, les mêmes règles s’appliquent dans le CLR que : essayez de réduire la quantité de trafic que vous envoyez et évitez que le reste du programme attende le retour d’un appel distant. Voici quelques bonnes règles à suivre lors de l’utilisation de la communication à distance pour optimiser les performances :

  • Effectuez des appels segments au lieu d’appels bavards : vérifiez si vous pouvez réduire le nombre d’appels que vous devez effectuer à distance. Par exemple, supposons que vous définissez des propriétés pour un objet distant à l’aide des méthodes get() et set(). Il vous ferait gagner du temps pour recréer simplement l’objet à distance, avec ces propriétés définies lors de la création. Étant donné que cela peut être effectué à l’aide d’un seul appel distant, vous gagnerez du temps perdu dans le trafic réseau. Parfois, il peut être judicieux de déplacer l’objet sur l’ordinateur local, de définir les propriétés là-bas, puis de le copier. En fonction de la bande passante et de la latence, il arrive qu’une solution soit plus logique que l’autre.
  • Équilibrez la charge du processeur avec la charge réseau : parfois, il est judicieux d’envoyer quelque chose à faire sur le réseau, et d’autres fois, il est préférable d’effectuer le travail vous-même. Si vous perdez beaucoup de temps à parcourir le réseau, vos performances en souffriront. Si vous utilisez une trop grande partie de votre processeur, vous ne pourrez pas répondre à d’autres demandes. Il est essentiel de trouver un bon équilibre entre ces deux éléments pour que votre application soit mise à l’échelle.
  • Utiliser des appels asynchrones : lorsque vous effectuez un appel sur le réseau, assurez-vous qu’il est asynchrone, sauf si vous en avez vraiment besoin. Sinon, votre application se bloque jusqu’à ce qu’elle reçoive une réponse, ce qui peut être inacceptable dans une interface utilisateur ou un serveur à volume élevé. Un bon exemple à examiner est disponible dans le KIT de développement logiciel (SDK) Framework fourni avec .NET, sous « Samples\technologies\remoting\advanced\asyncdelegate ».
  • Utiliser les objets de manière optimale : vous pouvez spécifier qu’un nouvel objet est créé pour chaque requête (SingleCall) ou que le même objet est utilisé pour toutes les requêtes (Singleton). Le fait d’avoir un seul objet pour toutes les requêtes est certainement moins gourmand en ressources, mais vous devez faire attention à la synchronisation et à la configuration de l’objet d’une requête à une autre.
  • Utiliser des canaux et des formateurs enfichables : une fonctionnalité puissante de la communication à distance est la possibilité de connecter n’importe quel canal ou formateur à votre application. Par exemple, sauf si vous avez besoin de passer par un pare-feu, il n’y a aucune raison d’utiliser le canal HTTP. La connexion à un canal TCP vous permettra d’obtenir de bien meilleures performances. Veillez à choisir le canal ou le formateur qui vous convient le mieux.

ValueTypes

Concepts de base

La flexibilité offerte par les objets est offerte à un petit prix de performance. L’allocation, l’accès et la mise à jour des objets gérés par un tas prennent plus de temps que les objets gérés par pile. C’est pourquoi, par exemple, un struct en C++ est beaucoup plus efficace qu’un objet. Bien sûr, les objets peuvent faire des choses que les structs ne peuvent pas, et sont beaucoup plus polyvalents.

Mais parfois, vous n’avez pas besoin de toute cette flexibilité. Parfois, vous voulez quelque chose d’aussi simple qu’un struct, et vous ne voulez pas payer le coût des performances. Le CLR vous permet de spécifier ce qu’on appelle un ValueType et, au moment de la compilation, il est traité comme un struct. Les ValueTypes sont gérés par la pile et vous fournissent toute la vitesse d’un struct. Comme prévu, ils sont également fournis avec la flexibilité limitée des structs (il n’y a pas d’héritage, par exemple). Mais pour les instances où tout ce dont vous avez besoin est un struct, les ValueTypes fournissent une augmentation de vitesse incroyable. Des informations plus détaillées sur ValueTypes et le reste du système de type CLR sont disponibles sur MSDN Library.

Réglage de la vitesse

Les ValueTypes sont utiles uniquement dans les cas où vous les utilisez comme structs. Si vous devez traiter un ValueType comme un objet, le temps d’exécution gère la boxe et le déballage de l’objet pour vous. Cependant, c’est encore plus coûteux que de le créer en tant qu’objet en premier lieu!

Voici un exemple de test simple qui compare le temps nécessaire à la création d’un grand nombre d’objets et de ValueTypes :

using System;
using System.Collections;

namespace ConsoleApplication{
  public struct foo{
    public foo(double arg){ this.y = arg; }
    public double y;
  }
  public class bar{
    public bar(double arg){ this.y = arg; }
    public double y;
  }
class Class1{
  static void Main(string[] args){
    Console.WriteLine("starting struct loop....");
    int t1 = Environment.TickCount;
    for (int i = 0; i < 25000000; i++) {
      foo test1 = new foo(3.14);
      foo test2 = new foo(3.15);
       if (test1.y == test2.y) break; // prevent code from being 
       eliminated JIT
    }
    int t2 = Environment.TickCount;
    Console.WriteLine("struct loop: (" + (t2-t1) + "). starting object 
       loop....");
    t1 = Environment.TickCount;
    for (int i = 0; i < 25000000; i++) {
      bar test1 = new bar(3.14);
      bar test2 = new bar(3.15);
      if (test1.y == test2.y) break; // prevent code from being 
      eliminated JIT
    }
    t2 = Environment.TickCount;
    Console.WriteLine("object loop: (" + (t2-t1) + ")");
    }

Essayez par vous-même L’intervalle de temps est de l’ordre de plusieurs secondes. Nous allons maintenant modifier le programme afin que le temps d’exécution doit boxer et déconstruire notre struct. Notez que les avantages de vitesse de l’utilisation d’un ValueType ont complètement disparu ! La morale ici est que les ValueTypes ne sont utilisés que dans des situations extrêmement rares, lorsque vous ne les utilisez pas comme objets. Il est important d’examiner ces situations, car les gains de performance sont souvent extrêmement importants lorsque vous les utilisez correctement.

using System;
using System.Collections;

namespace ConsoleApplication{
  public struct foo{
    public foo(double arg){ this.y = arg; }
    public double y;
  }
  public class bar{
    public bar(double arg){ this.y = arg; }
    public double y;
  }
  class Class1{
    static void Main(string[] args){
      Hashtable boxed_table = new Hashtable(2);
      Hashtable object_table = new Hashtable(2);
      System.Console.WriteLine("starting struct loop...");
      for(int i = 0; i < 10000000; i++){
        boxed_table.Add(1, new foo(3.14)); 
        boxed_table.Add(2, new foo(3.15));
        boxed_table.Remove(1);
      }
      System.Console.WriteLine("struct loop complete. 
                                starting object loop...");
      for(int i = 0; i < 10000000; i++){
        object_table.Add(1, new bar(3.14)); 
        object_table.Add(2, new bar(3.15));
        object_table.Remove(1);
      }
      System.Console.WriteLine("All done");
    }
  }
}

Microsoft utilise les ValueTypes en grande partie : toutes les primitives des frameworks sont des ValueTypes. Ma recommandation est d’utiliser ValueTypes chaque fois que vous vous sentez démanger pour un struct. Tant que vous ne boxez pas/unbox, ils peuvent fournir un énorme boost de vitesse.

Une chose extrêmement importante à noter est que les ValueTypes ne nécessitent pas de marshaling dans les scénarios d’interopérabilité. Étant donné que le marshaling est l’un des plus grands succès en matière de performances lors de l’interopérabilité avec du code natif, l’utilisation de ValueTypes comme arguments pour les fonctions natives est peut-être le plus grand ajustement des performances que vous pouvez faire.

Ressources supplémentaires

Les rubriques connexes sur les performances dans .NET Framework sont les suivantes :

Regardez les futurs articles en cours de développement, y compris une vue d’ensemble de la conception, de l’architecture et des philosophies de codage, une procédure pas à pas des outils d’analyse des performances dans le monde managé et une comparaison des performances de .NET avec d’autres applications d’entreprise disponibles aujourd’hui.

Annexe : Hébergement de l’heure d’exécution du serveur

#include "mscoree.h"
#include "stdio.h"
#import "mscorlib.tlb" named_guids no_namespace raw_interfaces_only \
no_implementation exclude("IID_IObjectHandle", "IObjectHandle")

long main(){
  long retval = 0;
  LPWSTR pszFlavor = L"svr";

  // Bind to the Run time.
  ICorRuntimeHost *pHost = NULL;
  HRESULT hr = CorBindToRuntimeEx(NULL,
               pszFlavor, 
               NULL,
               CLSID_CorRuntimeHost, 
               IID_ICorRuntimeHost, 
               (void **)&pHost);

  if (SUCCEEDED(hr)){
    printf("Got ICorRuntimeHost\n");
      
    // Start the Run time (this also creates a default AppDomain)
    hr = pHost->Start();
    if(SUCCEEDED(hr)){
      printf("Started\n");
         
      // Get the Default AppDomain created when we called Start
      IUnknown *pUnk = NULL;
      hr = pHost->GetDefaultDomain(&pUnk);

      if(SUCCEEDED(hr)){
        printf("Got IUnknown\n");
            
        // Ask for the _AppDomain Interface
        _AppDomain *pDomain = NULL;
        hr = pUnk->QueryInterface(IID__AppDomain, (void**)&pDomain);
            
        if(SUCCEEDED(hr)){
          printf("Got _AppDomain\n");
               
          // Execute Assembly's entry point on this thread
          BSTR pszAssemblyName = SysAllocString(L"Managed.exe");
          hr = pDomain->ExecuteAssembly_2(pszAssemblyName, &retval);
          SysFreeString(pszAssemblyName);
               
          if (SUCCEEDED(hr)){
            printf("Execution completed\n");

            //Execution completed Successfully
            pDomain->Release();
            pUnk->Release();
            pHost->Stop();
            
            return retval;
          }
        }
        pDomain->Release();
        pUnk->Release();
      }
    }
    pHost->Release();
  }
  printf("Failure, HRESULT: %x\n", hr);
   
  // If we got here, there was an error, return the HRESULT
  return hr;
}

Si vous avez des questions ou des commentaires sur cet article, contactez Claudio Caldato, responsable de programme pour les problèmes de performances .NET Framework.