Sdílet prostřednictvím


Profiler Stack Walking in the .NET Framework 2.0: Basics and Beyond

 

Dne

David Broman
Microsoft Corporation

Platí pro:
   Microsoft .NET Framework 2.0
   Common Language Runtime (CLR)

Shrnutí: Popisuje, jak můžete naprogramovat profiler tak, aby procházel spravované zásobníky v modulu CLR (Common Language Runtime) rozhraní .NET Framework. (14 tištěných stránek)

Obsah

Úvod
Synchronní a asynchronní volání
Míchání
Buďte na svém nejlepším chování
Odtud až potud
Kredit v případě, že je kredit splatný
O autorovi

Úvod

Tento článek je určený pro každého, kdo má zájem o vytvoření profileru pro zkoumání spravovaných aplikací. Popíšeme vám, jak můžete naprogramovat profiler tak, aby procházel spravované zásobníky v modulu CLR (Common Language Runtime) rozhraní .NET Framework. Budu se snažit udržet náladu světlo, protože téma samo o sobě může být těžké jít někdy.

Rozhraní API pro profilaci ve verzi 2.0 modulu CLR má novou metodu s názvem DoStackSnapshot , která vašemu profileru umožňuje procházet zásobník volání aplikace, kterou profilujete. Verze 1.1 modulu CLR zveřejnila podobné funkce prostřednictvím rozhraní ladění v procesu. Ale procházení zásobníku volání je snazší, přesnější a stabilnější s DoStackSnapshot. DoStackSnapshot Metoda používá stejný zásobník walker, který používá systém uvolňování paměti, systém zabezpečení, systém výjimek a tak dále. Takže víš, že to musí být správné.

Přístup k úplnému trasování zásobníku dává uživatelům vašeho profileru možnost získat celkový přehled o tom, co se v aplikaci děje, když se stane něco zajímavého. V závislosti na aplikaci a na tom, co chce uživatel profilovat, si můžete představit, že uživatel chce zásobník volání při přidělení objektu, při načtení třídy, při vyvolání výjimky atd. I získání zásobníku volání pro něco jiného než událost aplikace – například událost časovače – by bylo pro vzorkovací profiler zajímavé. Když se podíváte na aktivní body v kódu, bude to ještě poučnější, když zjistíte, kdo volal funkci, která volala funkci obsahující aktivní bod.

Zaměřím se na získání trasování zásobníku pomocí rozhraní API DoStackSnapshot . Trasování zásobníku můžete získat také vytvořením stínových zásobníků: propojením FunkcíEnter a FunctionLeave můžete zachovat kopii spravovaného zásobníku volání pro aktuální vlákno. Sestavení stínového zásobníku je užitečné, pokud potřebujete informace o zásobníku vždy během provádění aplikace a pokud vám nevadí náklady na výkon, které mají kód profileru spuštěný při každém spravovaném volání a vrácení. Metoda DoStackSnapshot je nejlepší, pokud potřebujete mírně střídmější sestavy zásobníků, například v reakci na události. Dokonce i profiler vzorkování, který každých několik milisekund vytváří snímky zásobníku, je mnohem řidší než vytváření stínových zásobníků. DoStackSnapshot je proto vhodný pro vzorkování profilátorů.

Udělejte si stoh procházku na divoké straně

Je velmi užitečné mít možnost získat zásobníky volání, kdykoli je budete chtít. Ale s mocí přichází zodpovědnost. Uživatel profileru nechce, aby procházení zásobníku vedlo k narušení přístupu (AV) nebo zablokování v modulu runtime. Jako profiler spisovatel, musíte o své síly s opatrností. Budu mluvit o tom, jak používat DoStackSnapshot, a jak to udělat pečlivě. Jak uvidíte, čím více chcete s touto metodou dělat, tím těžší je dosáhnout toho, aby byla správná.

Pojďme se podívat na naše téma. Tady je postup, který volá váš profiler (najdete ho v rozhraní ICorProfilerInfo2 v 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); 

Následující kód je to, co CLR volá ve vašem profileru. (Najdete ho také v Corprof.idl.) Ukazatel na implementaci této funkce předáte v parametru zpětného volání z předchozího příkladu.

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

Je to jako sendvič. Když váš profiler chce procházet zásobník, zavoláte DoStackSnapshot. Než se CLR vrátí z tohoto volání, volá funkci StackSnapshotCallback několikrát, jednou pro každý spravovaný rámec nebo pro každé spuštění nespravovaných rámců v zásobníku. Tento sendvič je znázorněný na obrázku 1.

