Freigeben über


Profiler Stack Walking im .NET Framework 2.0: Grundlagen und darüber hinaus

 

September 2006

David Broman
Microsoft Corporation

Gilt für:
   Microsoft .NET Framework 2.0
   Common Language Runtime (CLR)

Zusammenfassung: Beschreibt, wie Sie Ihren Profiler so programmieren können, dass verwaltete Stapel in der Common Language Runtime (CLR) des .NET Framework. (14 gedruckte Seiten)

Inhalte

Einführung
Synchrone und asynchrone Aufrufe
Vermischen
Ihr bestes Verhalten haben
Jetzt reicht es
Guthaben, bei dem Guthaben fällig ist
Zum Autor

Einführung

Dieser Artikel richtet sich an alle, die an der Erstellung eines Profilers zum Untersuchen verwalteter Anwendungen interessiert sind. Ich beschreibe, wie Sie Ihren Profiler so programmieren können, dass verwaltete Stapel in der Common Language Runtime (CLR) des .NET Framework. Ich werde versuchen, die Stimmung hell zu halten, weil das Thema selbst manchmal schwer in Gang sein kann.

Die Profilerstellungs-API in Version 2.0 der CLR verfügt über eine neue Methode mit dem Namen DoStackSnapshot , mit der Ihr Profiler die Aufrufliste der Anwendung durchlaufen kann, die Sie profilieren. Version 1.1 der CLR hat ähnliche Funktionen über die In-Process-Debugschnittstelle verfügbar gemacht. Das Durchlaufen der Aufrufliste ist jedoch einfacher, genauer und stabiler mit DoStackSnapshot. Die DoStackSnapshot-Methode verwendet denselben Stack Walker, der auch vom Garbage Collector, dem Sicherheitssystem, dem Ausnahmesystem usw. verwendet wird. Sie wissen also, dass es richtig sein muss.

Der Zugriff auf eine vollständige Stapelüberwachung gibt Benutzern Ihres Profilers die Möglichkeit, sich einen überblick über die Vorgänge in einer Anwendung zu verschaffen, wenn etwas Interessantes passiert. Abhängig von der Anwendung und dem, was ein Benutzer profilieren möchte, können Sie sich vorstellen, dass ein Benutzer eine Aufrufliste wünscht, wenn ein Objekt zugeordnet wird, wenn eine Klasse geladen wird, wenn eine Ausnahme ausgelöst wird usw. Selbst das Abrufen einer Aufrufliste für etwas anderes als ein Anwendungsereignis , z. B. ein Timerereignis, wäre für einen Sampling-Profiler interessant. Die Betrachtung von Hotspots im Code wird erhellend, wenn Sie sehen können, wer die Funktion aufgerufen hat, die die Funktion aufgerufen hat, die den Hotspot enthält.

Ich werde mich auf das Abrufen von Stapelablaufverfolgungen mit der DoStackSnapshot-API konzentrieren. Eine weitere Möglichkeit zum Abrufen von Stapelüberwachungen besteht darin, Schattenstapel zu erstellen: Sie können FunctionEnter und FunctionLeave einbinden, 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 Anwendungsausführung jederzeit Stapelinformationen benötigen, und wenn Sie die Leistungskosten nicht stört, wenn der Code Ihres Profilers bei jedem verwalteten Aufruf und jeder Rückgabe ausgeführt wird. Die DoStackSnapshot-Methode eignet sich am besten, wenn Sie eine geringfügig sparsamere Berichterstellung für Stapel benötigen, z. B. als Reaktion auf Ereignisse. Selbst ein Sampling-Profiler, der alle paar Millisekunden Stapelmomentaufnahmen erstellt, ist viel sparsamer als das Erstellen von Schattenstapeln. Daher eignet sich DoStackSnapshot gut für Sampling-Profiler.

Machen Sie einen Stapelspaziergang auf der wilden Seite

Es ist sehr nützlich, Aufruflisten zu erhalten, wann immer Sie sie möchten. Aber mit Macht kommt Verantwortung. Ein Profilerbenutzer möchte nicht, dass der Stapelgang zu einer Zugriffsverletzung (AV) oder einem Deadlock in der Runtime führt. Als Profiler-Writer müssen Sie Ihre Macht mit Bedacht nutzen. Ich werde darüber sprechen, wie DoStackSnapshot verwendet wird, und wie man dies sorgfältig tut. Je mehr Sie mit dieser Methode tun möchten, desto schwieriger ist es, sie richtig zu machen.

Werfen wir einen Blick auf unser Thema. Hier ist, was Ihr Profiler aufruft (Sie finden dies in der ICorProfilerInfo2-Schnittstelle in 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); 

Der folgende Code ruft die CLR auf Ihrem Profiler auf. (Sie finden dies auch in Corprof.idl.) Sie übergeben einen Zeiger auf Ihre Implementierung dieser Funktion im Rückrufparameter aus dem vorherigen Beispiel.

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

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

Abbildung 1. Ein "Sandwich" von Anrufen während der Profilerstellung

Wie Sie aus meinen Notationen sehen können, benachrichtigt Die CLR Sie über die Frames in umgekehrter Reihenfolge von der Art, wie sie auf den Stapel geschoben wurden – Blattrahmen zuerst (zuletzt geschoben), Standard letzten Frame (zuerst gepusht).

