Partager via


Parcours de la pile du profileur dans .NET Framework 2.0 : Concepts de base et au-delà

 

Septembre 2006

David Broman
Microsoft Corporation

S’applique à :
   Microsoft .NET Framework 2.0
   CLR (Common Language Runtime)

Résumé: Décrit comment programmer votre profileur pour parcourir les piles managées dans le Common Language Runtime (CLR) du .NET Framework. (14 pages imprimées)

Contenu

Introduction
Appels synchrones et asynchrones
Mélangez-le
Soyez sur votre meilleur comportement
Assez c'est assez
Crédit où le crédit est dû
À propos de l’auteur

Introduction

Cet article s’adresse à toute personne intéressée par la création d’un profileur pour examiner les applications managées. Je vais décrire comment programmer votre profileur pour parcourir les piles managées dans le Common Language Runtime (CLR) du .NET Framework. Je vais essayer de garder l’ambiance légère, parce que le sujet lui-même peut être lourd à certains moments.

L’API de profilage de la version 2.0 du CLR a une nouvelle méthode nommée DoStackSnapshot qui permet à votre profileur de parcourir la pile des appels de l’application que vous effectuez le profilage. La version 1.1 du CLR a exposé des fonctionnalités similaires via l’interface de débogage in-process. Mais parcourir la pile des appels est plus facile, plus précis et plus stable avec DoStackSnapshot. La méthode DoStackSnapshot utilise le même déambulateur de pile que celui utilisé par le récupérateur de mémoire, le système de sécurité, le système d’exceptions, etc. Donc tu sais que ça doit être juste.

L’accès à une trace de pile complète permet aux utilisateurs de votre profileur d’avoir une vue d’ensemble de ce qui se passe dans une application quand quelque chose d’intéressant se produit. En fonction de l’application et de ce qu’un utilisateur souhaite profiler, vous pouvez imaginer qu’un utilisateur souhaite une pile d’appels lorsqu’un objet est alloué, lorsqu’une classe est chargée, lorsqu’une exception est levée, etc. Même l’obtention d’une pile d’appels pour un événement autre qu’un événement d’application(par exemple, un événement de minuteur) serait intéressant pour un profileur d’échantillonnage. L’analyse des points chauds dans le code devient plus éclairante quand vous pouvez voir qui a appelé la fonction qui a appelé la fonction contenant le point chaud.

Je vais me concentrer sur l’obtention de traces de pile avec l’API DoStackSnapshot . Une autre façon d’obtenir des traces de pile consiste à créer des piles d’ombres : vous pouvez raccorder FunctionEnter et FunctionLeave pour conserver une copie de la pile des appels managés pour le thread actuel. La génération de pile fantôme est utile si vous avez besoin d’informations de pile à tout moment pendant l’exécution de l’application, et si vous ne vous souciez pas du coût de performances lié à l’exécution du code de votre profileur à chaque appel managé et retour. La méthode DoStackSnapshot est la meilleure si vous avez besoin de rapports légèrement épars de piles, par exemple en réponse à des événements. Même un profileur d’échantillonnage prenant des instantanés de pile toutes les quelques millisecondes est beaucoup plus épars que la création de piles d’ombres. Par conséquent, DoStackSnapshot est bien adapté aux profileurs d’échantillonnage.

Faire une promenade sur le côté sauvage

Il est très utile de pouvoir obtenir des piles d’appels quand vous le souhaitez. Mais avec le pouvoir vient la responsabilité. Un utilisateur du profileur ne souhaite pas que la marche sur la pile entraîne une violation d’accès (AV) ou un interblocage dans le runtime. En tant qu’auteur de profileur, vous devez exercer votre pouvoir avec soin. Je vais vous parler de la façon d’utiliser DoStackSnapshot, et comment le faire avec soin. Comme vous le verrez, plus vous voulez faire avec cette méthode, plus il est difficile d’obtenir la bonne réponse.

Jetons un coup d’œil à notre sujet. Voici ce que votre profileur appelle (vous pouvez le trouver dans l’interface ICorProfilerInfo2 dans Corprof.idl) :

HRESULT DoStackSnapshot( 
  [in] ThreadID thread, 
  [in] StackSnapshotCallback *callback, 
  [in] ULONG32 infoFlags, 
  [in] void *clientData, 
  [in, size_is(contextSize), length_is(contextSize)] BYTE context[], 
  [in] ULONG32 contextSize); 

Le code suivant est ce que le CLR appelle sur votre profileur. (Vous pouvez également le trouver dans Corprof.idl.) Vous passez un pointeur vers votre implémentation de cette fonction dans le paramètre de rappel de l’exemple précédent.

