Durchlaufen von Stapeln im .NET Framework 2.0 durch den Profiler: Grundlagen und mehr

Veröffentlicht: 30. Okt 2006
Von David Broman

In diesem Artikel wird beschrieben, wie Sie Ihren Profiler so programmieren können, dass sie verwaltete Stapel in der CLR (Common Language Runtime) des .NET Framework durchlaufen. (14 gedruckte Seiten)

Auf dieser Seite

Einführung Einführung
Synchrone und asynchrone Aufrufe Synchrone und asynchrone Aufrufe
Die Mischung macht's Die Mischung macht's
Zeigen Sie sich von Ihrer besten Seite Zeigen Sie sich von Ihrer besten Seite
Genug jetzt Genug jetzt
Ehre wem Ehre gebührt Ehre wem Ehre gebührt
Über den Autor Über den Autor

Einführung

Dieser Artikel richtet sich an all jene, die einen Profiler erstellen möchten, um verwaltete Anwendungen zu untersuchen. In diesem Artikel wird beschrieben, wie Sie Ihren Profiler so programmieren können, dass er verwaltete Stapel in der CLR (Common Language Runtime) des .NET Framework durchläuft. Ich will mir Mühe geben, den Text einfach zu halten, da das Thema streckenweise recht schwerfällig ist.

Die Profilerstellungs-API in Version 2.0 der CLR verfügt über eine neue Methode namens DoStackSnapshot, mit der Ihr Profiler die Aufrufliste der Anwendung durchlaufen kann, für die Sie ein Profil erstellen. Version 1.1 der CLR hat durch die Oberfläche zum Prozessdebuggen ähnliche Funktionen verfügbar gemacht. Doch das Durchlaufen der Aufrufliste mit DoStackSnapshot ist einfacher, genauer und stabiler. Die Methode DoStackSnapshot verwendet denselben Stapeldurchlauf wie der Garbage Collector, das Sicherheitssystem, Ausnahmesystem usw. Sie wissen also, dass das richtig sein muss.

Durch den Zugriff auf eine vollständige Stapelüberwachung haben Benutzer Ihres Profilers die Möglichkeit, sich einen Überblick darüber zu verschaffen, was in einer Anwendung vorgeht, wenn etwas Interessantes geschieht. In Abhängigkeit von der Anwendung des Zwecks, für den ein Benutzer ein Profil erstellen möchte, können Sie sich vorstellen, dass ein Benutzer eine Aufrufliste haben möchte, wenn ein Objekt zugeordnet, eine Klasse geladen, eine Ausnahme ausgelöst wird usw. Für einen Beispielprofiler wäre es auch interessant, eine Aufrufliste für etwas anderes als ein Anwendungsereignis zu erhalten, z. B. ein Zeitgeberereignis. Das Betrachten von Hotspots in Code wird aufschlussreicher, wenn Sie sehen können, wer die Funktion aufgerufen hat, die die Funktion aufgerufen hat, die wiederum die Funktion aufgerufen hat, die den Hotspot enthält.

Ich gehe schwerpunktmäßig darauf ein, Stapelüberwachungen mit der DoStackSnapshot-API abzurufen. Sie können Stapelüberwachungen auch durch das Erstellen von Schattenstapeln erhalten: Sie können FunctionEnter und FunctionLeave verknüpfen, um eine Kopie der verwalteten Aufrufliste für den aktuellen Thread zu behalten. Das Erstellen von Schattenstapeln ist nützlich, wenn Sie während der Ausführung der Anwendung jederzeit Stapelinformationen benötigen – und wenn es Ihnen nichts ausmacht, dass die Leistung leidet, wenn der Code Ihres Profilers bei jedem verwalteten Aufruf und jeder Rückgabe ausgeführt wird. Die Methode DoStackSnapshot eignet sich am besten, wenn Sie eine etwas kargere Berichterstattung für Stapel benötigen, etwa in Reaktion auf Ereignisse. Auch ein Beispielprofiler, der alle paar Millisekunden einen Schnappschuss vom Stapel anfertigt, ist viel karger als das Erstellen von Schattenstapeln. DoStackSnapshot eignet sich also sehr gut für Beispielprofiler.

„Take a Stackwalk on the Wild Side“

Es ist sehr nützlich, Stapel dann abrufen zu können, wenn Sie sie benötigen. Doch mit der Macht kommt auch die Verantwortung. Ein Benutzer des Profilers möchte sicher nicht, dass das Durchlaufen des Stapels zu einer Zugriffsverletzung (Access Violation, AV) oder zu einem Deadlock zur Laufzeit führt. Als Ersteller von Profilern müssen Sie Ihre Macht sorgsam ausüben. Im Folgenden wird die gewissenhafte Verwendung von DoStackSnapshot erklärt. Sie werden feststellen, je mehr Aufgaben Sie mit dieser Methode durchführen möchten, desto schwieriger ist es, das auch richtig hinzubekommen.

Sehen Sie sich den Fragenkomplex an. Hier wird angezeigt, was Ihr Profiler aufruft. Sie finden dies in Corprof.idl in der Benutzeroberfläche ICorProfilerInfo2:

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

Der folgende Code stellt dar, was die CLR in Ihrem Profiler aufruft. (Das finden Sie auch in der Corprof.idl.) Im callback-Parameter aus dem vorhergehenden Beispiel übergeben Sie dieser Funktion einen Zeiger auf Ihre Implementierung.

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

Das ist wie ein Sandwich. Wenn Ihr Profiler den Stapel durchlaufen soll, rufen Sie DoStackSnapshot auf. Bevor die CLR von diesem Aufruf zurückkehrt, ruft sie mehrmals Ihre StackSnapshotCallback-Funktion auf: einmal für jeden verwalteten Frame bzw. für jede Ausführung nicht verwalteter Frames im Stapel. In Abbildung 1 ist dieses Sandwich dargestellt.