Was bedeuten alle Parameter für diese Funktionen? Ich bin noch nicht bereit, alle zu besprechen, aber ich werde einige davon besprechen, beginnend mit DoStackSnapshot. (Ich werde in wenigen Augenblicken zum Rest kommen.) Der InfoFlags-Wert stammt aus der COR_PRF_SNAPSHOT_INFO-Enumeration in Corprof.idl und ermöglicht es Ihnen zu steuern, ob die CLR Ihnen Registrierungskontexte für die von ihr gemeldeten Frames bereitstellt. Sie können einen beliebigen Wert für clientData angeben, und die CLR gibt ihn Ihnen in Ihrem StackSnapshotCallback-Aufruf zurück.

In StackSnapshotCallback verwendet die CLR den parameter funcId , um Ihnen den FunctionID-Wert des aktuell durchlaufenen Frames zu übergeben. Dieser Wert ist 0, wenn der aktuelle Frame eine Ausführung nicht verwalteter Frames ist, über die ich später sprechen werde. Wenn funcId ungleich null ist, können Sie funcId und frameInfo an andere Methoden wie GetFunctionInfo2 und GetCodeInfo2 übergeben, um weitere Informationen zur Funktion zu erhalten. Sie können diese Funktionsinformationen sofort während des Stapellaufs abrufen oder alternativ die funcId-Werte speichern und die Funktionsinformationen später abrufen, wodurch ihre Auswirkungen auf die ausgeführte Anwendung verringert werden. Wenn Sie die Funktionsinformationen später erhalten, denken Sie daran, dass ein frameInfo-Wert nur innerhalb des Rückrufs gültig ist, der ihnen zugibt. Obwohl es in Ordnung ist, die funcId-Werte zur späteren Verwendung zu speichern, speichern Sie frameInfo nicht zur späteren Verwendung.

Wenn Sie von StackSnapshotCallback zurückkehren, geben Sie in der Regel S_OK zurück, und die CLR führt den Stapel weiter. Wenn Sie möchten, können Sie S_FALSE zurückgeben, wodurch der Stapellauf beendet wird. Ihr DoStackSnapshot-Aufruf gibt dann CORPROF_E_STACKSNAPSHOT_ABORTED zurück.

Synchrone und asynchrone Aufrufe

Sie können DoStackSnapshot auf zwei Arten aufrufen: synchron und asynchron. Ein synchroner Anruf ist am einfachsten zu erreichen. Sie führen einen synchronen Aufruf durch, wenn die CLR eine der ICorProfilerCallback(2) -Methoden Ihres Profilers aufruft, und als Antwort rufen Sie DoStackSnapshot auf, um den Stapel des aktuellen Threads zu durchlaufen. Dies ist nützlich, wenn Sie sehen möchten, wie der Stapel an einem interessanten Benachrichtigungspunkt wie ObjectAllocated aussieht. Um einen synchronen Aufruf auszuführen, rufen Sie DoStackSnapshot innerhalb Ihrer ICorProfilerCallback(2) -Methode auf und übergeben null für die Parameter, über die ich Sie nicht informiert habe.

Ein asynchroner Stapellauf tritt auf, wenn Sie den Stapel eines anderen Threads durchlaufen oder einen Thread erzwungen unterbrechen, um einen Stapellauf (auf sich selbst oder in einem anderen Thread) durchzuführen. Das Unterbrechen eines Threads umfasst das Kapern des Anweisungszeigers des Threads, um ihn zu erzwingen, ihren eigenen Code zu beliebigen Zeiten auszuführen. Dies ist aus zu vielen Gründen wahnsinnig gefährlich, um hier auflisten zu können. Bitte, machen Sie es nicht. Ich schränke meine Beschreibung von asynchronen Stapelspaziergängen auf nicht-hijacking-Verwendungen von DoStackSnapshot ein, um einen separaten Zielthread zu durchlaufen. Ich nenne dies "asynchron", weil der Zielthread zu einem beliebigen Zeitpunkt ausgeführt wurde, zu dem der Stapellauf beginnt. Diese Technik wird häufig von Samplingprofilern verwendet.

Gehen Sie durch eine andere Person

Lassen Sie uns den Threadübergreifenden – d. h. den asynchronen – Stapellauf ein wenig aufschlüsseln. Sie verfügen über zwei Threads: den aktuellen Thread und den Zielthread. Der aktuelle Thread ist der Thread, der DoStackSnapshot ausführt. Der Zielthread ist der Thread, dessen Stapel von DoStackSnapshot durchlaufen wird. Sie geben den Zielthread an, indem Sie seine Thread-ID im Threadparameter an DoStackSnapshot übergeben. Was als Nächstes geschieht, ist nicht für die Ohnmacht des Herzens. Denken Sie daran, dass der Zielthread beliebigen Code ausgeführt hat, als Sie aufgefordert haben, den Stapel zu durchlaufen. Daher hält die CLR den Zielthread an und bleibt die gesamte Zeit, in der er durchlaufen wird, angehalten. Kann dies sicher geschehen?