Obrázek 1: "Sendvič" volání během profilace

Jak můžete vidět z mých zápisů, CLR vás upozorní na snímky v opačném pořadí, než jak byly vloženy do zásobníku – první listový rámec (stisknuto jako poslední), hlavní snímek poslední (stisknuto jako první).

Co znamenají všechny parametry těchto funkcí? Nejsem připraven prodiskutovat je všechny, ale budu probírat několik z nich, počínaje DoStackSnapshot. (Za chvíli se dostanu k ostatním.) Hodnota infoFlags pochází z COR_PRF_SNAPSHOT_INFO výčtu v Corprof.idl a umožňuje řídit, zda clr poskytne registraci kontextů pro rámce, které hlásí. Pro clientData můžete zadat libovolnou hodnotu a CLR vám ji vrátí ve volání StackSnapshotCallback .

V StackSnapshotCallback CLR používá parametr funcId k předání hodnoty FunctionID aktuálně procházejícího rámce. Tato hodnota je 0, pokud aktuální rámec je spuštění nespravovaných snímků, o kterém budu mluvit později. Pokud je funcId nenulový, můžete předat funcId a frameInfo jiným metodám, například GetFunctionInfo2 a GetCodeInfo2, abyste získali další informace o funkci. Informace o této funkci můžete získat okamžitě, během procházení zásobníku, nebo případně uložit hodnoty funcId a získat informace o funkci později, což sníží váš dopad na spuštěnou aplikaci. Pokud později získáte informace o funkci, mějte na paměti, že hodnota frameInfo je platná pouze uvnitř zpětného volání, které vám ji poskytuje. I když je v pořádku uložit hodnoty funcId pro pozdější použití, neukládejte frameInfo pro pozdější použití.

Když se vrátíte z StackSnapshotCallback, obvykle vrátíte S_OK a CLR bude pokračovat v procházení zásobníku. Pokud chcete, můžete vrátit S_FALSE, která zastaví procházení zásobníku. Volání DoStackSnapshot pak vrátí CORPROF_E_STACKSNAPSHOT_ABORTED.

Synchronní a asynchronní volání

DoStackSnapshot můžete volat dvěma způsoby, synchronně a asynchronně. Synchronní volání je nejjednodušší. Provedete synchronní volání, když CLR volá jeden z profiler ICorProfilerCallback(2) metody a v odpovědi volání DoStackSnapshot procházet zásobník aktuální vlákno. To je užitečné, když chcete vidět, jak zásobník vypadá v zajímavém bodě oznámení, jako je ObjectAllocated. Chcete-li provést synchronní volání, zavoláte DoStackSnapshot z vaší metody ICorProfilerCallback(2) a předáte nulu nebo null pro parametry, o které jsem vám neřekl.

Asynchronní procházení zásobníku nastane, když procházíte zásobník jiného vlákna nebo vynuceně přerušíte vlákno, aby provedlo procházení zásobníku (na sobě nebo v jiném vlákně). Přerušení vlákna zahrnuje napadení instrukčního ukazatele vlákna, aby vynutilo spuštění vlastního kódu v libovolných časech. To je šíleně nebezpečné z příliš mnoha důvodů na to, aby sem vypsaly. Prosím, prostě to nedělej. Omezím svůj popis asynchronních trasování zásobníku na neautorující použití DoStackSnapshot k procházení samostatného cílového vlákna. Nazývám to "asynchronní", protože cílové vlákno bylo spuštěno v libovolném bodě v okamžiku, kdy začíná procházení zásobníku. Tuto techniku běžně používají profilátory vzorkování.

Chůze po celém někom jiném

Pojďme si trochu rozebít křížové vlákno – tedy asynchronní – zásobník. Máte dvě vlákna: aktuální vlákno a cílové vlákno. Aktuální vlákno je vlákno, které spouští DoStackSnapshot. Cílové vlákno je vlákno, jehož zásobník prochází DoStackSnapshot. Cílové vlákno určíte předáním ID vlákna v parametru vláknado DoStackSnapshot. Co se stane dál, není pro slabé srdce. Vzpomeňte si, že cílové vlákno spouštělo libovolný kód, když jste požádali o procházení zásobníku. ClR tedy pozastaví cílové vlákno a zůstane pozastavené po celou dobu, kdy se prochází. Dá se to dělat bezpečně?

Jsem ráda, že se ptáš. To je opravdu nebezpečné a budu mluvit později o tom, jak to udělat bezpečně. Nejdřív se ale dostanu do zásobníků ve smíšeném režimu.