Abbildung 1: Ein „Sandwich“ aus Aufrufen während der Profilerstellung
Abbildung 1: Ein „Sandwich“ aus Aufrufen während der Profilerstellung

Wie Sie aus den Anmerkungen erkennen können, benachrichtigt die CLR Sie zuerst über den zuletzt mithilfe von Push in den Stapel übertragenen Frame (das „Salatblatt“) und über den zuerst mithilfe von Push in den Stapel übertragenen Frame (den Hauptframe) zuletzt. Die Reihenfolge ist also umgekehrt.

Was bedeuten all die Parameter für diese Funktionen? An dieser Stelle werden zunächst nur einige der Parameter erläutert, den Anfang macht DoStackSnapshot. (Die anderen werden in Kürze behandelt.) Der Wert infoFlags entstammt der Enumeration COR_PRF_SNAPSHOT_INFO in Corprof.idl. Damit können Sie steuern, ob die CLR Ihnen Registerkontexte für die Frames gibt, die sie meldet. Für clientData können Sie einen beliebigen Wert angeben. Die CLR gibt Ihnen diesen Wert bei Ihrem StackSnapshotCallback-Aufruf zurück.

In StackSnapshotCallback übergibt die CLR Ihnen mithilfe des Parameters funcId den FunctionID-Wert des aktuell durchlaufenen Frames. Dieser Wert beträgt 0, wenn der aktuelle Frame aus einem Satz nicht verwalteter Frames besteht. Wenn funcId ungleich Null ist, können Sie funcId und frameInfo an andere Methoden wie GetFunctionInfo2 und GetCodeInfo2 übergeben, um weitere Informationen zu dieser Funktion zu erhalten. Sie können diese Funktionsinformationen sofort während des Stapeldurchlaufs abrufen oder die funcId-Werte speichern und die Funktionsinformationen später abrufen. Dadurch werden die Auswirkungen auf Ihre ausgeführte Anwendung verringert. Wenn Sie die Funktionsinformationen später abrufen, bedenken Sie, dass ein frameInfo-Wert nur innerhalb des Rückrufs gilt, den er Ihnen gibt. Sie können die funcId-Werte zur späteren Verwendung speichern, aber nicht die frameInfo-Werte.

Wenn Sie vom StackSnapshotCallback zurückkehren, geben Sie in der Regel S_OK zurück, und die CLR fährt fort, den Stapel zu durchlaufen. Wenn Sie möchten, können Sie S_FALSE zurückgeben, wodurch der Stapeldurchlauf beendet wird. Ihr Aufruf DoStackSnapshot gibt dann CORPROF_E_STACKSNAPSHOT_ABORTED zurück.

Synchrone und asynchrone Aufrufe

Es gibt zwei Möglichkeiten, DoStackSnapshot aufzurufen: synchron und asynchron. Ein synchroner Aufruf lässt sich am einfachsten verwirklichen. Einen synchronen Aufruf nehmen Sie dann vor, wenn die CLR eine der ICorProfilerCallback(2)-Methoden Ihres Profilers aufruft. In Reaktion darauf rufen Sie DoStackSnapshot auf, um den Stapel des aktuellen Threads zu durchlaufen. Das ist nützlich, wenn Sie sich den Stapel an einem interessanten Benachrichtigungspunkt wie ObjectAllocated ansehen möchten. Um einen synchronen Aufruf durchzuführen, rufen Sie DoStackSnapshot aus Ihrer ICorProfilerCallback(2)-Methode auf und übergeben Null oder null an die nicht erwähnten Parameter.

Ein asynchroner Stapeldurchlauf tritt dann auf, wenn Sie den Stapel eines anderen Threads durchlaufen oder die Unterbrechung eines Threads erzwingen, um einen Stapeldurchlauf durchzuführen, entweder bei sich selbst oder in einem anderen Thread. Wenn Sie einen Thread unterbrechen, müssen Sie den Anweisungszeiger des Threads angreifen, um ihn dazu zu zwingen, Ihren eigenen Code zu willkürlich gewählten Zeitpunkten auszuführen. Dies ist aus sehr vielen Gründen, die nicht alle hier aufgeführt werden können, ausgesprochen gefährlich. Bitte, lassen Sie es einfach sein. Ich beschränke meine Beschreibung asynchroner Stapeldurchläufe auf die normale, angriffslose Verwendung von DoStackSnapshot, um einen separaten Zielthread zu durchlaufen. Dies wird als „asynchron“ bezeichnet, da sich der Zielthread zu dem Zeitpunkt, an dem der Stapeldurchlauf beginnt, an einem beliebigen Punkt seiner Ausführung befindet. Diese Technik wird in der Regel von Beispielprofilern verwendet.

Asynchrone Stapeldurchläufe

In diesem Abschnitt wird der threadübergreifende – also der asynchrone – Stapeldurchlauf ein wenig aufgegliedert. Sie haben zwei Threads: den aktuellen Thread und den Zielthread. Der aktuelle Thread ist derjenige, der DoStackSnapshot ausführt. Der Zielthread ist derjenige, dessen Stapel von DoStackSnapshot durchlaufen wird. Sie geben den Zielthread an, indem Sie dessen Thread-ID im thread-Parameter an DoStackSnapshot weitergeben. Was als Nächstes passiert, ist nichts für schwache Nerven. Wie Sie sich erinnern, führte der Zielthread willkürlichen Code aus, als Sie darum baten, seinen Stapel durchlaufen zu dürfen. Die CLR hält den Zielthread also an, der während des Durchlaufens auch die ganze Zeit angehalten bleibt. Ist das sicher?