Ich freue mich, dass Sie gefragt haben. Das ist in der Tat gefährlich, und ich werde später darüber sprechen, wie man dies sicher macht. Aber zuerst werde ich mich mit Stacks im gemischten Modus vertraut machen.

Vermischen

Eine verwaltete Anwendung verbringt wahrscheinlich nicht die gesamte Zeit mit verwaltetem Code. PInvoke-Aufrufe und COM-Interop ermöglichen es verwaltetem Code, in nicht verwalteten Code und manchmal wieder mit Delegaten aufzurufen. Und verwalteter Code ruft direkt in die nicht verwaltete Runtime (CLR) auf, um JIT-Kompilierung durchzuführen, Ausnahmen zu behandeln, Garbage Collection durchzuführen usw. Wenn Sie also einen Stapellauf durchführen, wird wahrscheinlich ein Stapel im gemischten Modus angezeigt. Einige Frames sind verwaltete Funktionen und andere nicht verwaltete Funktionen.

Wächst auf, schon!

Bevor ich weiterkomme, ein kurzes Intermum. Jeder weiß, dass Stapel auf unseren modernen PCs an kleinere Adressen wachsen (d. h. "pushen"). Aber wenn wir diese Adressen in unseren Köpfen oder auf Whiteboards visualisieren, sind wir mit der vertikalen Sortierung nicht einverstanden. Einige von uns stellen sich vor, dass der Stapel heranwächst (kleine Adressen oben); einige sehen, dass es nach unten wächst (kleine Adressen unten). Wir sind auch in diesem Thema in unserem Team gespalten. Ich entscheide mich für die Seite jedes Debuggers, den ich jemals verwendet habe– Aufrufstapel-Ablaufverfolgungen und Speicherabbilder sagen mir, dass die kleinen Adressen "über" den großen Adressen liegen. So wachsen Stapel auf; Standard am unteren Rand befindet, befindet sich oben der Angerufene. Wenn Sie nicht einverstanden sind, müssen Sie einige mentale Umgestaltungen durchführen, um diesen Teil des Artikels zu durchstehen.

Kellner, es gibt Löcher in meinem Stapel

Nachdem wir nun dieselbe Sprache sprechen, sehen wir uns einen Stapel im gemischten Modus an. Abbildung 2 zeigt einen Beispielstapel im gemischten Modus.

Abbildung 2. Ein Stapel mit verwalteten und nicht verwalteten Frames

Es lohnt sich zu verstehen, warum DoStackSnapshot überhaupt existiert. Sie hilft Ihnen beim Durchlaufen von verwalteten Frames auf dem Stapel. Wenn Sie versuchen, verwaltete Frames selbst zu durchlaufen, erhalten Sie unzuverlässige Ergebnisse, insbesondere auf 32-Bit-Systemen, aufgrund einiger abgefahrener Aufrufkonventionen, die in verwaltetem Code verwendet werden. Die CLR kennt diese Aufrufkonventionen, und DoStackSnapshot kann Ihnen daher helfen, sie zu decodieren. DoStackSnapshot ist jedoch keine vollständige Lösung, wenn Sie den gesamten Stapel einschließlich nicht verwalteter Frames durchlaufen möchten.

Hier haben Sie die Wahl:

Option 1: Tun Sie nichts und melden Sie Stapel mit "nicht verwalteten Löchern" an Ihre Benutzer, oder ...

Option 2: Schreiben Sie Ihren eigenen nicht verwalteten Stapellauf, um diese Löcher zu füllen.

Wenn DoStackSnapshot auf einen Block nicht verwalteter Frames trifft, wird Ihre StackSnapshotCallback-Funktion aufgerufen, wobei funcId auf 0 festgelegt ist, wie bereits erwähnt. Wenn Sie option 1 verwenden, tun Sie einfach nichts in Ihrem Rückruf, wenn funcId 0 ist. Die CLR ruft Sie erneut für den nächsten verwalteten Frame auf, und Sie können an diesem Punkt wieder aufwachen.

Wenn der nicht verwaltete Block aus mehr als einem nicht verwalteten Frame besteht, ruft die CLR StackSnapshotCallback immer noch nur einmal auf. Denken Sie daran, dass die CLR sich nicht darum bemüht, den nicht verwalteten Block zu decodieren. Sie verfügt über spezielle Insiderinformationen, die ihr helfen, den Block zum nächsten verwalteten Frame zu überspringen, und so wird der Vorgang fortgesetzt. Die CLR weiß nicht unbedingt, was sich im nicht verwalteten Block befindet. Das müssen Sie herausfinden, daher Option 2.

Dieser erste Schritt ist ein Doozy

Unabhängig davon, welche Option Sie wählen, ist das Ausfüllen der nicht verwalteten Löcher nicht der einzige harte Teil. Der Beginn des Spaziergangs kann eine Herausforderung sein. Sehen Sie sich den obigen Stapel an. Oben befindet sich nicht verwalteter Code. Manchmal haben Sie Glück, und der nicht verwaltete Code ist COM- oder PInvoke-Code . Wenn dies der Grund ist, ist die CLR intelligent genug, um zu wissen, wie sie übersprungen werden kann, und beginnt den Schritt am ersten verwalteten Frame (D im Beispiel). Möglicherweise möchten Sie jedoch trotzdem den obersten nicht verwalteten Block durchlaufen, um einen Stapel so vollständig wie möglich zu melden.