Míchání

Spravovaná aplikace pravděpodobně nebude trávit všech svůj čas ve spravovaném kódu. Volání PInvoke a interoperabilita modelu COM umožňují spravovanému kódu volat nespravovaný kód a někdy znovu s delegáty. A spravovaný kód volá přímo do nespravovaného modulu runtime (CLR) pro kompilaci JIT, zpracování výjimek, uvolňování paměti atd. Při procházení zásobníku se tedy pravděpodobně setkáte se zásobníkem ve smíšeném režimu – některé rámce jsou spravované funkce a jiné nespravované funkce.

Vyrostou, už!

Než budu pokračovat, krátká mezihra. Každý ví, že zásobníky na našich moderních počítačích se rozrůstají (tj. "push") na menší adresy. Když ale tyto adresy vizualizujeme v našich myslích nebo na tabulích, nesouhlasíme s tím, jak je uspořádat svisle. Někteří z nás si představují, že stack vyrůstá (malé adresy na vrcholu); někteří vidí, že roste dolů (malé adresy na spodní straně). V našem týmu jsme v tomto problému také rozděleni. Rozhodl(a) jsem se připojit k jakémukoli ladicímu programu, který jsem kdy používal – trasování zásobníku volání a výpisy paměti mi říkají, že malé adresy jsou "nad" velkými adresami. Takže stohy vyrostou; hlavní je dole, list volaný nahoře. Pokud nesouhlasíte, budete muset udělat nějaké mentální uspořádání, abyste se dostali přes tuto část článku.

Číšník, v mém zásobníku jsou díry

Teď, když mluvíme stejným jazykem, se podíváme na zásobník smíšeného režimu. Obrázek 2 znázorňuje příklad zásobníku smíšeného režimu.

Obrázek 2. Zásobník se spravovanými a nespravovanými rámci

Když trochu ustoupíme, stojí za to pochopit, proč DoStackSnapshot existuje. Je tu proto, aby vám pomohl procházet spravované rámce v zásobníku. Pokud byste se pokusili procházet spravované rámce sami, získali byste nespolehlivé výsledky, zejména u 32bitových systémů, kvůli některým blbým konvencím volání, které se používají ve spravovaném kódu. CLR těmto konvencím volání rozumí a DoStackSnapshot vám proto může pomoct je dekódovat. DoStackSnapshot však není kompletní řešení, pokud chcete být schopni procházet celý zásobník, včetně nespravovaných rámců.

Tady máte na výběr:

Možnost 1: Nedělejte nic a zásobníky sestav s "nespravovanými otvory" pro vaše uživatele, nebo ...

Možnost 2: Napište si vlastní nespravovaný stack walker vyplnit tyto díry.

Když DoStackSnapshot narazí na blok nespravovaných rámců, volá funkci StackSnapshotCallback s parametrem funcId nastaveným na hodnotu 0, jak jsem zmínil dříve. Pokud používáte možnost 1, jednoduše ve zpětném volání nedělejte nic, když je funcId 0. CLR vás znovu zavolá pro další spravovaný rámec a v tomto okamžiku se můžete probudit.

Pokud se nespravovaný blok skládá z více než jednoho nespravovaného rámce, CLR stále volá StackSnapshotCallback pouze jednou. Mějte na paměti, že CLR se nesnaží dekódovat nespravovaný blok – obsahuje speciální informace pro účastníky programu Insider, které mu pomůžou přeskočit blok na další spravovaný rámec a tímto způsobem postupuje. CLR nemusí nutně vědět, co je uvnitř nespravovaného bloku. To je pro vás, abyste to zjistili, proto možnost 2.

První krok je nesměšný.

Bez ohledu na to, kterou možnost zvolíte, není vyplnění nespravovaných otvorů jedinou tvrdou částí. Jen začít chodit může být výzva. Podívejte se na výše uvedený zásobník. Nahoře je nespravovaný kód. Někdy budete mít štěstí a nespravovaný kód bude kód COM nebo PInvoke . Pokud ano, clr je dostatečně chytrý, aby věděl, jak ho přeskočit, a začne chodit od prvního spravovaného rámce (V příkladu D). I tak ale můžete chtít projít horní nespravovaný blok, abyste mohli nahlásit co nejúplnější zásobník.