Schön, dass Sie fragen. Tatsächlich ist es gefährlich, und später wird erläutert, wie Sie dies sicher durchführen können. Doch zunächst werden Stapel im gemischten Modus behandelt.

Die Mischung macht's

Es ist unwahrscheinlich, dass eine verwaltete Anwendung ihre gesamte Zeit in verwaltetem Code zubringt. Mit PInvoke-Aufrufen und COM-Interop können Sie es verwaltetem Code ermöglichen, nicht verwalteten Code aufzurufen, und mithilfe von Delegaten manchmal auch wieder anders herum. Verwalteter Code ruft die nicht verwaltete Laufzeit (CLR) auf, um eine JIT-Kompilierung durchzuführen, Ausnahmen zu verarbeiten, Garbage Collection durchzuführen usw. Wenn Sie also einen Stapeldurchlauf durchführen, treffen Sie wahrscheinlich auf einen Stapel im gemischten Modus, bei dem einige Frames verwaltete Funktionen und andere nicht verwaltete Funktionen darstellen.

Wachstum nach oben

Bevor es weitergeht, ein kurzes Zwischenspiel. Es ist allgemein bekannt, dass die Stapel in modernen PCs zu kleineren Adressen anwachsen, d. h., sich dorthin „verschieben“. Doch wenn Sie sich diese Adressen in Gedanken oder auf dem Whiteboard bildlich vorstellen, können Sie sich bezüglich der vertikalen Sortierung nicht einig werden. Einige stellen sich vor, dass der Stapel nach oben wächst, (kleinere Adressen oben); andere meinen, dass er nach unten wächst (kleinere Adressen unten). In unserem Team sind wir diesbezüglich ebenfalls geteilter Meinung. Ich schlage mich auf die Seite aller Debugger, die ich jemals verwendet habe. Überwachungen von Aufruflisten und Speicherabbilder sagen mir, dass die kleinen Adressen sich „oberhalb“ der großen Adressen befinden. Also wachsen die Stapel nach oben. Der Hauptframe befindet sich unten, der zuletzt Aufgerufene befindet sich oben. Wenn Sie anderer Meinung sind, müssen Sie Ihre Gedanken ein wenig umstellen, um diesen Teil des Artikels zu verstehen.

Ober, da sind Löcher in meinem Stapel!

Nach dieser Begriffsklärung sehen Sie sich einen Stapel im gemischten Modus an. In Abbildung 2 ist ein Beispiel eines gemischten Stapels dargestellt.

Abbildung 2: Ein Stapel mit verwalteten und nicht verwalteten Frames
Abbildung 2: Ein Stapel mit verwalteten und nicht verwalteten Frames

Treten Sie einen Schritt zurück; es lohnt sich zu verstehen, warum DoStackSnapshot überhaupt existiert. Damit können Sie verwaltete Frames im Stapel durchlaufen. Wenn Sie versuchten, verwaltete Frames selbst zu durchlaufen, bekämen Sie unzuverlässige Ergebnisse. Dies gilt insbesondere für 32-Bit-Systeme; das hängt mit einigen schrulligen Aufrufkonventionen zusammen, die in verwaltetem Code verwendet werden. Die CLR versteht diese Aufrufkonventionen, und deshalb kann DoStackSnapshot Ihnen bei der Decodierung helfen. DoStackSnapshot ist jedoch keine vollständige Lösung, wenn Sie in der Lage sein möchten, den gesamten Stapel zu durchlaufen, einschließlich der nicht verwalteten Frames.

An dieser Stelle haben Sie eine Wahl:

Option 1: Tun Sie nichts, und melden Sie Ihren Benutzern Stapel mit „nicht verwalteten Löchern“, oder ...

Option 2: Schreiben Sie Ihren eigenen, nicht verwalteten Stapeldurchlauf, um diese Löcher auszufüllen.

Wenn DoStackSnapshot bei einem Block nicht verwalteter Frames ankommt, wird Ihre Funktion StackSnapshotCallback aufgerufen, bei der funcId wie bereits erwähnt auf 0 gesetzt ist. Wenn Sie Option 1 wählen, tun Sie einfach nichts in Ihrem Rückruf, wenn funcId gleich 0 ist. Die CLR ruft Sie beim nächsten verwalteten Frame erneut auf, dann können Sie wieder aktiv werden.

Wenn der nicht verwaltete Block aus mehr als einem nicht verwalteten Frame besteht, ruft die CLR StackSnapshotCallback dennoch nur einmal auf. Denken Sie daran, die CLR unternimmt keine Anstrengungen, den nicht verwalteten Block zu decodieren. Sie verfügt über besondere Insiderinformationen, mit denen sie den Block überspringen und zum nächsten verwalteten Frame gelangen kann. So fährt sie fort. Die CLR weiß nicht unbedingt, was sich in dem nicht verwalteten Block befindet. Das müssen Sie herausfinden, dafür gibt es Option 2.

Dieser erste Schritt ist der Hammer

Gleichgültig, welche Option Sie wählen – das Ausfüllen der nicht verwalteten Löcher ist nicht der einzige schwierige Teil. Schon das Starten des Durchlaufs kann eine Herausforderung darstellen. Sehen Sie sich den Stapel oben an. An der Spitze befindet sich nicht verwalteter Code. Manchmal werden Sie Glück haben, und bei dem nicht verwalteten Code handelt es sich um COM- oder PInvoke-Code. Wenn dem so ist, ist die CLR durchaus in der Lage, ihn zu überspringen. Der Durchlauf beginnt dann am ersten verwalteten Frame, dies ist im Beispiel D. Unter Umständen möchten Sie dennoch den obersten, nicht verwalteten Block durchlaufen, um einen möglichst vollständigen Stapel zu melden.