Auch wenn Sie den obersten Block nicht durchlaufen möchten, sind Sie möglicherweise trotzdem dazu gezwungen– wenn Sie nicht Glück haben, ist dieser nicht verwaltete Code nicht COM- oder PInvoke-Code , sondern Hilfscode in der CLR selbst, z. B. Code für die JIT-Kompilierung oder Garbage Collection. Wenn dies der Fall ist, kann die CLR den D-Frame ohne Ihre Hilfe nicht finden. Daher führt ein nicht empfangener Aufruf von DoStackSnapshot zu dem Fehler CORPROF_E_STACKSNAPSHOT_UNMANAGED_CTX oder CORPROF_E_STACKSNAPSHOT_UNSAFE. (Übrigens lohnt es sich wirklich, corerror.h.)

Beachten Sie, dass ich das Wort "ungesehen" verwendet habe. DoStackSnapshot verwendet einen Seedkontext mit den Parametern context und contextSize . Das Wort "Kontext" ist mit vielen Bedeutungen überladen. In diesem Fall spreche ich von einem Registerkontext. Wenn Sie die architekturabhängigen Windows-Header (z. B. nti386.h) verwenden, finden Sie eine Struktur namens CONTEXT. Sie enthält Werte für die CPU-Register und stellt den Zustand der CPU zu einem bestimmten Zeitpunkt dar. Das ist der Kontext, von dem ich spreche.

Wenn Sie NULL für den Kontextparameter übergeben, wird der Stapellauf nicht mehr verwendet, und die CLR beginnt oben. Wenn Sie jedoch einen Wert ungleich NULL für den Kontextparameter übergeben, der den CPU-Zustand an einer stelle unten im Stapel darstellt (z. B. auf den D-Frame zeigt), führt die CLR einen Stapellauf durch, der mit Ihrem Kontext versehen ist. Es ignoriert den tatsächlichen Oberen des Stapels und beginnt überall dort, wo Sie darauf zeigen.

OK, nicht ganz wahr. Der Kontext, den Sie an DoStackSnapshot übergeben, ist eher ein Hinweis als eine direktive Direktive. Wenn die CLR sicher ist, dass sie den ersten verwalteten Frame finden kann (da der oberste nicht verwaltete Block PInvoke oder COM-Code ist), wird dies ausgeführt und Ihr Seed ignoriert. Nehmen Sie es jedoch nicht persönlich. Die CLR versucht, Ihnen zu helfen, indem sie den genauesten Stapellauf bereitstellt. Ihr Seed ist nur nützlich, wenn der oberste nicht verwaltete Block Hilfscode in der CLR selbst ist, da wir keine Informationen haben, die uns helfen, ihn zu überspringen. Daher wird Ihr Seed nur verwendet, wenn die CLR nicht selbst bestimmen kann, wo der Schritt beginnen soll.

Sie fragen sich vielleicht, wie Sie uns den Seed überhaupt zur Verfügung stellen können. Wenn der Zielthread noch nicht angehalten ist, können Sie nicht einfach den Stapel des Zielthreads durchlaufen, um den D-Frame zu finden und somit Ihren Startkontext zu berechnen. Und dennoch erzähle ich Ihnen, dass Sie Ihren Seedkontext berechnen, indem Sie Ihren nicht verwalteten Schritt ausführen, bevorSie DoStackSnapshot aufrufen und somit bevor DoStackSnapshot den Zielthread für Sie angehalten. Muss der Zielthread von Ihnen und der CLR angehalten werden? Eigentlich ja.

Ich denke, es ist an der Zeit, dieses Ballett zu choreografieren. Bevor ich jedoch zu tief schreite, beachten Sie, dass die Frage, ob und wie ein Stapellauf ausgeführt werden kann, nur für asynchrone Walks gilt. Wenn Sie einen synchronen Walk ausführen, ist DoStackSnapshot immer in der Lage, den Weg zum am besten verwalteten Frame ohne Ihre Hilfe zu finden – kein Seed erforderlich.

Jetzt alles zusammen

Für den wirklich abenteuerlichen Profiler, der einen asynchronen, threadübergreifenden, seeded Stack Walk ausführt, während er die nicht verwalteten Löcher füllt, sieht ein Stapelspaziergang wie folgt aus. Angenommen, der hier dargestellte Stapel ist derselbe Stapel, den Sie in Abbildung 2 gesehen haben, und soeben ein wenig aufgeschlüsselt.

Stapelinhalt Profiler- und CLR-Aktionen

1. Sie setzen den Zielthread an. (Die Anzahl der Unterbrechungen des Zielthreads ist jetzt 1.)

2. Sie erhalten den aktuellen Registerkontext des Zielthreads.

3. Sie bestimmen, ob der Registerkontext auf nicht verwalteten Code verweist. Das heißt, Sie rufen ICorProfilerInfo2::GetFunctionFromIP auf und überprüfen, ob Sie den FunctionID-Wert 0 erhalten.

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