typedef HRESULT __stdcall StackSnapshotCallback( 
  FunctionID funcId, 
  UINT_PTR ip, 
  COR_PRF_FRAME_INFO frameInfo, 
  ULONG32 contextSize, 
  BYTE context[], 
  void *clientData); 

C’est comme un sandwich. Lorsque votre profileur souhaite parcourir la pile, vous appelez DoStackSnapshot. Avant que le CLR ne revienne de cet appel, il appelle votre fonction StackSnapshotCallback plusieurs fois, une fois pour chaque frame managé ou pour chaque exécution d’images non managées sur la pile. La figure 1 montre ce sandwich.

Figure 1. Un « sandwich » d’appels pendant le profilage

Comme vous pouvez le voir dans mes notations, le CLR vous informe des images dans l’ordre inverse de la façon dont elles ont été poussées sur la pile : cadre feuille en premier (poussé en dernier), main dernier cadre (poussé en premier).

Que signifient tous les paramètres de ces fonctions ? Je ne suis pas encore prêt à en discuter tous, mais je vais en discuter quelques-uns, en commençant par DoStackSnapshot. (Je vais aller au reste dans quelques instants.) La valeur infoFlags provient de l’énumération COR_PRF_SNAPSHOT_INFO dans Corprof.idl et vous permet de contrôler si le CLR vous donnera des contextes d’enregistrement pour les trames qu’il signale. Vous pouvez spécifier n’importe quelle valeur de votre choix pour clientData et le CLR vous la rendra dans votre appel StackSnapshotCallback .

Dans StackSnapshotCallback, le CLR utilise le paramètre funcId pour vous transmettre la valeur FunctionID du frame actuellement parcouru. Cette valeur est 0 si le frame actuel est une série d’images non managées, dont je vous parlerai plus tard. Si funcId est différent de zéro, vous pouvez passer funcId et frameInfo à d’autres méthodes, telles que GetFunctionInfo2 et GetCodeInfo2, pour obtenir plus d’informations sur la fonction. Vous pouvez obtenir ces informations de fonction immédiatement, pendant votre marche sur la pile, ou bien enregistrer les valeurs de funcId et obtenir les informations de fonction plus tard, ce qui réduit votre impact sur l’application en cours d’exécution. Si vous obtenez les informations de la fonction ultérieurement, n’oubliez pas qu’une valeur frameInfo n’est valide qu’à l’intérieur du rappel qui vous la donne. Bien qu’il soit possible d’enregistrer les valeurs de funcId pour une utilisation ultérieure, n’enregistrez pas frameInfo pour une utilisation ultérieure.

Lorsque vous revenez de StackSnapshotCallback, vous retournez généralement S_OK et le CLR continue à parcourir la pile. Si vous le souhaitez, vous pouvez retourner S_FALSE, ce qui arrête la marche de la pile. Votre appel DoStackSnapshot retourne alors CORPROF_E_STACKSNAPSHOT_ABORTED.

Appels synchrones et asynchrones

Vous pouvez appeler DoStackSnapshot de deux manières , de manière synchrone et asynchrone. Un appel synchrone est le plus facile à obtenir. Vous effectuez un appel synchrone lorsque le CLR appelle l’une des méthodes ICorProfilerCallback(2) de votre profileur et, en réponse, vous appelez DoStackSnapshot pour parcourir la pile du thread actuel. Cela est utile lorsque vous souhaitez voir à quoi ressemble la pile à un point de notification intéressant comme ObjectAllocated. Pour effectuer un appel synchrone, vous appelez DoStackSnapshot à partir de votre méthode ICorProfilerCallback(2), en passant zéro ou null pour les paramètres dont je ne vous ai pas parlé.

Une procédure de pile asynchrone se produit lorsque vous parcourez la pile d’un thread différent ou interrompez de force un thread pour effectuer une marche de pile (sur lui-même ou sur un autre thread). L’interruption d’un thread implique de détourner le pointeur d’instruction du thread pour le forcer à exécuter votre propre code à des moments arbitraires. C’est extrêmement dangereux pour trop de raisons à énumérer ici. S’il vous plaît, ne le faites pas. Je limiterai ma description des marches de pile asynchrones aux utilisations sans détournement de DoStackSnapshot pour parcourir un thread cible distinct. J’appelle cela « asynchrone », car le thread cible s’exécutait à un point arbitraire au moment où la marche de la pile commence. Cette technique est couramment utilisée par les profileurs d’échantillonnage.

Marcher partout sur quelqu’un d’autre