Selbst wenn Sie den obersten Block nicht durchlaufen möchten, sind Sie u. U. dazu gezwungen. Falls Sie nämlich kein Glück haben, handelt es sich bei dem nicht verwalteten Code nicht um COM- oder PInvoke-Code, sondern um Hilfscode in der CLR selbst, z. B. Code für die JIT-Kompilierung oder Garbage Collection. Wenn das der Fall ist, kann die CLR Frame D nur mit Ihrer Hilfe finden. Ein unangelegter Aufruf an DoStackSnapshot führt also zu dem Fehler CORPROF_E_STACKSNAPSHOT_UNMANAGED_CTX oder CORPROF_E_STACKSNAPSHOT_UNSAFE. (Übrigens, es lohnt sich wirklich, corerror.h zu besuchen.)

Beachten Sie die Verwendung des Wortes „unangelegt“. DoStackSnapshot benötigt einen Ausgangskontext, in dem die Parameter context und contextSize verwendet werden. Das Wort „Kontext“ ist mit vielen unterschiedlichen Bedeutungen überfrachtet. In diesem Fall spreche ich von einem Registerkontext. Wenn Sie die architekturabhängigen Fensterkopfzeilen durchgehen, (z. B. nti386.h) finden Sie eine Struktur namens CONTEXT. Diese Struktur enthält Werte für die CPU-Register und stellt den Zustand der CPU zu einem bestimmten Zeitpunkt dar. Das ist die Art von Kontext, von dem hier die Rede ist.

Wenn Sie für den Parameter context den Wert null übergeben, ist der Stapeldurchlauf nicht angelegt, d. h. sie hat keinen Ausgangspunkt, und die CLR beginnt an der Spitze. Wenn Sie jedoch für den Parameter context einen Wert weitergeben, der nicht Null ist und der den Zustand der CPU an einem Punkt weiter unten in dem Stapel repräsentiert (z. B. indem er auf Frame D zeigt), führt die CLR einen Stapeldurchlauf mit Ihrem Kontext als Ausgangspunkt durch. Die CLR ignoriert also die Spitze des Stapels und beginnt dort, wo Sie hinzeigen.

Okay, das trifft nicht ganz zu. Der Kontext, den Sie an DoStackSnapshot übergeben, ist mehr ein Hinweis als eine regelrechte Anweisung. Wenn die CLR sicher ist, dass sie den ersten verwalteten Frame finden kann, da der oberste unverwaltete Block in PInvoke- oder COM-Code vorliegt, wird sie danach suchen und Ihren Ausgangswert ignorieren. Nehmen Sie das aber nicht persönlich. Die CLR versucht Ihnen zu helfen, indem sie einen möglichst akkuraten Stapeldurchlauf bereitstellt. Ihr Ausgangswert ist nur dann nützlich, wenn es sich bei dem obersten nicht verwalteten Block um Hilfscode in der CLR selbst handelt, denn es gibt keinerlei Informationen, die dabei helfen, den Code zu überspringen. Aus diesem Grund wird Ihr Ausgangswert nur dann verwendet, wenn die CLR nicht selbst ermitteln kann, wo sie mit dem Durchlaufen beginnen soll.

Möglicherweise fragen Sie sich, wie Sie den Ausgangswert überhaupt bereitstellen können. Wenn der Zielthread noch nicht angehalten ist, können Sie nicht einfach den Stapel des Zielthreads durchlaufen, um Frame D zu finden und damit den Kontext Ihres Ausgangswerts zu berechnen. Und dennoch weise ich Sie an, den Kontext Ihres Ausgangswerts zu berechnen, indem Sie Ihren nicht verwaltete Durchlauf durchführen, bevor Sie DoStackSnapshot aufrufen; also bevor DoStackSnapshot sich darum kümmert, den Zielthread für Sie anzuhalten. Muss der Zielthread von Ihnen und von der CLR angehalten werden? Das ist tatsächlich der Fall.

Es ist an der Zeit, dieses Ballett zu choreographieren. Doch bevor ich mich zu sehr in den Einzelheiten verliere, beachten Sie bitte, dass das Platzieren eines Stapeldurchlaufs nur für asynchrone Durchläufe gilt. Wenn Sie einen synchronen Durchlauf durchführen, kann DoStackSnapshot den Weg zum obersten verwalteten Frame immer ohne Hilfestellung finden, es ist kein Ausgangswert erforderlich.

Und jetzt alle zusammen

So sieht ein Stapeldurchlauf eines wirklich waghalsigen Profilers aus, der einen asynchronen, threadübergreifenden Stapeldurchlauf mit Ausgangswert durchführt und dabei die nicht verwalteten Löcher füllt. Angenommen, bei dem hier abgebildeten Stapel handelt es sich um denselben Stapel wie in Abbildung 2, nur ein wenig aufgegliedert.

Stapelinhalte

Aktionen von Profiler und CLR

Abbildung

1. Sie halten den Zielthread an. (Der Unterbrechungszähler
    des Zielthreads steht jetzt auf 1.)

2. Sie erhalten den aktuellen Registerkontext des Zielthreads.

3. Sie legen fest, ob der Registerkontext auf nicht verwalteten
    Code zeigt. Das heißt, Sie rufen
    ICorProfilerInfo2::GetFunctionFromIP
    auf und überprüfen, ob Sie einen FunctionID-Wert von 0
    zurückbekommen.

4. Da der Registerkontext in diesem Beispiel auf nicht
    verwalteten Code zeigt, führen Sie einen nicht verwalteten
    Stapeldurchlauf durch, bis Sie den obersten verwalteten
    Frame finden (Funktion D).

Abbildung

