Recherche et analyse de l'utilisation élevée du processeur dans les applications .NET

Publié: 2022-03-11

Le développement de logiciels peut être un processus très compliqué. En tant que développeurs, nous devons prendre en compte de nombreuses variables différentes. Certains ne sont pas sous notre contrôle, certains nous sont inconnus au moment de l'exécution réelle du code, et certains sont directement contrôlés par nous. Et les développeurs .NET ne font pas exception à cela.

Compte tenu de cette réalité, les choses se déroulent généralement comme prévu lorsque nous travaillons dans des environnements contrôlés. Un exemple est notre machine de développement, ou un environnement d'intégration auquel nous avons un accès complet. Dans ces situations, nous avons à notre disposition des outils pour analyser différentes variables qui affectent notre code et nos logiciels. Dans ces cas, nous n'avons pas non plus à faire face à de lourdes charges du serveur ou à des utilisateurs simultanés essayant de faire la même chose en même temps.

Dans les situations décrites et sûres, notre code fonctionnera correctement, mais en production sous une charge importante ou d'autres facteurs externes, des problèmes inattendus pourraient survenir. Les performances des logiciels en production sont difficiles à analyser. La plupart du temps, nous devons faire face à des problèmes potentiels dans un scénario théorique : nous savons qu'un problème peut survenir, mais nous ne pouvons pas le tester. C'est pourquoi nous devons baser notre développement sur les meilleures pratiques et la documentation du langage que nous utilisons, et éviter les erreurs courantes.

Comme mentionné, lorsque le logiciel est mis en ligne, les choses peuvent mal tourner et le code peut commencer à s'exécuter d'une manière que nous n'avions pas prévue. Nous pourrions nous retrouver dans la situation où nous devons traiter des problèmes sans pouvoir déboguer ou savoir avec certitude ce qui se passe. Que pouvons-nous faire dans ce cas ?

Une utilisation élevée du processeur se produit lorsqu'un processus utilise plus de 90 % du processeur pendant une période prolongée - et nous avons des problèmes

Si un processus utilise plus de 90 % du processeur pendant une période prolongée, nous avons des problèmes
Tweeter

Dans cet article, nous allons analyser un cas réel d'utilisation élevée du processeur d'une application Web .NET sur le serveur Windows, les processus impliqués pour identifier le problème et, plus important encore, pourquoi ce problème s'est produit en premier lieu et comment nous résoudre.

L'utilisation du processeur et la consommation de mémoire sont des sujets largement discutés. Habituellement, il est très difficile de savoir avec certitude quelle est la bonne quantité de ressources (CPU, RAM, E/S) qu'un processus spécifique doit utiliser, et pendant quelle période de temps. Bien qu'une chose soit sûre - si un processus utilise plus de 90% du CPU pendant une période prolongée, nous avons des problèmes simplement à cause du fait que le serveur ne sera pas en mesure de traiter toute autre demande dans cette circonstance.

Cela signifie-t-il qu'il y a un problème avec le processus lui-même ? Pas nécessairement. Il se peut que le processus ait besoin de plus de puissance de traitement ou qu'il traite beaucoup de données. Pour commencer, la seule chose que nous pouvons faire est d'essayer d'identifier pourquoi cela se produit.

Tous les systèmes d'exploitation disposent de plusieurs outils différents pour surveiller ce qui se passe sur un serveur. Les serveurs Windows ont spécifiquement le gestionnaire de tâches, Performance Monitor, ou dans notre cas, nous avons utilisé New Relic Servers qui est un excellent outil pour surveiller les serveurs.

Premiers symptômes et analyse du problème

Après avoir déployé notre application, pendant un laps de temps des deux premières semaines, nous avons commencé à voir que le serveur avait des pics d'utilisation du processeur, ce qui rendait le serveur insensible. Nous avons dû le redémarrer afin de le rendre à nouveau disponible, et cet événement s'est produit trois fois au cours de cette période. Comme je l'ai mentionné précédemment, nous avons utilisé New Relic Servers comme moniteur de serveur, et cela a montré que le processus w3wp.exe utilisait 94 % du processeur au moment où le serveur s'est écrasé.

Un processus de travail Internet Information Services (IIS) est un processus Windows ( w3wp.exe ) qui exécute des applications Web et est chargé de gérer les demandes envoyées à un serveur Web pour un pool d'applications spécifique. Le serveur IIS peut avoir plusieurs pools d'applications (et plusieurs processus w3wp.exe différents) qui pourraient générer le problème. En fonction de l'utilisateur que le processus avait (cela a été montré dans les rapports New Relic), nous avons identifié que le problème était notre application héritée de formulaire Web .NET C#.