I když nechcete projít horní blok, můžete být nuceni přesto – pokud nemáte štěstí, nespravovaný kód není kód COM nebo PInvoke , ale pomocný kód v samotném CLR, například kód pro kompilaci JIT nebo uvolňování paměti. Pokud je to váš případ, clr nebude moct bez vaší pomoci najít rámeček D. Nesesené volání do DoStackSnapshot tedy bude mít za následek chybu CORPROF_E_STACKSNAPSHOT_UNMANAGED_CTX nebo CORPROF_E_STACKSNAPSHOT_UNSAFE. (Mimochodem, opravdu stojí za to navštívit corerror.h.)

Všimněte si, že jsem použil(a) slovo "neseedováno". DoStackSnapshot přebírá počáteční kontext pomocí parametrů context a contextSize . Slovo "kontext" je přetížené mnoha významy. V tomto případě mluvím o kontextu registru. Pokud si prohlédnete hlavičky Windows závislé na architektuře (například nti386.h), najdete strukturu s názvem CONTEXT. Obsahuje hodnoty registrů procesoru a představuje stav procesoru v určitém okamžiku v čase. To je ten kontext, o kterém mluvím.

Pokud předáte hodnotu null pro parametr kontextu , bude procházení zásobníku unseded a CLR začíná nahoře. Pokud však předáte hodnotu, která není null pro parametr kontextu , která představuje stav procesoru na určitém místě dole v zásobníku (například odkazuje na rámec D), clR provede procházení zásobníku s vaším kontextem. Ignoruje skutečný vrchol zásobníku a začíná tam, kde ho namíříte.

Dobře, není to tak docela pravda. Kontext, který předáte DoStackSnapshot , je spíše nápovědou než přímou direktivou. Pokud je modul CLR jistý, že najde první spravovaný rámec (protože hlavní nespravovaný blok je PInvoke nebo kód COM), provede to a vaše počáteční hodnota bude ignorovat. Neberte to ale osobně. CLR se vám snaží pomoct tím, že poskytuje co nejpřesnější postup zásobníku. Vaše počáteční hodnota je užitečná jenom v případě, že nejspravovanější nespravovaný blok je pomocný kód v samotném modulu CLR, protože nemáme žádné informace, které by nám pomohly ho přeskočit. Proto se vaše počáteční hodnota používá pouze v případě, že CLR nemůže sám určit, kde má začít procházku.

Možná vás zajímá, jak nám můžete poskytnout semeno. Pokud cílové vlákno ještě není pozastavené, nemůžete jednoduše procházet zásobník cílového vlákna, najít rámec D a vypočítat tak kontext počátečního vlákna. A přesto vám říkám, abyste vypočítali svůj počáteční kontext tím, že provedete svou nespravovanou procházku před voláním DoStackSnapshot a tím dříve, než se DoStackSnapshot postará o pozastavení cílového vlákna za vás. Musí být cílové vlákno pozastaveno vámi a CLR? Vlastně ano.

Myslím, že je čas na balet. Ale než se dostanu příliš hluboko, všimněte si, že problém, zda a jak seed stoh chůze platí pouze pro asynchronní procházky. Pokud provádíte synchronní procházku, DoStackSnapshot vždy dokáže najít cestu k nejspravovanějšímu snímku bez vaší pomoci – bez nutnosti počátečního nastavení.

Vše pohromadě

Pro skutečně dobrodružný profiler, který provádí asynchronní, křížové, seeded stack chůze při vyplňování nespravovaných otvorů, zde je, jak by vypadala stoh chůze. Předpokládejme, že zásobník, který je zde znázorněný, je stejný, jako jste viděli na obrázku 2, jen je trochu rozdělený.

Obsah zásobníku Akce profileru a CLR

1. Pozastavíte cílové vlákno. (Počet pozastavení cílového vlákna je teď 1.)

2. Získáte aktuální kontext registru cílového vlákna.

3. Určíte, zda kontext registru odkazuje na nespravovaný kód-to znamená, že zavoláte ICorProfilerInfo2::GetFunctionFromIP a zkontrolujete, zda vrátíte hodnotu FunctionID 0.

4. Vzhledem k tomu, že v tomto příkladu kontext registru ukazuje na nespravovaný kód, provedete nespravovaný zásobník, dokud nenajdete nejlépe spravovaný rámec (funkce D).