Nous allons décomposer un peu le parcours de pile croisé, c’est-à-dire le parcours asynchrone. Vous avez deux threads : le thread actuel et le thread cible. Le thread actuel est le thread qui exécute DoStackSnapshot. Le thread cible est le thread dont la pile est parcourue par DoStackSnapshot. Vous spécifiez le thread cible en passant son ID de thread dans le paramètre thread à DoStackSnapshot. Ce qui se passe ensuite n’est pas pour le faible de cœur. N’oubliez pas que le thread cible exécutait du code arbitraire lorsque vous avez demandé à parcourir sa pile. Ainsi, le CLR suspend le thread cible et reste suspendu pendant toute la durée de son exécution. Peut-on le faire en toute sécurité ?

Je suis content que vous l’ayez demandé. C’est effectivement dangereux, et je vous parlerai plus tard de la façon de le faire en toute sécurité. Mais d’abord, je vais entrer dans les piles en mode mixte.

Mélangez-le

Une application managée n’est pas susceptible de passer tout son temps dans le code managé. Les appels PInvoke et l’interopérabilité COM permettent au code managé d’appeler du code non managé, et parfois de revenir à nouveau avec des délégués. Et le code managé appelle directement dans le runtime non managé (CLR) pour effectuer une compilation JIT, gérer les exceptions, effectuer le garbage collection, etc. Ainsi, lorsque vous effectuez une marche sur la pile, vous rencontrerez probablement une pile en mode mixte : certaines images sont des fonctions managées, tandis que d’autres sont des fonctions non managées.

Grandissez, déjà !

Avant de continuer, un bref intermède. Tout le monde sait que les piles sur nos PC modernes augmentent (c’est-à-dire, « push ») à des adresses plus petites. Mais lorsque nous visualisons ces adresses dans notre esprit ou sur des tableaux blancs, nous ne sommes pas d’accord avec la façon de les trier verticalement. Certains d’entre nous imaginent la pile grandir (petites adresses sur le dessus) ; certains le voient croître vers le bas (petites adresses en bas). Nous sommes également divisés sur ce problème au sein de notre équipe. Je choisis de me mettre du côté de n’importe quel débogueur que j’ai déjà utilisé : les traces de pile d’appels et les vidages de mémoire me disent que les petites adresses sont « au-dessus » des grandes adresses. Ainsi, les piles grandissent; main est en bas, la feuille appelée est en haut. Si vous n’êtes pas d’accord, vous devrez effectuer une réorganisation mentale pour passer à travers cette partie de l’article.

Serveur, il y a des trous dans ma pile

Maintenant que nous parlons la même langue, examinons une pile en mode mixte. La figure 2 illustre un exemple de pile en mode mixte.

Figure 2 : Une pile avec des images managées et non managées

En reculant un peu, il est utile de comprendre pourquoi DoStackSnapshot existe en premier lieu. Il est là pour vous aider à parcourir les frames managés sur la pile. Si vous essayiez de parcourir vous-même les frames managés, vous obtiendriez des résultats peu fiables, en particulier sur les systèmes 32 bits, en raison de certaines conventions d’appel loufoques utilisées dans le code managé. Le CLR comprend ces conventions d’appel, et DoStackSnapshot peut donc vous aider à les décoder. Toutefois, DoStackSnapshot n’est pas une solution complète si vous souhaitez pouvoir parcourir la pile entière, y compris les images non managées.

Voici où vous avez le choix :

Option 1 : Ne rien faire et signaler des piles avec des « trous non managés » à vos utilisateurs, ou ...

Option 2 : Écrivez votre propre marcheur de pile non managé pour combler ces trous.

Lorsque DoStackSnapshot rencontre un bloc d’images non managées, il appelle votre fonction StackSnapshotCallback avec funcId défini sur 0, comme je l’ai mentionné précédemment. Si vous utilisez l’option 1, il vous suffit de ne rien faire dans votre rappel lorsque le funcId est 0. Le CLR vous appelle à nouveau pour le cadre managé suivant et vous pouvez vous réveiller à ce stade.

Si le bloc non managé se compose de plusieurs trames non managées, le CLR appelle toujours StackSnapshotCallback une seule fois. N’oubliez pas que le CLR ne fait aucun effort pour décoder le bloc non managé : il dispose d’informations internes spéciales qui l’aident à passer du bloc à la trame managée suivante, et c’est ainsi qu’il progresse. Le CLR ne sait pas nécessairement ce qu’il y a à l’intérieur du bloc non managé. C’est à vous de comprendre, d’où l’option 2.

Cette première étape est une doozy