5. Sie rufen DoStackSnapshot mit Ihrem Seedkontext auf, und die CLR hält den Zielthread erneut an. (Die Anzahl der Unterbrechungen beträgt jetzt 2.) Das Sandwich beginnt.
a. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit der FunctionID für D auf.
b. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit FunctionID gleich 0 auf. Sie müssen diesen Block selbst durchlaufen. Sie können beenden, wenn Sie den ersten verwalteten Frame erreichen. Alternativ können Sie Ihren nicht verwalteten Walk bis zu einer Zeit nach Ihrem nächsten Rückruf betrügen und verzögern, da der nächste Rückruf Ihnen genau sagt, wo der nächste verwaltete Frame beginnt und wo Ihr nicht verwalteter Walk enden soll.
c. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit der FunctionID für C auf.
d. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit der FunctionID für B auf.
e. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit FunctionID gleich 0 auf. Auch hier müssen Sie diesen Block selbst durchlaufen.
f. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit der FunctionID für A auf.
g. Die CLR ruft Ihre StackSnapshotCallback-Funktion mit der FunctionID für Main auf.

h. Dostacksnapshot "setzt" den Zielthread fort, indem die Win32 ResumeThread() -API aufgerufen wird, wodurch die Anzahl der Unterbrechungen des Threads verringert wird (die Anzahl der Unterbrechungen ist jetzt 1) und zurückgegeben wird. Das Sandwich ist fertig.
6. Sie setzen den Zielthread fort. Die Anzahl der Unterbrechungen ist jetzt 0, sodass der Thread physisch fortgesetzt wird.

Ihr bestes Verhalten haben

Ok, dies ist viel zu viel Leistung ohne ernsthafte Vorsicht. Im fortgeschrittensten Fall reagieren Sie auf Timer-Interrupts und anhalten Anwendungsthreads willkürlich, um deren Stapel zu durchlaufen. Huch!

Gut zu sein ist hart und beinhaltet Regeln, die zunächst nicht offensichtlich sind. Lassen Sie uns also eintauchen.

Der schlechte Seed

Beginnen wir mit einer einfachen Regel: Verwenden Sie keinen schlechten Seed. Wenn Ihr Profiler beim Aufrufen von DoStackSnapshot einen ungültigen (nicht NULL)-Seed bereitstellt, führt die CLR zu schlechten Ergebnissen. Es wird der Stapel untersucht, auf den Sie ihn zeigen, und es werden Annahmen darüber getroffen, was die Werte auf dem Stapel darstellen sollen. Dies führt dazu, dass die CLR den Rückschluss auf die angenommenen Adressen im Stapel übernimmt. Bei einem fehlerhaften Seed leitet die CLR werte an eine unbekannte Stelle im Arbeitsspeicher ab. Die CLR tut alles, was sie kann, um einen vollständigen zweiten AV-Vorgang zu vermeiden, der den Prozess, den Sie gerade erstellen, zu einem Abbruch führen würde. Aber Sie sollten sich wirklich bemühen, Ihr Saatgut richtig zu bekommen.

Probleme der Suspension

Andere Aspekte des Anhaltens von Threads sind so kompliziert, dass sie mehrere Regeln erfordern. Wenn Sie sich für threadübergreifendes Gehen entscheiden, haben Sie mindestens beschlossen, die CLR aufzufordern, Threads in Ihrem Namen anzusetzen. Wenn Sie außerdem den nicht verwalteten Block oben im Stapel durchlaufen möchten, haben Sie beschlossen, Threads selbst anzusetzen, ohne die Weisheit der CLR zu aufrufen, ob dies derzeit eine gute Idee ist.

Wenn Sie Informatikunterricht genommen haben, erinnern Sie sich wahrscheinlich an das Problem der "Essensphilosophen". Eine Gruppe von Philosophen sitzt an einem Tisch, jeweils mit einer Gabe rechts und einer links. Je nach Problem benötigen sie jeweils zwei Forks zum Essen. Jeder Philosoph nimmt seine rechte Gabe auf, aber dann kann niemand seine linke Gababel aufheben, weil jeder Philosoph darauf wartet, dass der Philosoph links von ihm die benötigte Gababel abgibt. Und wenn die Philosophen an einem kreisförmigen Tisch sitzen, haben Sie einen Kreislauf aus Warten und viel leere Mäge. Der Grund, warum sie alle hungern, ist, dass sie eine einfache Regel der Deadlockvermeidung brechen: Wenn Sie mehrere Sperren benötigen, nehmen Sie sie immer in der gleichen Reihenfolge. Wenn Sie diese Regel ausführen, wird der Zyklus vermieden, in dem A auf B, B auf C und C auf A wartet.

Angenommen, eine Anwendung folgt der Regel und nimmt Sperren immer in der gleichen Reihenfolge an. Nun kommt eine Komponente (z. B. Ihr Profiler) und beginnt, Threads willkürlich anzusetzen. Die Komplexität hat sich deutlich erhöht. Was ist, wenn der Hänger jetzt eine Sperre nehmen muss, die vom Suspendierer gehalten wird? Oder was, wenn der Suspender eine Sperre benötigt, die von einem Thread gehalten wird, der auf eine Sperre wartet, die von einem anderen Thread gehalten wird, der auf eine Sperre wartet, die vom Angehaltenen gehalten wird? Suspension fügt unserer Thread-Abhängigkeitsdiagramm eine neue Kante hinzu, die Zyklen einführen kann. Sehen wir uns einige spezifische Probleme an.