5. Zavoláte DoStackSnapshot s počátečním kontextem a CLR znovu pozastaví cílové vlákno. (Počet pozastavení je teď 2.) Sendvič začíná.
a. CLR volá funkci StackSnapshotCallback s ID funkce pro D.
b. CLR volá funkci StackSnapshotCallback s ID funkce rovno 0. Musíš si projít tenhle blok sám. Můžete ho zastavit, jakmile se dostanete k prvnímu spravovanému rámci. Alternativně můžete nespravovanou procházku podvést a odložit na chvíli po dalším zpětném volání, protože při příštím zpětném volání se dozvíte, kde přesně začíná další spravovaný rámec a kde má nespravovaná procházka končit.
c. ClR volá funkci StackSnapshotCallback s ID funkce jazyka C.
d. ClR volá funkci StackSnapshotCallback s ID funkce pro B.
e. CLR volá funkci StackSnapshotCallback s ID funkce rovno 0. Znovu, musíte se projít tímto blokem sami.
f. ClR volá funkci StackSnapshotCallback s ID funkce pro A.
například ClR volá funkci StackSnapshotCallback s ID funkce pro Main.

h. DoStackSnapshot "obnoví" cílové vlákno voláním rozhraní API Win32 ResumeThread(), které sníží počet pozastavení vlákna (počet pozastavení je teď 1) a vrátí hodnotu. Sendvič je hotový.
6. Obnovíte cílové vlákno. Jeho počet pozastavení je teď 0, takže vlákno se fyzicky obnoví.

Buďte na svém nejlepším chování

Ok, to je příliš moc energie bez nějaké vážné opatrnosti. V nejpokročilejším případě reagujete na přerušení časovače a pozastavujete vlákna aplikací libovolně, aby procházela jejich zásobníky. Jejda!

Být v pořádku je těžké a zahrnuje pravidla, která nejsou na první pohled zřejmá. Tak se pusťme dovnitř.

Špatné semeno

Začněme jednoduchým pravidlem: nepoužívejte špatné semeno. Pokud váš profiler zadá při volání DoStackSnapshot neplatné počáteční (bez null), clR vám poskytne špatné výsledky. Podívá se na zásobník, kam ho nasměrujete, a vytvoří předpoklady o tom, co mají hodnoty v zásobníku představovat. To způsobí, že CLR bude dereference toho, co se předpokládá jako adresy v zásobníku. Vzhledem k chybnému počátečnímu datu clR přesune hodnoty na neznámé místo v paměti. CLR dělá vše, co je v jeho moci, aby se zabránilo úplně náhodnému audiovizuálnímu prostředí, které by zrušovalo proces profilace. Ale opravdu byste se měli snažit, aby se vaše semeno správně.

Strasti pozastavení

Další aspekty pozastavení vláken jsou natolik složité, že vyžadují více pravidel. Když se rozhodnete provést procházení napříč vlákny, rozhodli jste se minimálně požádat CLR o pozastavení vláken vaším jménem. Navíc, pokud chcete procházet nespravovaný blok v horní části zásobníku, rozhodli jste se pozastavit vlákna sami bez vyvolání moudrosti CLR o tom, jestli je to v tuto chvíli dobrý nápad.

Pokud jste chodili na hodiny počítačových věd, pravděpodobně si pamatujete problém "stolování filosofů". Skupina filosofů sedí u stolu, každý s jednou vidlicí vpravo a jednou vlevo. Podle problému potřebují každý dva forky k jídlu. Každý filosof zvedne svou pravou fork, ale pak nikdo nemůže zvednout levou vidli, protože každý filosof čeká, až filosof nalevo odloží potřebný fork. A pokud jsou filosofové usazeni u kruhového stolu, máte cyklus čekání a spoustu prázdných žaludků. Důvod, proč všichni hladovějí, je to, že porušují jednoduché pravidlo zabránění vzájemnému zablokování: pokud potřebujete více zámků, vždy je vezměte ve stejném pořadí. Při dodržení tohoto pravidla se vyhnete cyklu, kdy A čeká na B, B čeká na C a C čeká na A.

Předpokládejme, že aplikace dodržuje pravidlo a vždy přebírá zámky ve stejném pořadí. Teď přichází komponenta (například váš profiler) a začne libovolně pozastavovat vlákna. Složitost se podstatně zvýšila. Co když teď podvazovač potřebuje vzít zámek, který drží suspendee? Nebo co když podvazečka potřebuje zámek podržený vláknem, které čeká na zámek podržené jiným vláknem, které čeká na zámek podržené suspendee? Pozastavení přidává do grafu závislostí na vláknech novou hranu, která může zavést cykly. Pojďme se podívat na některé konkrétní problémy.

Problém 1: Pozastavený uživatel vlastní zámky, které jsou potřebné pro podvazeče nebo které jsou potřebné pro vlákna, na které je podvazovací zařízení závislé.

Problém 1a: Zámky jsou zámky CLR.