Quelle que soit l’option choisie, le remplissage des trous non managés n’est pas la seule partie difficile. Le début de la marche peut être un défi. Jetez un coup d’œil à la pile ci-dessus. Il y a du code non managé en haut. Parfois, vous aurez de la chance, et le code non managé sera du code COM ou PInvoke . Si c’est le cas, le CLR est suffisamment intelligent pour savoir comment l’ignorer et commence la marche à la première image managée (D dans l’exemple). Toutefois, vous pouvez toujours parcourir le bloc non managé le plus haut afin de signaler une pile aussi complète que possible.

Même si vous ne souhaitez pas parcourir le bloc le plus haut, vous serez peut-être obligé de le faire, si vous n’êtes pas chanceux, ce code non managé n’est pas du code COM ou PInvoke , mais du code d’assistance dans le CLR lui-même, comme le code pour effectuer la compilation JIT ou le garbage collection. Si tel est le cas, le CLR ne pourra pas trouver le cadre D sans votre aide. Par conséquent, un appel non transféré à DoStackSnapshot génère l’erreur CORPROF_E_STACKSNAPSHOT_UNMANAGED_CTX ou CORPROF_E_STACKSNAPSHOT_UNSAFE. (Au fait, il est vraiment utile de visiter corerror.h.)

Notez que j’ai utilisé le mot « unseeded ». DoStackSnapshot prend un contexte de départ à l’aide des paramètres context et contextSize . Le mot « contexte » est surchargé de nombreuses significations. Dans ce cas, je parle d’un contexte de registre. Si vous utilisez les en-têtes windows dépendants de l’architecture (par exemple, nti386.h), vous trouverez une structure nommée CONTEXT. Il contient des valeurs pour les registres d’UC et représente l’état de l’UC à un moment donné dans le temps. C’est le genre de contexte dont je parle.

Si vous passez la valeur Null pour le paramètre de contexte , la procédure de pile est non chargée et le CLR commence en haut. Toutefois, si vous transmettez une valeur non null pour le paramètre de contexte , qui représente l’état du processeur à un endroit plus bas dans la pile (par exemple, pointant vers le frame D), le CLR effectue une promenade de pile amorcée avec votre contexte. Il ignore le haut réel de la pile et démarre où que vous le pointiez.

Ok, pas tout à fait vrai. Le contexte que vous passez à DoStackSnapshot est plus une indication qu’une directive pure et simple. Si le CLR est certain qu’il peut trouver le premier frame managé (parce que le bloc non managé le plus haut est le code PInvoke ou COM), il le fait et ignore votre valeur initiale. Mais ne le prenez pas personnellement. Le CLR essaie de vous aider en fournissant la marche de pile la plus précise possible. Votre valeur initiale n’est utile que si le bloc non managé le plus haut est le code d’assistance dans le CLR lui-même, car nous n’avons aucune information pour nous aider à l’ignorer. Par conséquent, votre valeur initiale est utilisée uniquement lorsque le CLR ne peut pas déterminer par lui-même où commencer la marche.

Vous pouvez vous demander comment vous pouvez nous fournir la graine en premier lieu. Si le thread cible n’est pas encore suspendu, vous ne pouvez pas simplement parcourir la pile du thread cible pour rechercher le frame D et ainsi calculer votre contexte de départ. Et pourtant, je vous dis de calculer votre contexte de départ en effectuant votre marche non managée avant d’appeler DoStackSnapshot et donc avant que DoStackSnapshot se charge de suspendre le thread cible pour vous. Le thread cible doit-il être suspendu par vous et par le CLR ? En fait, oui.

Je pense qu’il est temps de chorégraphier ce ballet. Mais avant d’aller trop loin, notez que la question de savoir si et comment amorcer une marche de pile s’applique uniquement aux promenades asynchrones . Si vous effectuez une marche synchrone, DoStackSnapshot sera toujours en mesure de trouver son chemin jusqu’au cadre le plus managé sans votre aide , aucune valeur initiale n’est nécessaire.

All Together Now

Pour le profileur vraiment aventureux qui effectue une marche asynchrone, croisée et ensemencement de pile tout en remplissant les trous non managés, voici à quoi ressemblerait une promenade de pile. Supposons que la pile illustrée ici soit la même que celle que vous avez vue dans la figure 2, juste décomposée un peu.

Contenu de la pile Actions du profileur et du CLR

1. Vous suspendez le thread cible. (Le nombre de suspensions du thread cible est maintenant de 1.)

2. Vous obtenez le contexte de registre actuel du thread cible.

3. Vous déterminez si le contexte de registre pointe vers du code non managé. Autrement dit, vous appelez ICorProfilerInfo2::GetFunctionFromIP et case activée si vous récupérez une valeur FunctionID de 0.

4. Étant donné que dans cet exemple, le contexte de registre pointe vers du code non managé, vous effectuez une promenade de pile non managée jusqu’à ce que vous trouviez la trame managée la plus haute (fonction D).