Problem 1: Der Suspende besitzt Sperren, die vom Suspender benötigt werden oder die von Threads benötigt werden, von denen der Suspender abhängt.

Problem 1a: Die Sperren sind CLR-Sperren.

Wie Sie sich vorstellen können, führt die CLR viele Threadsynchronisierungen aus und verfügt daher über mehrere Intern verwendete Sperren. Wenn Sie DoStackSnapshot aufrufen, erkennt die CLR, dass der Zielthread eine CLR-Sperre besitzt, die der aktuelle Thread (der Thread, der DoStackSnapshot aufruft) benötigt, um den Stapellauf auszuführen. Wenn diese Bedingung auftritt, verweigert die CLR die Ausführung der Unterbrechung, und DoStackSnapshot gibt sofort mit dem Fehler CORPROF_E_STACKSNAPSHOT_UNSAFE zurück. Wenn Sie den Thread an diesem Punkt vor Ihrem Aufruf von DoStackSnapshot selbst angehalten haben, setzen Sie den Thread selbst fort, und Sie haben ein Problem vermieden.

Problem 1b: Die Sperren sind die Sperren Ihres eigenen Profilers.

Dieses Problem ist wirklich eher ein Problem des gesunden Menschenverstands. Möglicherweise haben Sie hier und da eine eigene Threadsynchronisierung. Stellen Sie sich vor, ein Anwendungsthread (Thread A) stößt auf einen Profiler-Rückruf und führt einen Teil Ihres Profilercodes aus, der eine der Sperren des Profilers akzeptiert. Dann muss Thread B Thread A durchlaufen, was bedeutet, dass Thread B Thread A angehalten. Sie müssen sich daran erinnern, dass Thread A während des Anhaltens von Thread A nicht versuchen sollte, dass Thread B versuchen sollte, eine der eigenen Sperren des Profilers zu ergreifen, die Thread A möglicherweise besitzt. Thread B führt beispielsweise StackSnapshotCallback während des Stapellaufs aus, sodass Sie während dieses Rückrufs keine Sperren vornehmen sollten, die im Besitz von Thread A sein könnten.

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

Sie könnten sagen: "Das kann nicht passieren!" Ob Sie es glauben oder nicht, kann es, wenn:

  • Ihre Anwendung wird auf einem Multiprozessorfeld ausgeführt, und
  • Thread A wird auf einem Prozessor und Thread B auf einem anderen ausgeführt, und
  • Thread A versucht, Thread B anzusetzen, während Thread B versucht, Thread A anzusetzen.

In diesem Fall ist es möglich, dass beide Aufhängungen gewinnen und beide Threads am Ende angehalten werden. Da jeder Thread darauf wartet, dass der andere ihn aufweckt, bleiben sie für immer angehalten.

Dieses Problem ist beunruhigender als Problem 1, da Sie sich nicht darauf verlassen können, dass die CLR erkennt, bevor Sie DoStackSnapshot aufrufen, dass die Threads einander angehalten werden. Und nachdem Sie die Federung ausgeführt haben, ist es zu spät!

Warum versucht der Zielthread, den Profiler anzusetzen? In einem hypothetischen, schlecht geschriebenen Profiler kann der Stapellaufcode zusammen mit dem Aufhängungscode von einer beliebigen Anzahl von Threads zu beliebigen Zeiten ausgeführt werden. Stellen Sie sich vor, Thread A versucht, Thread B gleichzeitig zu durchlaufen, während Thread B versucht, Thread A zu durchlaufen. Beide versuchen, einander gleichzeitig anzusetzen, da beide den SuspendThread-Teil der Stack-Walking-Routine des Profilers ausführen. Sowohl win als auch die Anwendung, die ein Profil erstellt wird, ist deadlocked. Die Regel hier ist offensichtlich: Erlauben Sie Ihrem Profiler nicht, Stapellaufcode (und damit Code aussetzen) auf zwei Threads gleichzeitig auszuführen!

Ein weniger offensichtlicher Grund, warum der Zielthread versuchen könnte, den lauffähigen Thread anzusetzen, liegt an der inneren Funktionsweise der CLR. Die CLR hält Anwendungsthreads an, um Aufgaben wie die Garbage Collection zu unterstützen. Wenn Ihr Walker versucht, den Thread zu laufen (und somit anzusetzen), der die Garbage Collection zur gleichen Zeit ausführt, wie der Garbage Collector-Thread versucht, Ihren Walker anzusetzen, werden die Prozesse deadlockt.

Aber es ist leicht, das Problem zu vermeiden. Die CLR hält nur die Threads an, die sie anhalten muss, um ihre Arbeit zu erledigen. Stellen Sie sich vor, dass es zwei Threads gibt, die an Ihrem Stapellauf beteiligt sind. Thread W ist der aktuelle Thread (der Thread, der den Schritt ausführt). Thread T ist der Zielthread (der Thread, dessen Stapel gelaufen wird). Solange Thread W noch nie verwalteten Code ausgeführt hat und daher nicht der CLR-Garbage Collection unterliegt, versucht die CLR niemals, Thread W anzusetzen. Dies bedeutet, dass Es für Ihren Profiler sicher ist, dass Thread W Thread T angehalten wird.