5. Sie rufen DoStackSnapshot mit Ihrem Ausgangskontext auf,
    und die CLR hält den Zielthread wieder an. (Dessen
    Unterbrechungszähler steht jetzt auf 2.) Das Sandwich
    beginnt.

     a. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit
         der FunctionIDfür D auf.

Abbildung

     b. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit
         der FunctionID gleich 0 auf. Diesen Block müssen Sie
         selbst durchlaufen. Sie können anhalten, sobald Sie den
         ersten verwalteten Frame erreichen. Alternativ können Sie
         auch mogeln und Ihren nicht verwalteten Durchlauf bis zu
         einem beliebigen Zeitpunkt nach Ihrem nächsten Rückruf
         verzögern, da Ihr nächster Rückruf Ihnen genau mitteilt,
         wo der nächste verwaltete Frame beginnt und wo deshalb
         Ihr nicht verwalteter Durchlauf enden sollte.

Abbildung

     c. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit
         der FunctionID für C auf.

Abbildung

     d. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit
         der FunctionID für B auf.

Abbildung

     e. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit
         der FunctionID gleich 0 auf. Diesen Block müssen Sie
         wieder selbst durchlaufen.

Abbildung

     f. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit
         der FunctionID für A auf.

Abbildung

     g. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit
         der FunctionID für den Hauptframe auf.

     h. DoStackSnapshot nimmt den Zielthread wieder auf,
         indem die Win32-API ResumeThread() aufgerufen wird,
         die den Unterbrechungszähler des Threads herabsetzt (der
         Zähler beträgt jetzt 1) und zurückkehrt. Das Sandwich ist
         fertig.


6. Sie nehmen den Zielthread wieder auf. Dessen
    Unterbrechungszähler liegt jetzt bei 0, sodass der Thread
    physisch wieder aufgenommen wird.

Zeigen Sie sich von Ihrer besten Seite

OK, das ist viel zu viel Macht, da muss die entsprechende Vorsicht walten. Im fortschrittlichsten Fall reagieren Sie auf Zeitgeberunterbrechungen und halten Anwendungsthreads willkürlich an, um deren Stapel zu durchlaufen. Pfui!

Gutes Benehmen ist schwer und erfordert einige Regeln, die zunächst nicht offensichtlich sind. Und los geht's.

Der schlechte Ausgangswert

Zunächst eine einfache Regel: verwenden Sie keine schlechten Ausgangswerte. Wenn Ihr Profiler beim Aufrufen von DoStackSnapshot einen ungültigen Ausgangswert (der nicht gleich Null ist) zurückgibt, gibt die CLR schlechte Ergebnisse zurück. Sie sieht sich den Stapel an der Stelle an, wo Sie hinzeigen, und stellt Vermutungen darüber an, was die Werte in dem Stapel wohl bedeuten mögen. Dadurch dereferenziert die CLR die Anteile in dem Stapel, bei denen davon ausgegangen wird, dass es sich um Adressen handelt. Bei einem schlechten Ausgangswert dereferenziert die CLR Werte an einen unbekannten Ort im Speicher. Die CLR unternimmt alles in ihrer Macht stehende, um eine uneingeschränkte zweite Zugriffsverletzung zu vermeiden, die den Prozess abbrechen würde, für den Sie das Profil erstellen. Doch Sie sollten sich wirklich anstrengen, Ihren Ausgangswert richtig hinzubekommen.

Die Leiden der Unterbrechung

Weitere Aspekte beim Anhalten von Threads sind so kompliziert, dass sie mehrere Regeln erfordern. Wenn Sie sich zum threadübergreifenden Durchlaufen entschließen, haben Sie sich mindestens dazu entschlossen, die CLR darum zu bitten, Threads in Ihrem Namen anzuhalten. Wenn Sie außerdem den nicht verwalteten Block an der Spitze des Stapels durchlaufen, haben Sie sich dazu entschlossen, Threads selbst anzuhalten, ohne die CLR in ihrer Weisheit zu befragen, ob das im Moment eine gute Idee ist.

Wenn Sie einen Kurs in Computerwissenschaften belegt haben, erinnern Sie sich bestimmt an das Problem der „dinierenden Philosophen“. Eine Gruppe von Philosophen sitzt bei Tisch, und jeder hat rechts und links von seinem Platz eine Gabel liegen. Dem Problem gemäß benötigt jeder zwei Gabeln, um essen zu können. Jeder Philosoph nimmt seine rechte Gabel in die Hand, doch keiner kann seine linke Gabel aufnehmen, da jeder Philosoph darauf wartet, dass sein linker Tischnachbar die benötigte Gabel niederlegt. Und wenn die Philosophen an einem runden Tisch sitzen, haben Sie einen Kreisprozess des Wartens und viele knurrende Mägen. Der Grund, warum sie alle hungern, ist, dass sie eine einfache Regel brechen, mit der ein Deadlock verhindert werden könnte: Wenn Sie mehrere Sperren benötigen, aktivieren Sie sie immer in derselben Reihenfolge. Wenn Sie diese Regel befolgen, wird der Kreisprozess vermieden, in dem A auf B wartet, B auf C und C auf A.

Angenommen, eine Anwendung befolgt die Regel und aktiviert Sperren immer in derselben Reihenfolge. Dann kommt eine Komponente daher (z. B. Ihr Profiler), die willkürlich Threads anhält. Die Komplexität nimmt dramatisch zu. Was geschieht, wenn der Anhaltende jetzt eine Sperre aktivieren muss, die der Angehaltene belegt? Oder was geschieht, wenn der Anhaltende eine Sperre benötigt, die von dem Thread belegt wird, der auf eine von einem anderen Thread belegte Sperre wartet, der wiederum auf eine Sperre wartet, die vom Angehaltenen belegt wird? Durch das Anhalten wird Ihrem Threadabhängigkeitsdiagramm ein neuer Aspekt hinzugefügt, der Kreisprozesse ermöglicht. Sehen Sie sich ein paar spezifische Probleme an.