5. Vous appelez DoStackSnapshot avec votre contexte initial, et le CLR suspend à nouveau le thread cible. (Son nombre de suspensions est maintenant de 2.) Le sandwich commence.
a. Le CLR appelle votre fonction StackSnapshotCallback avec l’ID de fonction pour D.
b. Le CLR appelle votre fonction StackSnapshotCallback avec FunctionID égal à 0. Vous devez marcher ce bloc vous-même. Vous pouvez vous arrêter lorsque vous atteignez la première image managée. Vous pouvez également tricher et retarder votre marche non managée jusqu’à un certain temps après votre prochain rappel, car le prochain rappel vous indiquera exactement où commence le prochain frame managé et, par conséquent, où votre marche non managée doit se terminer.
c. Le CLR appelle votre fonction StackSnapshotCallback avec l’ID de fonction pour C.
d. Le CLR appelle votre fonction StackSnapshotCallback avec l’ID de fonction pour B.
e. Le CLR appelle votre fonction StackSnapshotCallback avec FunctionID égal à 0. Encore une fois, vous devez marcher ce bloc vous-même.
f. Le CLR appelle votre fonction StackSnapshotCallback avec l’ID de fonction pour A.
g. Le CLR appelle votre fonction StackSnapshotCallback avec l’ID de fonction pour Main.

h. Dostacksnapshot « reprend » le thread cible en appelant l’API Win32 ResumeThread(), qui décrémente le nombre de suspensions du thread (son nombre de suspensions est désormais 1) et retourne. Le sandwich est complet.
6. Vous reprenez le thread cible. Son nombre de suspensions est maintenant de 0, de sorte que le thread reprend physiquement.

Soyez sur votre meilleur comportement

Ok, c’est beaucoup trop de puissance sans une certaine prudence sérieuse. Dans le cas le plus avancé, vous répondez aux interruptions du minuteur et suspendez arbitrairement les threads d’application pour parcourir leurs piles. Aïe!

Être bon est difficile et implique des règles qui ne sont pas évidentes au début. Alors nous allons nous plonger.

La mauvaise graine

Commençons par une règle simple : n’utilisez pas une mauvaise graine. Si votre profileur fournit une valeur initiale non valide (non null) lorsque vous appelez DoStackSnapshot, le CLR vous donnera de mauvais résultats. Il examine la pile où vous la pointez et fait des hypothèses sur ce que les valeurs de la pile sont censées représenter. Cela entraîne la déréférencement du CLR qui est supposé être des adresses sur la pile. Si une valeur initiale est incorrecte, le CLR déréférence des valeurs dans un endroit inconnu en mémoire. Le CLR fait tout ce qui est en son pouvoir pour éviter une av de seconde chance, qui déchirait le processus que vous effectuez le profilage. Mais vous devriez vraiment faire un effort pour obtenir votre graine droit.

Malheurs de la suspension

D’autres aspects de la suspension des threads sont suffisamment complexes pour qu’ils nécessitent plusieurs règles. Lorsque vous décidez d’effectuer une marche croisée, vous avez décidé au minimum de demander au CLR de suspendre les threads en votre nom. De plus, si vous voulez parcourir le bloc non managé en haut de la pile, vous avez décidé de suspendre les threads par vous-même sans invoquer la sagesse du CLR sur la question de savoir si c’est une bonne idée pour le moment.

Si vous avez suivi des cours d’informatique, vous vous souvenez probablement du problème des « philosophes de la restauration ». Un groupe de philosophes est assis à une table, chacun avec une fourche à droite et une à gauche. Selon le problème, ils ont chacun besoin de deux fourchettes à manger. Chaque philosophe prend sa fourche droite, mais alors personne ne peut prendre sa fourche gauche parce que chaque philosophe attend que le philosophe à sa gauche dépose la fourche nécessaire. Et si les philosophes sont assis à une table circulaire, vous avez un cycle d’attente et beaucoup d’estomacs vides. La raison pour laquelle ils sont tous affamés est qu’ils en brisent une règle simple d’évitement des interblocages: si vous avez besoin de plusieurs verrous, prenez-les toujours dans le même ordre. Suivre cette règle éviterait le cycle où A attend sur B, B attend sur C et C attend sur A.

Supposons qu’une application suit la règle et accepte toujours des verrous dans le même ordre. Maintenant, un composant arrive (votre profileur, par exemple) et commence arbitrairement à suspendre les threads. La complexité a considérablement augmenté. Que se passe-t-il si le suspendeur doit maintenant prendre un verrou tenu par le suspendu ? Ou que se passe-t-il si le suspendeur a besoin d’un verrou tenu par un thread qui attend un verrou tenu par un autre thread qui attend un verrou tenu par le suspendu ? La suspension ajoute un nouveau bord à notre thread-graphe des dépendances, ce qui peut introduire des cycles. Jetons un coup d’œil à certains problèmes spécifiques.