Jak si můžete představit, CLR provádí hodně synchronizace vláken, a proto má několik zámků, které se používají interně. Při volání DoStackSnapshot CLR zjistí, že cílové vlákno vlastní zámek CLR, který aktuální vlákno (vlákno, které volá DoStackSnapshot) potřebuje k provedení zásobníku. Když nastane tato podmínka, CLR odmítne provést pozastavení a DoStackSnapshot se okamžitě vrátí s chybou CORPROF_E_STACKSNAPSHOT_UNSAFE. V tomto okamžiku, pokud jste pozastavili vlákno sami před voláním DoStackSnapshot, pak obnovíte vlákno sami a vyhnete se problému.

Problém 1b: Zámky jsou zámky vašeho vlastního profileru.

Tento problém je ve skutečnosti spíše problém se zdravým rozumem. Tady a tam můžete mít vlastní synchronizaci vláken. Představte si, že vlákno aplikace (vlákno A) narazí na zpětné volání profileru a spustí nějaký kód profileru, který převezme jeden ze zámků profileru. Potom vlákno B musí procházet vlákno A, což znamená, že vlákno B pozastaví vlákno A. Je třeba si uvědomit, že když je vlákno A pozastavené, neměli byste se vlákno B pokoušet o převzetí jakýchkoli vlastních zámků profileru, které by mohlo vlákno A vlastnit. Například vlákno B spustí StackSnapshotCallback během procházení zásobníku, takže byste neměli zamykat během zpětného volání, které by mohlo vlastnit vlákno A.

Problém 2: Při pozastavení cílového vlákna se cílové vlákno pokusí vás pozastavit.

Můžeš říct: "To se nemůže stát!" Věřte tomu nebo ne, může, pokud:

  • Vaše aplikace běží na víceprocesorovém poli a
  • Vlákno A běží na jednom procesoru a vlákno B na jiném a
  • Vlákno A se pokouší pozastavit vlákno B, zatímco vlákno B se pokouší pozastavit vlákno A.

V takovém případě je možné, že obě pozastavení vyhraje a obě vlákna skončí pozastaveny. Vzhledem k tomu, že každé vlákno čeká, až ho druhé probudí, zůstanou navždy pozastavené.

Tento problém je více zneklidnění než problém 1, protože se nemůžete spoléhat na CLR, aby před voláním DoStackSnapshot zjistil, že se vlákna vzájemně pozastaví. A po provedení pozastavení už je pozdě!

Proč se cílové vlákno pokouší pozastavit profiler? V hypotetické, špatně napsané profiler, může být stack-walking kód spolu s kódem pozastavení spuštěn libovolným počtem vláken v libovolném čase. Představte si, že vlákno A se pokouší procházet vlákno B ve stejnou dobu, kdy se vlákno B pokouší procházet vlákno A. Oba se snaží vzájemně pozastavit, protože oba spouští část SuspendThread rutiny stack-walking profileru. Win i profilovaná aplikace jsou zablokování. Toto pravidlo je zřejmé – neumožněte vašemu profileru spouštět stack-walking kód (a tím i kód pozastavení) na dvou vláknech současně!

Méně zřejmým důvodem, proč se cílové vlákno může pokusit pozastavit vaše pěší vlákno, je vnitřní fungování CLR. ClR pozastavuje vlákna aplikací, aby pomohla s úlohami, jako je uvolňování paměti. Pokud se váš chodec pokusí projít (a tak pozastavit) vlákno provádějící uvolňování paměti ve stejnou dobu, kdy se vlákno uvolňování paměti pokusí pozastavit chodec, procesy budou zablokované.

Ale je snadné se tomu problému vyhnout. Modul CLR pozastaví pouze vlákna, která je potřeba pozastavit, aby mohl provádět svou práci. Představte si, že se na procházce zásobníku podílejí dvě vlákna. Vlákno W je aktuální vlákno (vlákno provádějící procházku). Vlákno T je cílové vlákno (vlákno, jehož zásobník je procházený). Dokud vlákno W nikdy nespustí spravovaný kód, a proto není předmětem uvolňování paměti CLR, CLR se nikdy nepokusí pozastavit Vlákno W. To znamená, že je bezpečné, aby váš profiler pozastavil vlákno W vlákno T.