Problem 1: Der Angehaltene belegt Sperren, die der Anhaltende benötigt oder die von Threads benötigt werden, von denen der Anhaltende abhängt.

Problem 1a: Bei den Sperren handelt es sich um CLR-Sperren.

Wie Sie sich vorstellen können, führt die CLR viele Threadsynchronisierungen durch und belegt dadurch mehrere Sperren, die intern verwendet werden. Wenn Sie DoStackSnapshot aufrufen, erkennt die CLR, dass der Zielthread eine CLR-Sperre belegt, die der aktuelle Thread (der Thread, der DoStackSnapshot aufruft) benötigt, um den Stapeldurchlauf durchführen zu können. Wenn diese Bedingung eintritt, verweigert die CLR das Anhalten, und DoStackSnapshot gibt sofort den Fehler CORPROF_E_STACKSNAPSHOT_UNSAFE zurück. Wenn Sie den Thread vor Ihrem Aufruf an DoStackSnapshot selbst angehalten haben, nehmen Sie ihn an dieser Stelle wieder auf und haben ein Problem vermieden.

Problem 1b: Bei den Sperren handelt es sich um die Sperren Ihres Profilers.

Dieses Problem lässt sich mit gesundem Menschenverstand lösen. Unter Umständen müssen Sie gelegentlich eine eigene Threadsynchronisierung durchführen. Stellen Sie sich vor, ein Anwendungsthread (Thread A) trifft auf einen Profilerrückruf und führt einen Teil Ihres Profilercodes aus, der eine der Sperren des Profilers aktiviert. Dann muss Thread B Thread A durchlaufen, was bedeutet, dass Thread B Thread A anhält. Während Thread A angehalten ist, darf Thread B keine der Sperren des Profilers aktivieren, die Thread A belegen könnte. Thread B führt während des Stapeldurchlaufs StackSnapshotCallback aus. Während dieses Rückrufs sollten Sie keine Sperren aktivieren, die von Thread A belegt sein könnten.

Problem 2: Während Sie den Zielthread anhalten, versucht der Zielthread, Sie anzuhalten.

Vielleicht halten Sie das für unmöglich. Aber unter folgenden Bedingungen ist es durchaus möglich:

  • Ihre Anwendung wird auf einem Multiprozessorcomputer ausgeführt, und

  • Thread A wird auf dem einen Prozessor ausgeführt und Thread B auf einem anderen, und

  • Thread A versucht, Thread B anzuhalten, während Thread B versucht, Thread A anzuhalten.

In diesem Fall ist es möglich, dass sich beide Unterbrechungen durchsetzen, und dann sind beide Threads angehalten. Da jeder Thread darauf wartet, dass der andere ihn reaktiviert, bleiben sie für immer angehalten.

Dieses Problem ist beunruhigender als Problem 1. Sie können sich nicht darauf verlassen, dass die CLR vor Ihrem Aufruf von DoStackSnapshot erkennt, dass die Threads einander anhalten werden. Und sobald Sie die Unterbrechung durchgeführt haben, ist es zu spät!

Warum versucht der Zielthread, den Profiler anzuhalten? In einem hypothetischen, schlecht geschriebenen Profiler kann der Stapeldurchlaufcode zusammen mit dem Unterbrechungscode zu willkürlichen Zeiten von einer beliebigen Anzahl von Threads durchgeführt werden. Stellen Sie sich vor, dass Thread A zu derselben Zeit versucht, Thread B zu durchlaufen, zu der Thread B versucht, Thread A zu durchlaufen. Sie versuchen beide gleichzeitig, einander anzuhalten, da sie beide den Teil SuspendThread der Stapeldurchlaufroutine des Profilers ausführen. Beide setzen sich durch, und die Anwendung, für die das Profil erstellt wird, kommt zum Stillstand. Die Regel ist offensichtlich: Lassen Sie nicht zu, dass Ihr Profiler Stapeldurchlaufcode und damit Unterbrechungscode für zwei Threads gleichzeitig ausführt!

Ein weniger offensichtlicher Grund dafür, dass der Zielthread u. U. versucht, Ihren Durchlaufthread anzuhalten, liegt am Innenleben der CLR. Die CLR hält Anwendungsthreads an, um mit Aufgaben wie der Garbage Collection zu helfen. Wenn Ihr Durchlauf versucht, den Thread, der die Garbage Collection durchführt, zum gleichen Zeitpunkt zu durchlaufen (und damit anzuhalten), zu dem der Garbage Collector versucht, Ihren Durchlauf anzuhalten, kommen beide Prozesse zum Stillstand.

Doch das Problem lässt sich leicht vermeiden. Die CLR hält nur die Threads an, die sie anhalten muss, um ihre Arbeit ausführen zu können. Stellen Sie sich vor, dass zwei Threads an Ihrem Stapeldurchlauf beteiligt sind. Bei Thread D handelt es sich um den aktuellen Thread; den Thread, der den Durchlauf durchführt. Thread Z ist der Zielthread; der Thread, dessen Stapel durchgelaufen wird. Solange Thread D noch keinen verwalteten Code ausgeführt hat und daher nicht der Garbage Collection der CLR unterliegt, wird die CLR nie versuchen, Thread D anzuhalten. Das bedeutet, Ihr Profiler kann ruhig zulassen, dass Thread D Thread Z anhält.