Problème 1 : le suspendu possède les verrous nécessaires au suspendeur ou aux threads dont dépend le suspendeur.

Problème 1a : Les verrous sont des verrous CLR.

Comme vous pouvez l’imaginer, le CLR effectue beaucoup de synchronisation de threads et a donc plusieurs verrous qui sont utilisés en interne. Lorsque vous appelez DoStackSnapshot, le CLR détecte que le thread cible possède un verrou CLR dont le thread actuel (le thread appelant DoStackSnapshot) a besoin pour effectuer la procédure de pile. Lorsque cette condition se produit, le CLR refuse d’effectuer la suspension et DoStackSnapshot retourne immédiatement avec l’erreur CORPROF_E_STACKSNAPSHOT_UNSAFE. À ce stade, si vous avez suspendu le thread vous-même avant votre appel à DoStackSnapshot, vous reprendrez le thread vous-même et vous avez évité un problème.

Problème 1b : les verrous sont vos propres verrous de profileur.

Ce problème est vraiment plus une question de bon sens. Vous avez peut-être votre propre synchronisation de threads à effectuer ici et là. Imaginez qu’un thread d’application (thread A) rencontre un rappel du profileur et exécute une partie du code de votre profileur qui prend l’un des verrous du profileur. Ensuite, le thread B doit parcourir le thread A, ce qui signifie que le thread B suspend le thread A. Vous devez vous rappeler que pendant que le thread A est suspendu, vous ne devriez pas avoir le thread B à essayer de prendre les propres verrous du profileur que le thread A pourrait posséder. Par exemple, le thread B exécute StackSnapshotCallback pendant la procédure de pile. Vous ne devez donc pas prendre de verrous pendant ce rappel qui pourraient appartenir au thread A.

Problème 2 : Pendant que vous suspendez le thread cible, le thread cible tente de vous interrompre.

Vous pourriez dire : « Ça ne peut pas arriver ! » Croyez-le ou non, il peut, si :

  • Votre application s’exécute sur une zone multiprocesseur, et
  • Le thread A s’exécute sur un processeur et le thread B sur un autre, et
  • Le thread A tente de suspendre le thread B tandis que le thread B tente de suspendre le thread A.

Dans ce cas, il est possible que les deux suspensions gagnent et que les deux threads finissent suspendus. Étant donné que chaque thread attend que l’autre le réveille, il reste suspendu pour toujours.

Ce problème est plus déconcertant que le problème 1, car vous ne pouvez pas vous appuyer sur le CLR pour détecter avant d’appeler DoStackSnapshot que les threads vont s’interrompre mutuellement. Et une fois que vous avez effectué la suspension, il est trop tard!

Pourquoi le thread cible tente-t-il de suspendre le profileur ? Dans un profileur hypothétique et mal écrit, le code stack-walking, ainsi que le code de suspension, peuvent être exécutés par un nombre quelconque de threads à des moments arbitraires. Imaginez que le thread A tente de parcourir le thread B en même temps que le thread B tente de parcourir le thread A. Ils essaient tous deux de s’interrompre l’un l’autre simultanément, car ils exécutent tous les deux la partie SuspendThread de la routine stack-walking du profileur. Les deux gagnent et l’application en cours de profilage est bloquée. La règle ici est évidente : n’autorisez pas votre profileur à exécuter simultanément du code stack-walking (et donc du code de suspension) sur deux threads !

Une raison moins évidente pour laquelle le thread cible peut essayer de suspendre votre thread de marche est due au fonctionnement interne du CLR. Le CLR suspend les threads d’application pour faciliter les tâches telles que le garbage collection. Si votre marcheur tente de marcher (et donc de suspendre) le thread effectuant le garbage collection en même temps que le thread de récupérateur de mémoire tente de suspendre votre marcheur, les processus sont bloqués.

Mais il est facile d’éviter le problème. Le CLR suspend uniquement les threads qu’il doit suspendre pour effectuer son travail. Imaginez que deux threads sont impliqués dans votre parcours de pile. Le thread W est le thread actuel (le thread effectuant la marche). Le thread T est le thread cible (le thread dont la pile est parcourue). Tant que thread W n’a jamais exécuté de code managé et n’est donc pas soumis au garbage collection CLR, le CLR n’essaie jamais de suspendre le thread W. Cela signifie qu’il est sûr que le thread W suspend le thread T pour votre profileur.