Pokud píšete profilátor vzorkování, je zcela přirozené, že to všechno zajistíte. Obvykle budete mít samostatné vlákno vlastního vytvoření, které reaguje na přerušení časovače a které prochází zásobníky dalších vláken. Nazvěte toto vlákno vzorkovače. Vzhledem k tomu, že vlákno sampleru vytvoříte sami a máte kontrolu nad tím, co provádí (a proto nikdy nespustí spravovaný kód), clR nebude mít důvod ho pozastavit. Když profiler navrhnete tak, aby vytvořil vlastní vlákno vzorkování, které provede všechny kroky zásobníku, vyhnete se také problému se špatně napsaným profilerem popsaným výše. Vlákno sampleru je jediným vláknem vašeho profileru, které se pokouší procházet nebo pozastavit jiná vlákna, takže se váš profiler nikdy nepokusí vlákno vzorkovače přímo pozastavit.

Toto je naše první netriviální pravidlo, takže pro zdůraznění zopakuji:

Pravidlo 1: Pouze vlákno, které nikdy nespustilo spravovaný kód, by mělo pozastavit jiné vlákno.

Nikdo nemá rád chodit mrtvolu

Pokud provádíte procházení zásobníku mezi vlákny, musíte zajistit, aby cílové vlákno zůstalo naživu po dobu trvání chůze. To, že předáte cílové vlákno jako parametr volání DoStackSnapshot , neznamená, že jste k němu implicitně přidali nějaký druh odkazu na životnost. Aplikace může vlákno kdykoli opustit. Pokud k tomu dojde, když se pokoušíte procházet vlákno, můžete snadno způsobit narušení přístupu.

ClR naštěstí upozorní profilery, když se chystá zničit vlákno, pomocí vhodně pojmenovaného Zpětného volání ThreadDestroyed definovaného rozhraním ICorProfilerCallback(2). Je vaší zodpovědností implementovat ThreadDestroyed a nechat ho čekat, dokud se vlákno nedokončí. To je dostatečně zajímavé, aby se kvalifikoval jako naše další pravidlo:

Pravidlo 2: Přepište zpětné volání ThreadDestroyed a nechte implementaci počkat, až dokončíte procházení zásobníku vlákna, aby bylo zničeno.

Následující pravidlo 2 blokuje CLR v zničení vlákna, dokud nebudete hotovi s procházením zásobníku tohoto vlákna.

Uvolňování paměti vám pomůže vytvořit cyklus

Věci mohou být v tomto okamžiku trochu matoucí. Začněme textem dalšího pravidla a dešifrujme ho odtud:

Pravidlo 3: Během volání profileru nedržte zámek, který může aktivovat uvolňování paměti.

Zmínil jsem se dříve, že je špatný nápad pro váš profiler držet jeden, pokud jeho vlastní zámky, pokud vlastní vlákno může být pozastaveno, a pokud vlákno může být procházené jiným vláknem, které potřebuje stejný zámek. Pravidlo 3 vám pomůže vyhnout se jemnějšímu problému. Tady říkám, že byste neměli držet žádné z vašich vlastních zámků, pokud se vlastnící vlákno chystá volat metodu ICorProfilerInfo(2), která může aktivovat uvolňování paměti.

Mělo by vám pomoct několik příkladů. V prvním příkladu předpokládejme, že vlákno B provádí uvolňování paměti. Jedná se o následující posloupnost:

  1. Vlákno A přebírá a nyní vlastní jeden ze zámků profileru.
  2. Vlákno B volá zpětné volání profileru GarbageCollectionStarted .
  3. Bloky vlákna B na zámku profileru z kroku 1.
  4. Vlákno A spustí funkci GetClassFromTokenAndTypeArgs .
  5. Volání GetClassFromTokenAndTypeArgs se pokusí aktivovat uvolňování paměti, ale zjistí, že uvolňování paměti již probíhá.
  6. Vlákno A blokuje a čeká na dokončení uvolňování paměti aktuálně probíhajícího (vlákno B). Vlákno B však čeká na vlákno A kvůli zámku profileru.

Obrázek 3 znázorňuje scénář v tomto příkladu:

Obrázek 3: Vzájemné zablokování mezi profilerem a uvolňováním paměti

Druhý příklad je trochu jiný scénář. Pořadí je:

  1. Vlákno A přebírá a nyní vlastní jeden z vašich zámků profileru.
  2. Vlákno B volá zpětné volání ModuleLoadStarted profileru.
  3. Bloky vlákna B na zámku profileru z kroku 1.
  4. Vlákno A spustí funkci GetClassFromTokenAndTypeArgs .
  5. Volání GetClassFromTokenAndTypeArgs aktivuje uvolňování paměti.
  6. Vlákno A (které teď provádí uvolňování paměti) čeká, až bude vlákno B připravené ke shromáždění. Ale vlákno B čeká na vlákno A kvůli zámku profileru.
  7. Druhý příklad znázorňuje obrázek 4.