Wenn Sie einen Sampling-Profiler schreiben, ist es ganz selbstverständlich, all dies sicherzustellen. In der Regel verfügen Sie über einen separaten Thread Ihrer eigenen Erstellung, der auf Timerunterbrechungen reagiert und die Stapel anderer Threads durchläuft. Rufen Sie hier Ihren Samplerthread auf. Da Sie den Samplerthread selbst erstellen und die Kontrolle darüber haben, was er ausführt (und daher nie verwalteten Code ausgeführt wird), hat die CLR keinen Grund, ihn anzusetzen. Wenn Sie Ihren Profiler so entwerfen, dass er einen eigenen Samplingthread erstellt, um alle Stapelgänge auszuführen, wird auch das Problem des zuvor beschriebenen "schlecht geschriebenen Profilers" vermieden. Der Samplerthread ist der einzige Thread Ihres Profilers, der versucht, andere Threads zu durchlaufen oder anzusetzen, sodass Ihr Profiler nie versucht, den Samplerthread direkt anzusetzen.

Dies ist unsere erste nichttriviale Regel.

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

Niemand mag es, eine Leiche zu gehen

Wenn Sie einen threadübergreifenden Stapellauf ausführen, müssen Sie sicherstellen, dass Ihr Zielthread für die Dauer des Spaziergangs am Leben bleibt. Nur weil Sie den Zielthread als Parameter an den DoStackSnapshot-Aufruf übergeben, bedeutet dies nicht, dass Sie implizit irgendeine Art von Lebensdauerreferenz hinzugefügt haben. Die Anwendung kann dazu führen, dass der Thread jederzeit entfernt wird. Wenn dies 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 zerstört wird, indem der mit der ICorProfilerCallback(2)-Schnittstelle definierte threadDestroyed-Rückruf verwendet wird. Es liegt in Ihrer Verantwortung , ThreadDestroyed zu implementieren und zu warten, bis ein Prozess abgeschlossen ist, der für diesen Thread ausgeführt wird. Dies ist interessant genug, um sich als unsere nächste Regel zu qualifizieren:

Regel 2: Überschreiben Sie den ThreadDestroyed-Rückruf, und lassen Sie Ihre Implementierung warten, bis Sie den Stapel des zu zerstörenden Threads durchlaufen haben.

Nach Regel 2 wird verhindert, dass die CLR den Thread zerstört, bis Sie den Stapel dieses Threads durchlaufen haben.

Garbage Collection hilft Ihnen beim Erstellen eines Zyklus

Die Dinge können an dieser Stelle ein wenig verwirrend werden. Beginnen wir mit dem Text der nächsten Regel und entschlüsseln sie von dort aus:

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

Ich habe bereits erwähnt, dass es eine schlechte Idee für Ihren Profiler ist, eine zu halten, wenn seine eigenen Sperren, wenn der besitzende Thread möglicherweise angehalten wird, und wenn der Thread von einem anderen Thread durchlaufen wird, der die gleiche Sperre benötigt. Regel 3 hilft Ihnen, ein subtileres Problem zu vermeiden. Hier sage ich, dass Sie keine ihrer eigenen Sperren halten sollten, wenn der besitzende Thread eine ICorProfilerInfo(2) -Methode aufruft, die möglicherweise eine Garbage Collection auslöst.

Einige Beispiele sollten hilfreich sein. Nehmen Sie für das erste Beispiel an, dass Thread B die Garbage Collection ausführt. Die Sequenz lautet:

  1. Thread A übernimmt und besitzt jetzt eine Ihrer Profilersperren.
  2. Thread B ruft den GarbageCollectionStarted-Rückruf des Profilers auf.
  3. Thread B-Blöcke für die Profilersperre aus Schritt 1.
  4. Thread A führt die GetClassFromTokenAndTypeArgs-Funktion aus.
  5. Der GetClassFromTokenAndTypeArgs-Aufruf versucht, eine Garbage Collection auszulösen, erkennt jedoch, dass bereits eine Garbage Collection ausgeführt wird.
  6. Thread A-Blöcke, die auf den Abschluss der derzeit laufenden Garbage Collection (Thread B) warten. Thread B wartet jedoch aufgrund Ihrer Profilersperre auf Thread A.

Abbildung 3 veranschaulicht das Szenario in diesem Beispiel:

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

Das zweite Beispiel ist ein etwas anderes Szenario. Die Sequenz lautet:

  1. Thread A übernimmt und besitzt jetzt eine Ihrer Profilersperren.
  2. Thread B ruft den ModuleLoadStarted-Rückruf des Profilers auf.
  3. Thread B blockiert die Profilersperre aus Schritt 1.
  4. Thread A führt die GetClassFromTokenAndTypeArgs-Funktion aus .
  5. Der GetClassFromTokenAndTypeArgs-Aufruf löst eine Garbage Collection aus.
  6. Thread A (der jetzt die Garbage Collection durchführt) wartet, bis Thread B gesammelt werden kann. Thread B wartet jedoch aufgrund Ihrer Profilersperre auf Thread A.
  7. Abbildung 4 veranschaulicht das zweite Beispiel.