Si vous écrivez un profileur d’échantillonnage, il est tout à fait naturel de garantir tout cela. Vous disposez généralement d’un thread distinct de votre propre création qui répond aux interruptions du minuteur et qui parcourt les piles d’autres threads. Appelez ce thread de l’échantillonneur. Étant donné que vous créez le thread de l’échantillonneur vous-même et que vous contrôlez ce qu’il exécute (et qu’il n’exécute donc jamais de code managé), le CLR n’a aucune raison de le suspendre. La conception de votre profileur afin qu’il crée son propre thread d’échantillonnage pour effectuer toute la marche sur la pile évite également le problème du « profileur mal écrit » décrit précédemment. Le thread de l’échantillonneur étant le seul thread de votre profileur qui tente de marcher ou de suspendre d’autres threads, votre profileur n’essaiera donc jamais de suspendre directement le thread de l’échantillonneur.

Il s’agit de notre première règle nontriviale, donc pour insister, permettez-moi de le répéter:

Règle 1 : Seul un thread qui n’a jamais exécuté de code managé doit suspendre un autre thread.

Personne n’aime marcher un cadavre

Si vous effectuez une procédure de pile de threads croisés, vous devez vous assurer que votre thread cible reste actif pendant toute la durée de la marche. Le simple fait que vous transmettiez le thread cible en tant que paramètre à l’appel DoStackSnapshot ne signifie pas que vous y avez implicitement ajouté un type de référence de durée de vie. L’application peut faire disparaître le thread à tout moment. Si cela se produit pendant que vous essayez de parcourir le thread, vous pouvez facilement provoquer une violation d’accès.

Heureusement, le CLR avertit les profileurs lorsqu’un thread est sur le point d’être détruit, à l’aide du rappel ThreadDestroyed bien nommé défini avec l’interface ICorProfilerCallback(2). Il est de votre responsabilité d’implémenter ThreadDestroyed et de le faire attendre jusqu’à ce que tout processus qui marche sur ce thread soit terminé. C’est assez intéressant pour être considéré comme notre règle suivante :

Règle 2 : Remplacez le rappel ThreadDestroyed et faites attendre votre implémentation jusqu’à ce que vous ayez terminé de parcourir la pile du thread à détruire.

La règle 2 empêche le CLR de détruire le thread jusqu’à ce que vous ayez terminé de parcourir la pile de ce thread.

Le garbage collection vous aide à créer un cycle

Les choses peuvent devenir un peu confuses à ce stade. Commençons par le texte de la règle suivante, et décryptons-le à partir de là :

Règle 3 : Ne tenez pas de verrou pendant un appel du profileur qui peut déclencher le garbage collection.

J’ai mentionné précédemment qu’il est une mauvaise idée pour votre profileur d’en conserver un si ses propres verrous si le thread propriétaire peut être suspendu, et si le thread peut être parcouru par un autre thread qui a besoin du même verrou. La règle 3 vous aide à éviter un problème plus subtil. Ici, je dis que vous ne devriez contenir aucun de vos propres verrous si le thread propriétaire est sur le point d’appeler une méthode ICorProfilerInfo(2) qui pourrait déclencher un garbage collection.

Quelques exemples devraient vous aider. Pour le premier exemple, supposons que le thread B effectue le garbage collection. La séquence est la suivante :

  1. Le thread A prend et possède désormais l’un de vos verrous de profileur.
  2. Le thread B appelle le rappel GarbageCollectionStarted du profileur.
  3. Le thread B bloque le verrou du profileur à l’étape 1.
  4. Le thread A exécute la fonction GetClassFromTokenAndTypeArgs .
  5. L’appel GetClassFromTokenAndTypeArgs tente de déclencher un garbage collection, mais détecte qu’un garbage collection est déjà en cours.
  6. Le thread A se bloque, en attendant que le garbage collection en cours (thread B) se termine. Toutefois, le thread B attend le thread A en raison du verrou de votre profileur.

La figure 3 illustre le scénario dans cet exemple :

Figure 3. Interblocage entre le profileur et le garbage collector

Le deuxième exemple est un scénario légèrement différent. La séquence est la suivante :

  1. Le thread A prend et possède désormais l’un de vos verrous de profileur.
  2. Le thread B appelle le rappel ModuleLoadStarted du profileur.
  3. Le thread B bloque le verrou du profileur à l’étape 1.
  4. Le thread A exécute la fonction GetClassFromTokenAndTypeArgs .
  5. L’appel GetClassFromTokenAndTypeArgs déclenche un garbage collection.
  6. Le thread A (qui effectue maintenant le garbage collection) attend que le thread B soit prêt à être collecté. Mais le thread B attend le thread A en raison du verrou de votre profileur.
  7. La figure 4 illustre le deuxième exemple.