Obrázek 4. Vzájemné zablokování mezi profilerem a čekajícím uvolňováním paměti

Už jsi strávila šílenství? Podstatou problému je, že uvolňování paměti má vlastní synchronizační mechanismy. Výsledek v prvním příkladu nastane, protože může najednou dojít pouze k jednomu uvolňování paměti. To je nepochybně okrajový případ, protože uvolňování paměti obvykle nedochází tak často, že jeden musí čekat na další, pokud nepracujete ve stresových podmínkách. I tak platí, že pokud profilujete dostatečně dlouho, dojde k tomuto scénáři a musíte se na něj připravit.

Výsledek v druhém příkladu nastane, protože vlákno provádějící uvolňování paměti musí čekat, až ostatní vlákna aplikace budou připravena pro shromažďování. Problém nastává, když do mixu zavedete jeden z vlastních zámků, čímž vznikne cyklus. V obou případech je pravidlo 3 porušeno povolením vlákna A vlastnit jeden z zámků profileru a poté volání GetClassFromTokenAndTypeArgs. (Volání jakékoli metody, která může aktivovat uvolňování paměti, ve skutečnosti stačí k tomu, aby se proces zmátl.)

Pravděpodobně už máte několik otázek.

Otázka: Jak zjistíte, které metody ICorProfilerInfo(2) můžou aktivovat uvolňování paměti?

A. Plánujeme to zdokumentovat na MSDN, nebo alespoň na mém blogu nebo blogu Jonathana Keljo.

Otázka: Co to má společného s procházením zásobníku? Není tu žádná zmínka o DoStackSnapshot.

A. Správně. A DoStackSnapshot není ani jedním z těch ICorProfilerInfo(2) metody, které aktivují uvolňování paměti. Důvod, proč zde diskutuji o pravidle 3, je, že právě ti dobrodružní programátoři asynchronně procházející zásobníky z libovolných vzorků, kteří budou s největší pravděpodobností implementovat své vlastní zámky profileru, a tak budou náchylní k pádu do této pasti. Pravidlo 2 v podstatě říká, abyste do profileru přidali synchronizaci. Je docela pravděpodobné, že profiler vzorkování bude mít také jiné synchronizační mechanismy, například ke koordinaci čtení a zápisu sdílených datových struktur v libovolných časech. K tomuto problému samozřejmě může docházet u profileru, který se nikdy nedotkne DoStackSnapshot .

Odtud až potud

Zakončím stručným souhrnem hlavních bodů. Tady jsou důležité body, které je potřeba si zapamatovat:

  • Synchronní procházení zásobníku zahrnuje procházení aktuálního vlákna v reakci na zpětné volání profileru. Ty nevyžadují seeding, pozastavení ani žádná zvláštní pravidla.
  • Asynchronní procházky vyžadují počáteční, pokud je horní část zásobníku nespravovaný kód a není součástí volání PInvoke nebo COM. Počáteční hodnotu zadáte tak, že přímo pozastavíte cílové vlákno a budete ho procházet sami, dokud nenajdete horní spravovaný rámec. Pokud v tomto případě nezadáte počáteční hodnotu, může DoStackSnapshot vrátit kód chyby nebo přeskočit některé snímky v horní části zásobníku.
  • Pokud potřebujete pozastavit vlákna, mějte na paměti, že pouze vlákno, které nikdy nespustí spravovaný kód, by mělo pozastavit jiné vlákno.
  • Při provádění asynchronních procházení vždy přepište zpětné volání ThreadDestroyed tak, aby blokoval CLR v zničení vlákna, dokud nebude dokončena zásobníku vlákna.
  • Během volání profileru do funkce CLR, která může aktivovat uvolňování paměti, nedržte zámek.

Další informace o rozhraní API pro profilaci naleznete v tématu Profilace (nespravované) na webu MSDN.

Kredit v případě, že je kredit splatný

Chtěl bych uvést poznámku o poděkování zbytku týmu ROZHRANÍ API pro profilaci CLR, protože psaní těchto pravidel bylo opravdu týmovou snahou. Zvláštní díky Sean Selitrennikoff, který poskytl dřívější inkarnaci velké části tohoto obsahu.

 

O autorovi

David pracuje v Microsoftu jako vývojář déle, než si myslíte, vzhledem k jeho omezeným znalostem a vyspělosti. I když už nemá povolené vracení kódu se změnami, stále nabízí nápady na nové názvy proměnných. David je nadšený fanoušek Count Chocula a vlastní auto.