Abbildung 4. Ein Deadlock zwischen dem Profiler und einer ausstehenden Garbage Collection

Haben Sie den Wahnsinn verdaut? Der Kern des Problems ist, dass die Garbage Collection über eigene Synchronisierungsmechanismen verfügt. Das Ergebnis im ersten Beispiel tritt auf, weil jeweils nur eine Garbage Collection erfolgen kann. Dies ist zugegebenermaßen ein Randfall, da Garbage Collections in der Regel nicht so oft auftreten, dass man auf eine andere warten muss, es sei denn, man arbeitet unter stressigen Bedingungen. Wenn Sie jedoch lange genug profilieren, tritt dieses Szenario auf, und Sie müssen darauf vorbereitet sein.

Das Ergebnis im zweiten Beispiel tritt auf, weil der Thread, der die Garbage Collection ausführt, warten muss, bis die anderen Anwendungsthreads für die Sammlung bereit sind. Das Problem tritt auf, wenn Sie eine Ihrer eigenen Schlösser in die Mischung einführen und so einen Zyklus bilden. In beiden Fällen wird Regel 3 verletzt, indem Thread A eine der Profilersperren besitzt und dann GetClassFromTokenAndTypeArgs aufruft. (Tatsächlich reicht das Aufrufen einer Beliebigen Methode, die eine Garbage Collection auslösen könnte, aus, um den Prozess zu beenden.)

Wahrscheinlich haben Sie inzwischen mehrere Fragen.

Q. Woher wissen Sie, welche ICorProfilerInfo(2)-Methoden eine Garbage Collection auslösen können?

A. Wir planen, dies auf MSDN zu dokumentieren, oder zumindest in meinem Blog oder Jonathan Keljos Blog.

Q. Was hat das mit Stack Walking zu tun? Es gibt keine Erwähnung von DoStackSnapshot.

A. Richtig. Und DoStackSnapshot ist nicht einmal eine dieser ICorProfilerInfo(2) -Methoden, die eine Garbage Collection auslösen. Der Grund, warum ich hier über Regel 3 spreche, ist, dass gerade diese abenteuerlustigen Programmierer, die asynchron Stapel von beliebigen Beispielen durchlaufen, die höchstwahrscheinlich ihre eigenen Profilersperren implementieren und daher anfällig dafür sind, in diese Falle zu fallen. In Regel 2 werden Sie im Wesentlichen dazu aufgefordert, Ihrem Profiler eine Synchronisierung hinzuzufügen. Es ist sehr wahrscheinlich, dass ein Sampling-Profiler auch über andere Synchronisierungsmechanismen verfügt, um das Lesen und Schreiben freigegebener Datenstrukturen zu beliebigen Zeiten zu koordinieren. Natürlich ist es immer noch möglich, dass ein Profiler, der DoStackSnapshot nie berührt, dieses Problem auftritt.

Jetzt reicht es

Zum Abschluss gibt es eine kurze Zusammenfassung der Highlights. Hier sind die wichtigen Punkte, die Sie beachten sollten:

  • Synchrone Stapelspaziergänge umfassen das Durchlaufen des aktuellen Threads als Reaktion auf einen Profiler-Rückruf. Für diese sind keine Seeding-, Ansetzungs- oder Sonderregeln erforderlich.
  • Asynchrone Schritte erfordern einen Startwert, wenn der Anfang des Stapels nicht verwalteter Code und nicht Teil eines PInvoke - oder COM-Aufrufs ist. Sie stellen einen Seed zur Verfügung, indem Sie den Zielthread direkt anhalten und selbst durchlaufen, bis Sie den am häufigsten verwalteten Frame gefunden haben. Wenn Sie in diesem Fall keinen Seed bereitstellen, gibt DoStackSnapshot möglicherweise einen Fehlercode zurück oder überspringt einige Frames am Anfang 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 sollte.
  • Wenn Sie asynchrone Schritte ausführen, überschreiben Sie immer den ThreadDestroyed-Rückruf , um zu verhindern, dass die CLR einen Thread zerstört, bis der Stapellauf dieses Threads abgeschlossen ist.
  • Halten Sie keine Sperre aufrecht, während Ihr Profiler eine CLR-Funktion aufruft, die eine Garbage Collection auslösen kann.

Weitere Informationen zur Profilerstellungs-API finden Sie unter Profilerstellung (nicht verwaltet) auf der MSDN-Website.

Guthaben, bei dem Guthaben fällig ist

Ich möchte dem Rest des CLR-Profilerstellungs-API-Teams danken, da das Schreiben dieser Regeln wirklich eine Teamleistung war. Ein besonderer Dank geht an Sean Selitrennikoff, der einen Großteil dieses Inhalts früher inkarnation bereitgestellt hat.

 

Zum Autor

David ist schon länger entwickler bei Microsoft, als Man denkt, da er nur wenig Wissen und Reife hat. Obwohl es nicht mehr erlaubt ist, Code einzuchecken, bietet er dennoch Ideen für neue Variablennamen. David ist ein begeisterter Fan von Graf Chocula und besitzt sein eigenes Auto.