Figure 4. Interblocage entre le profileur et un garbage collection en attente

Avez-vous digéré la folie ? Le nœud du problème est que le garbage collection a ses propres mécanismes de synchronisation. Le résultat du premier exemple se produit, car un seul garbage collection peut se produire à la fois. Il s’agit certes d’un cas marginal, car les collectes de déchets ne se produisent généralement pas si souvent que l’un d’eux doit attendre l’autre, sauf si vous travaillez dans des conditions stressantes. Malgré cela, si vous effectuez un profil suffisamment long, ce scénario se produira et vous devez y être préparé.

Le résultat dans le deuxième exemple se produit, car le thread effectuant le garbage collection doit attendre que les autres threads d’application soient prêts pour la collecte. Le problème se pose lorsque vous introduisez l’un de vos propres verrous dans le mélange, formant ainsi un cycle. Dans les deux cas, la règle 3 est rompue en autorisant le thread A à posséder l’un des verrous du profileur, puis à appeler GetClassFromTokenAndTypeArgs. (En fait, l’appel d’une méthode susceptible de déclencher un garbage collection est suffisant pour faire échec au processus.)

Vous avez probablement plusieurs questions.

Q. Comment savoir quelles méthodes ICorProfilerInfo(2) peuvent déclencher un garbage collection ?

R. Nous prévoyons de documenter cela sur MSDN, ou du moins dans mon blog ou le blog de Jonathan Keljo.

Q. Qu’est-ce que cela a à voir avec la marche sur pile ? Il n’y a pas de mention de DoStackSnapshot.

R. Vrai. Et DoStackSnapshot n’est même pas l’une de ces méthodes ICorProfilerInfo(2) qui déclenchent un garbage collection. La raison pour laquelle je parle ici de la règle 3 est que ce sont précisément ces programmeurs aventureux marchant de façon asynchrone des piles à partir d’exemples arbitraires qui seront les plus susceptibles d’implémenter leurs propres verrous de profileur, et donc être enclins à tomber dans ce piège. En effet, la règle 2 vous indique essentiellement d’ajouter la synchronisation à votre profileur. Il est fort probable qu’un profileur d’échantillonnage aura également d’autres mécanismes de synchronisation, peut-être pour coordonner la lecture et l’écriture de structures de données partagées à des moments arbitraires. Bien sûr, il est toujours possible pour un profileur qui ne touche jamais DoStackSnapshot de rencontrer ce problème.

Assez c'est assez

Je vais terminer avec un résumé rapide des faits saillants. Voici les points importants à retenir :

  • Les étapes de pile synchrones impliquent de parcourir le thread actuel en réponse à un rappel du profileur. Celles-ci ne nécessitent pas d’amorçage, de suspension ou de règles spéciales.
  • Les étapes asynchrones nécessitent une valeur initiale si le haut de la pile est du code non managé et ne fait pas partie d’un appel PInvoke ou COM. Vous fournissez une graine en suspendant directement le thread cible et en le parcourant vous-même jusqu’à ce que vous trouviez le cadre le plus géré. Si vous ne fournissez pas de seed dans ce cas, DoStackSnapshot peut retourner un code d’échec ou ignorer certaines images en haut de la pile.
  • Si vous devez suspendre des threads, n’oubliez pas que seul un thread qui n’a jamais exécuté de code managé doit interrompre un autre thread.
  • Lorsque vous effectuez des marches asynchrones, remplacez toujours le rappel ThreadDestroyed pour empêcher le CLR de détruire un thread jusqu’à ce que la procédure de pile de ce thread soit terminée.
  • Ne maintenez pas un verrou pendant que votre profileur appelle une fonction CLR qui peut déclencher un nettoyage de la mémoire.

Pour plus d’informations sur l’API de profilage, consultez Profilage (non managé) sur le site Web MSDN.

Crédit où le crédit est dû

J’aimerais inclure une note de remerciement pour le reste de l’équipe de l’API de profilage CLR, car l’écriture de ces règles a vraiment été un effort d’équipe. Un merci spécial à Sean Selitrennikoff, qui a fourni une incarnation antérieure de la plupart de ce contenu.

 

À propos de l’auteur

David a été développeur chez Microsoft depuis plus longtemps que vous ne le pensez, étant donné ses connaissances limitées et sa maturité. Bien qu’il ne soit plus autorisé à case activée dans le code, il propose toujours des idées pour de nouveaux noms de variables. David est un fervent fan du comte Chocula et possède sa propre voiture.