Wenn Sie einen Beispielprofiler schreiben, ist es ganz natürlich, all dies sicherzustellen. In der Regel werden Sie einen separaten Thread aus eigener Hand haben, der auf Zeitgeberunterbrechungen reagiert und der die Stapel von anderen Threads durchläuft. Nennen Sie diesen Ihren Beispielthread. Da Sie den Beispielthread selbst erstellen und steuern können, was er ausführt (und der Thread daher nie verwalteten Code ausführt), wird die CLR keinen Grund haben, ihn anzuhalten. Wenn Sie Ihren Profiler so entwickeln, dass er seinen eigenen Beispielthread erstellt, der die Stapel durchläuft, vermeiden Sie auch das weiter oben beschriebene Problem des „schlecht geschriebenen Profilers“. Der Beispielthread ist der einzige Thread Ihres Profilers, der versucht, andere Threads zu durchlaufen oder anzuhalten, sodass Ihr Profiler nie direkt versuchen wird, den Beispielthread anzuhalten.

Dies ist die erste nicht triviale Regel; um das zu betonen, wird sie wiederholt:

Regel 1: Nur ein Thread, der nie verwalteten Code ausgeführt hat, sollte einen anderen Thread anhalten.

Niemand geht gern über Leichen

Wenn Sie einen threadübergreifenden Stapeldurchlauf durchführen, müssen Sie sicherstellen, dass Ihr Zielthread für die Dauer des Durchlaufs aktiv bleibt. Nur weil Sie den Zielthread als Parameter an den DoStackSnapshot-Aufruf weitergeben, bedeutet das noch lange nicht, dass sie implizit eine Art von Lebensdauerhinweis hinzugefügt haben. Die Anwendung kann den Thread jederzeit fortschicken. Wenn das geschieht, während Sie versuchen, den Thread zu durchlaufen, können Sie leicht eine Zugriffsverletzung verursachen.

Glücklicherweise benachrichtigt die CLR Profiler, wenn ein Thread gelöscht werden soll. Dazu wird der passend benannte Rückruf ThreadDestroyed verwendet, der in der Schnittstelle ICorProfilerCallback(2) definiert ist. Es liegt in Ihrer Verantwortung, ThreadDestroyed zu implementieren und warten zu lassen, bis alle Prozesse, die diesen Thread durchlaufen, abgeschlossen sind. Dies ist interessant genug, um sich als nächste Regel zu qualifizieren:

Regel 2: Überschreiben Sie den „ThreadDestroyed“-Rückruf, und lassen Sie Ihre Implementierung warten, bis Sie den Stapel des Threads durchgelaufen haben, der gelöscht werden soll.

Durch Befolgung von Regel 2 wird die CLR daran gehindert, den Thread zu löschen, bevor Sie mit dem Durchlauf des Stapels dieses Threads fertig sind.

Garbage Collection hilft Ihnen beim Erstellen eines Kreisprozesses

An dieser Stelle können die Dinge ein wenig konfus werden. Beginnen Sie mit dem Text der nächsten Regel, und entschlüsseln Sie alles von dort aus:

Regel 3: Aktivieren Sie während eines Profileraufrufs keine Sperre, die Garbage Collection auslösen kann.

Es wurde bereits erwähnt, dass es für Ihren Profiler nicht gut ist, eine seiner eigenen Sperren zu aktivieren, wenn der belegende Thread angehalten werden und der Thread von einem weiteren Thread durchlaufen werden könnte, der dieselbe Sperre benötigt. Regel 3 hilft Ihnen bei der Vermeidung eines subtileren Problems. Hier wurde gesagt, dass Sie keine Ihrer eigenen Sperren aktivieren sollten, wenn der belegende Thread im Begriff ist, eine ICorProfilerInfo(2)-Methode aufzurufen, die eine Garbage Collection auslösen könnte.

Eine Reihe von Beispielen sollte dies verständlich machen. Nehmen Sie für das erste Beispiel an, dass Thread B die Garbage Collection durchführt. Die Sequenz lautet:

  1. Thread A aktiviert eine der Sperren Ihres Profilers und belegt sie nun.

  2. Thread B ruft den GarbageCollectionStarted-Rückruf des Profilers auf.

  3. Thread B wird an der Profilersperre aus Schritt 1 blockiert.

  4. Thread A führt die Funktion GetClassFromTokenAndTypeArgs aus.

  5. Der Aufruf GetClassFromTokenAndTypeArgs versucht, eine Garbage Collection auszulösen, stellt jedoch fest, dass bereits eine Garbage Collection im Gange ist.

  6. Thread A wird blockiert und wartet darauf, dass die derzeit durchgeführte Garbage Collection (Thread B) abgeschlossen wird. Aufgrund Ihrer Profilersperre wartet Thread B jedoch auf Thread A.

In Abbildung 3 wird das Szenario in diesem Beispiel veranschaulicht:

Abbildung 3: Ein Deadlock zwischen dem Profiler und dem Garbage Collector
Abbildung 3: Ein Deadlock zwischen dem Profiler und dem Garbage Collector

Im zweiten Beispiel geht es um ein etwas anderes Szenario. Die Sequenz lautet:

  1. Thread A aktiviert eine der Sperren Ihres Profilers und belegt sie nun.

  2. Thread B ruft den ModuleLoadStarted-Rückruf des Profilers auf.

  3. Thread B wird an der Profilersperre aus Schritt 1 blockiert.

  4. Thread A führt die Funktion GetClassFromTokenAndTypeArgs aus.

  5. Der Aufruf GetClassFromTokenAndTypeArgs löst eine Garbage Collection aus.

  6. Thread A (der jetzt die Garbage Collection durchführt), wartet darauf, dass Thread B eingesammelt werden kann. Doch aufgrund Ihrer Profilersperre wartet Thread B auf Thread A.

  7. Dieses zweite Beispiel wird in Abbildung 4 veranschaulicht.