Le .NET Framework est étroitement intégré aux outils de débogage de Windows. La première chose que nous avons essayée de faire a donc été de consulter l'observateur d'événements et les fichiers journaux de l'application pour trouver des informations utiles sur ce qui se passait. Que nous ayons eu des exceptions enregistrées dans l'observateur d'événements, elles n'ont pas fourni suffisamment de données à analyser. C'est pourquoi nous avons décidé d'aller plus loin et de collecter plus de données, de sorte que lorsque l'événement se reproduirait, nous serions prêts.

Collecte de données

Le moyen le plus simple de collecter des vidages de processus en mode utilisateur consiste à utiliser Debug Diagnostic Tools v2.0 ou simplement DebugDiag. DebugDiag dispose d'un ensemble d'outils de collecte de données (DebugDiag Collection) et d'analyse de données (DebugDiag Analysis).

Commençons donc à définir des règles de collecte de données avec Debug Diagnostic Tools :

  1. Ouvrez la collection DebugDiag et sélectionnez Performance .

    Outil de diagnostic de débogage

  2. Sélectionnez Performance Counters et cliquez sur Next .
  3. Cliquez sur Add Perf Triggers .
  4. Développez l'objet Processor (pas le Process ) et sélectionnez % Processor Time . Notez que si vous êtes sur Windows Server 2008 R2 et que vous avez plus de 64 processeurs, veuillez choisir l'objet Processor Information au lieu de l'objet Processor .
  5. Dans la liste des instances, sélectionnez _Total .
  6. Cliquez sur Add puis sur OK .
  7. Sélectionnez le déclencheur nouvellement ajouté et cliquez sur Edit Thresholds .

    Compteurs de performances

  8. Sélectionnez Au- Above dans la liste déroulante.
  9. Changez le seuil à 80 .
  10. Entrez 20 pour le nombre de secondes. Vous pouvez ajuster cette valeur si nécessaire, mais veillez à ne pas spécifier un petit nombre de secondes afin d'éviter les faux déclenchements.

    Propriétés du déclencheur du moniteur de performances

  11. Cliquez sur OK .
  12. Cliquez sur Next .
  13. Cliquez sur Add Dump Target .
  14. Sélectionnez Web Application Pool dans la liste déroulante.
  15. Sélectionnez votre pool d'applications dans la liste des pools d'applications.
  16. Cliquez sur OK .
  17. Cliquez sur Next .
  18. Cliquez à nouveau sur Next .
  19. Entrez un nom pour votre règle si vous le souhaitez et notez l'emplacement où les vidages seront enregistrés. Vous pouvez modifier cet emplacement si vous le souhaitez.
  20. Cliquez sur Next .
  21. Sélectionnez Activate the Rule Now et cliquez sur Finish .

La règle décrite créera un ensemble de fichiers minidump qui seront de taille assez petite. Le vidage final sera un vidage avec mémoire pleine, et ce vidage sera beaucoup plus volumineux. Maintenant, nous n'avons plus qu'à attendre que l'événement CPU élevé se reproduise.

Une fois que nous aurons les fichiers de vidage dans le dossier sélectionné, nous utiliserons l'outil d'analyse DebugDiag afin d'analyser les données collectées :

  1. Sélectionnez Analyseurs de performances.

    Outil d'analyse DebugDiag

  2. Ajoutez les fichiers de vidage.

    Fichiers de vidage de péage d'analyse DebugDiag

  3. Lancer l'analyse.

DebugDiag prendra quelques (ou plusieurs) minutes pour analyser les vidages et fournir une analyse. Une fois l'analyse terminée, vous verrez une page Web avec un résumé et de nombreuses informations sur les fils de discussion, similaire à la suivante :

Résumé de l'analyse

Comme vous pouvez le voir dans le résumé, un avertissement indique "Une utilisation élevée du processeur entre les fichiers de vidage a été détectée sur un ou plusieurs threads". Si nous cliquons sur la recommandation, nous commencerons à comprendre où se situe le problème avec notre application. Notre exemple de rapport ressemble à ceci :

Top 10 des threads par processeur moyen

Comme nous pouvons le voir dans le rapport, il existe une tendance concernant l'utilisation du processeur. Tous les threads qui ont une utilisation élevée du processeur sont liés à la même classe. Avant de passer au code, examinons le premier.

Pile d'appels .NET

C'est le détail du premier fil avec notre problème. La partie qui nous intéresse est la suivante :

Détails de la pile d'appels .NET

Ici, nous avons un appel à notre code GameHub.OnDisconnected() qui a déclenché l'opération problématique, mais avant cet appel, nous avons deux appels Dictionary, qui peuvent donner une idée de ce qui se passe. Examinons le code .NET pour voir ce que fait cette méthode :

 public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }

Nous avons évidemment un problème ici. La pile d'appels des rapports a indiqué que le problème était avec un dictionnaire, et dans ce code, nous accédons à un dictionnaire, et plus précisément la ligne qui cause le problème est celle-ci :

 if (onlineSessions.TryGetValue(userId, out connId))

Voici la déclaration du dictionnaire :

 static Dictionary<int, string> onlineSessions = new Dictionary<int, string>();

Quel est le problème avec ce code .NET ?

Tous ceux qui ont de l'expérience en programmation orientée objet savent que les variables statiques seront partagées par toutes les instances de cette classe. Examinons de plus près ce que signifie statique dans le monde .NET.

Selon la spécification .NET C# :

Utilisez le modificateur static pour déclarer un membre statique, qui appartient au type lui-même plutôt qu'à un objet spécifique.

Voici ce que disent les spécifications du langage .NET C# concernant les classes statiques et les membres :

Comme c'est le cas avec tous les types de classe, les informations de type pour une classe statique sont chargées par le Common Language Runtime (CLR) .NET Framework lorsque le programme qui fait référence à la classe est chargé. Le programme ne peut pas spécifier exactement quand la classe est chargée. Cependant, il est garanti d'être chargé et d'avoir ses champs initialisés et son constructeur statique appelé avant que la classe ne soit référencée pour la première fois dans votre programme. Un constructeur statique n'est appelé qu'une seule fois et une classe statique reste en mémoire pendant toute la durée de vie du domaine d'application dans lequel réside votre programme.

Une classe non statique peut contenir des méthodes, des champs, des propriétés ou des événements statiques. Le membre statique est appelable sur une classe même lorsqu'aucune instance de la classe n'a été créée. Le membre statique est toujours accessible par le nom de la classe, et non par le nom de l'instance. Une seule copie d'un membre statique existe, quel que soit le nombre d'instances de la classe créées. Les méthodes et propriétés statiques ne peuvent pas accéder aux champs et événements non statiques dans leur type conteneur, et elles ne peuvent pas accéder à une variable d'instance d'un objet à moins qu'elle ne soit explicitement passée dans un paramètre de méthode.

Cela signifie que les membres statiques appartiennent au type lui-même, pas à l'objet. Ils sont également chargés dans le domaine d'application par le CLR. Par conséquent, les membres statiques appartiennent au processus qui héberge l'application et non à des threads spécifiques.

Étant donné qu'un environnement Web est un environnement multithread, chaque requête est un nouveau thread généré par le processus w3wp.exe ; et étant donné que les membres statiques font partie du processus, nous pouvons avoir un scénario dans lequel plusieurs threads différents tentent d'accéder aux données de variables statiques (partagées par plusieurs threads), ce qui peut éventuellement conduire à des problèmes de multithreading.

La documentation du dictionnaire sous la sécurité des threads indique ce qui suit :

Un Dictionary<TKey, TValue> peut prendre en charge plusieurs lecteurs simultanément, tant que la collection n'est pas modifiée. Même ainsi, l'énumération d'une collection n'est intrinsèquement pas une procédure thread-safe. Dans les rares cas où une énumération est confrontée à des accès en écriture, la collection doit être verrouillée pendant toute l'énumération. Pour permettre l'accès à la collection par plusieurs threads pour la lecture et l'écriture, vous devez implémenter votre propre synchronisation.

Cette déclaration explique pourquoi nous pouvons avoir ce problème. D'après les informations de vidage, le problème était lié à la méthode FindEntry du dictionnaire :

Détails de la pile d'appels .NET

Si nous regardons l'implémentation du dictionnaire FindEntry, nous pouvons voir que la méthode parcourt la structure interne (buckets) afin de trouver la valeur.

Ainsi, le code .NET suivant énumère la collection, ce qui n'est pas une opération thread-safe.

 public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }

Conclusion

Comme nous l'avons vu dans les dumps, plusieurs threads tentent d'itérer et de modifier une ressource partagée (dictionnaire statique) en même temps, ce qui a finalement fait entrer l'itération dans une boucle infinie, ce qui a amené le thread à consommer plus de 90 % du CPU. .

Il existe plusieurs solutions possibles à ce problème. Celui que nous avons implémenté en premier consistait à verrouiller et synchroniser l'accès au dictionnaire au prix d'une perte de performances. Le serveur plantait tous les jours à ce moment-là, nous devions donc résoudre ce problème dès que possible. Même si ce n'était pas la solution optimale, cela a résolu le problème.

La prochaine étape dans la résolution de ce problème serait d'analyser le code et de trouver la solution optimale pour cela. Refactoriser le code est une option : la nouvelle classe ConcurrentDictionary pourrait résoudre ce problème car elle ne se verrouille qu'au niveau du compartiment, ce qui améliorera les performances globales. Bien qu'il s'agisse d'un grand pas en avant, une analyse plus approfondie serait nécessaire.