Abbildung 4: Ein Deadlock zwischen dem Profiler und einer anstehenden Garbage Collection
Abbildung 4: Ein Deadlock zwischen dem Profiler und einer anstehenden Garbage Collection

Haben Sie dieses Durcheinander verdaut? Der Knackpunkt des Problems besteht darin, dass die Garbage Collection über ihre eigenen Synchronisierungsmechanismen verfügt. Zu dem Ergebnis in dem ersten Beispiel kommt es deswegen, weil nur jeweils eine Garbage Collection durchgeführt werden kann. Zugegeben, dies ist ein Randfall, da Garbage Collections in der Regel nicht so häufig auftreten, dass die eine auf die andere warten muss; es sei denn, Sie arbeiten unter stressigen Bedingungen. Wenn Sie jedoch lange genug Profile erstellen, wird dieses Szenario auftreten, und Sie müssen darauf vorbereitet sein.

Zu dem Ergebnis im zweiten Beispiel kommt es, weil der Thread, der die Garbage Collection durchführt, darauf warten muss, dass die Anwendungsthreads eingesammelt werden können. Das Problem tritt auf, wenn Sie der Mischung eine Ihrer eigenen Sperren hinzufügen und dadurch einen Kreisprozess bilden. In beiden Fällen wird Regel 3 gebrochen, indem es Thread A erlaubt wird, eine der Profilersperren zu belegen und dann GetClassFromTokenAndTypeArgs aufzurufen. (Tatsächlich reicht das Aufrufen einer beliebigen Methode, die eine Garbage Collection auslösen könnte, aus, um den Prozess zum Untergang zu verurteilen.)

Jetzt haben Sie wahrscheinlich mehrere Fragen.

F. Woher wissen Sie, welche „ICorProfilerInfo(2)“-Methoden eine Garbage Collection auslösen könnten?

A. Es ist geplant, dies in MSDN zu dokumentieren, oder zumindest in meinem Weblog oder dem von Jonathan Keljo (beide möglicherweise in englischer Sprache).

Q. Was hat all das mit Stapeldurchläufen zu tun? „DoStackSnapshot“ wird nicht erwähnt.

A. Das ist richtig. Und DoStackSnapshot ist nicht mal eine der ICorProfilerInfo(2)-Methoden, die eine Garbage Collection auslösen. Regel 3 wird deshalb hier erörtert, weil waghalsige Programmierer, die Stapel aus willkürlichen Beispielen asynchron durchlaufen, sehr wahrscheinlich ihre eigenen Profilersperren implementieren und daher anfällig dafür sind, in diese Falle zu tappen. Regel 2 weist Sie im Wesentlichen an, Ihrem Profiler Synchronisierung hinzuzufügen. Es ist recht wahrscheinlich, dass ein Beispielprofiler außerdem über andere Synchronisierungsmechanismen verfügt, vielleicht, um das Lesen und Schreiben gemeinsam genutzter Datenstrukturen zu beliebigen Zeiten zu koordinieren. Natürlich ist es dennoch möglich, dass ein Profiler mit diesem Problem konfrontiert wird, der nie mit DoStackSnapshot in Berührung kommt.

Genug jetzt

Zum Schluss werden die Highlights rasch zusammengefasst. Es folgen einige wichtige Punkte, die zu bedenken sind:

  • Zu synchronen Stapeldurchläufen gehört das Durchlaufen des aktuellen Threads in Reaktion auf einen Profilerrückruf. Dafür sind keine besonderen Regeln erforderlich, kein Setzen von Ausgangswerten und kein Anhalten.

  • Bei asynchronen Durchläufen ist ein Ausgangswert erforderlich, wenn sich an der Spitze des Stapels nicht verwalteter Code befindet und die Spitze nicht Teil eines PInvoke- oder COM-Aufrufs ist. Sie stellen einen Ausgangswert bereit, indem Sie den Zielthread direkt anhalten und ihn selbst durchlaufen, bis Sie den obersten verwalteten Frame finden. Wenn Sie in diesem Fall keinen Ausgangswert bereitstellen, gibt DoStackSnapshot u. U. einen Fehler zurück oder überspringt einige Frames an der Spitze des Stapels.

  • Wenn Sie Threads anhalten müssen, denken Sie daran, dass nur ein Thread, der noch nie verwalteten Code ausgeführt hat, einen anderen Thread anhalten darf.

  • Wenn Sie asynchrone Durchläufe durchführen, sollten Sie immer den ThreadDestroyed-Rückruf außer Kraft setzen, um die CLR vom Löschen eines Threads abzuhalten, bis der Stapeldurchlauf dieses Threads abgeschlossen ist.

  • Aktivieren Sie keine Sperre, während Ihr Profiler einen Aufruf in eine CLR-Funktion sendet, die eine Garbage Collection auslösen könnte.

Weitere Informationen zur Profilerstellungs-API finden Sie auf der MSDN-Website unter Profilerstellung (nicht verwaltet) (möglicherweise in englischer Sprache).

Ehre wem Ehre gebührt

Ich möchte mich bei dem Rest des CLR-Profilerstellungs-API-Teams bedanken, da das Verfassen dieser Regeln in Wirklichkeit eine Teamarbeit war. Besonderer Dank geht an Sean Selitrennikoff, der eine frühere Inkarnation eines Großteils dieses Inhalts zur Verfügung gestellt hat.

Über den Autor

David war länger Entwickler bei Microsoft, als Sie annehmen würden, wenn man sein begrenztes Wissen und seine mangelnde Reife berücksichtigt. Obwohl er keinen Code mehr einchecken darf, bietet er immer noch Ideen für neue Variablennamen an. David ist ein großer Fan von Count Chocula (eine Art Frühstücksflocken) und hat ein eigenes Auto.


Anzeigen: