Profiler Stack Walking in the .NET Framework 2.0: Basics and Beyond
Wrzesień 2006 r.
David Broman
Microsoft Corporation
Dotyczy:
Microsoft .NET Framework 2.0
Środowisko uruchomieniowe języka wspólnego (CLR)
Krótki opis: Opisuje sposób programowania profilera w celu chodzenia zarządzanych stosów w środowisku uruchomieniowym języka wspólnego (CLR) .NET Framework. (14 wydrukowanych stron)
Zawartość
Wprowadzenie
Synchroniczne i asynchroniczne wywołania
Mieszanie go w górę
Bądź na najlepszym zachowaniu
Wystarczająca ilość
Środki, w przypadku których środki są należne
Informacje o autorze
Wprowadzenie
Ten artykuł jest przeznaczony dla wszystkich osób zainteresowanych tworzeniem profilera w celu zbadania zarządzanych aplikacji. Opiszę, jak można programować profilera w celu chodzenia zarządzanych stosów w środowisku uruchomieniowym języka wspólnego (CLR) .NET Framework. Postaram się zachować światło nastroju, ponieważ sam temat może być ciężki będzie czasami.
Interfejs API profilowania w wersji 2.0 środowiska CLR ma nową metodę o nazwie DoStackSnapshot , która umożliwia profilerowi przejście stosu wywołań aplikacji, którą profilujesz. Wersja 1.1 środowiska CLR ujawniła podobne funkcje za pośrednictwem interfejsu debugowania w procesie. Ale chodzenie stosu wywołań jest łatwiejsze, dokładniejsze i bardziej stabilne za pomocą doStackSnapshot. Metoda DoStackSnapshot używa tego samego modułu stosu używanego przez moduł odśmiecania pamięci, systemu zabezpieczeń, systemu wyjątków itd. Więc wiesz, że to musi być w porządku.
Dostęp do pełnego śledzenia stosu daje użytkownikom profilera możliwość uzyskania dużego obrazu tego, co dzieje się w aplikacji, gdy coś interesującego się stanie. W zależności od aplikacji i tego, co użytkownik chce profilować, można sobie wyobrazić, że użytkownik chce stosu wywołań, gdy obiekt zostanie przydzielony, gdy klasa zostanie załadowana, gdy zostanie zgłoszony wyjątek itd. Nawet uzyskanie stosu wywołań dla zdarzenia innego niż zdarzenie aplikacji — na przykład zdarzenie czasomierza — byłoby interesujące dla profilera próbkowania. Patrząc na gorące punkty w kodzie staje się bardziej oświetlające, gdy można zobaczyć, kto nazwał funkcję, która nazwała funkcję, która nazwała funkcję zawierającą hot spot.
Skupię się na pobieraniu śladów stosu za pomocą interfejsu API DoStackSnapshot . Innym sposobem na uzyskanie śladów stosu jest utworzenie stosów w tle: możesz podłączyć funkcjęEnter i funkcjęLeave , aby zachować kopię zarządzanego stosu wywołań dla bieżącego wątku. Kompilacja stosu w tle jest przydatna, jeśli potrzebujesz informacji o stosie przez cały czas podczas wykonywania aplikacji i jeśli nie masz nic przeciwko kosztowi wydajności uruchamiania kodu profilera na każdym zarządzanym wywołaniu i zwracaniu. Metoda DoStackSnapshot jest najlepsza, jeśli potrzebujesz nieco rozrzedżonego raportowania stosów, takich jak w odpowiedzi na zdarzenia. Nawet profiler próbkowania wykonujący migawki stosu co kilka milisekund jest znacznie rozrzedzonych niż tworzenie stosów cieni. Dlatego DoStackSnapshot dobrze nadaje się do profilowania próbkowania.
Weź stos spacer po dzikiej stronie
Bardzo przydatne jest uzyskanie stosów wywołań za każdym razem, gdy chcesz. Ale z mocą przychodzi odpowiedzialność. Użytkownik profilera nie chce, aby stos przechodził w celu spowodowania naruszenia dostępu (AV) lub zakleszczenia w środowisku uruchomieniowym. Jako pisarz profilera musisz dzierżyć swoją moc z troską. Będę mówić o tym, jak używać doStackSnapshot i jak to zrobić ostrożnie. Jak zobaczysz, tym bardziej chcesz zrobić z tą metodą, tym trudniej jest uzyskać to dobrze.
Przyjrzyjmy się naszemu tematowi. Oto, co wywołuje profiler (można to znaleźć w interfejsie ICorProfilerInfo2 w 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);
Poniższy kod jest tym, co clR wywołuje na profilerze. (Można to również znaleźć w pliku Corprof.idl).) Wskaźnik do implementacji tej funkcji należy przekazać w parametrze wywołania zwrotnego z poprzedniego przykładu.
typedef HRESULT __stdcall StackSnapshotCallback(
FunctionID funcId,
UINT_PTR ip,
COR_PRF_FRAME_INFO frameInfo,
ULONG32 contextSize,
BYTE context[],
void *clientData);
To jak kanapka. Gdy profiler chce chodzić po stosie, wywołujesz metodę DoStackSnapshot. Zanim clR powróci z tego wywołania, wywołuje funkcję StackSnapshotCallback kilka razy, raz dla każdej zarządzanej ramki lub dla każdego przebiegu niezarządzanych ramek na stosie. Rysunek 1 przedstawia tę kanapkę.
Rysunek 1. "kanapka" wywołań podczas profilowania
Jak widać na moich notacjach, CLR powiadamia o ramkach w odwrotnej kolejności od sposobu ich wypchnięcia na stos — ramka liścia najpierw (wypchnięta ostatnio), ramka główna ostatnia (wypchnięta jako pierwsza).
Jakie są wszystkie parametry tych funkcji? Nie jestem jeszcze gotowy do omówienia ich wszystkich, ale omówię kilka z nich, począwszy od DoStackSnapshot. (Dostanę się do reszty w ciągu kilku chwil). Wartość infoFlags pochodzi z COR_PRF_SNAPSHOT_INFO wyliczenie w corprof.idl, i umożliwia kontrolowanie, czy CLR da rejestr kontekstów dla ramek, które raportuje. W wywołaniu StackSnapshotCallback możesz określić dowolną wartość, jaką chcesz dla parametru clientData.
W pliku StackSnapshotCallback clR używa parametru funcId , aby przekazać wartość FunctionID aktualnie chodzinej ramki. Ta wartość to 0, jeśli bieżąca ramka jest uruchomieniem niezarządzanych ramek, o których będę mówić później. Jeśli funcId jest nonzero, możesz przekazać funcId i frameInfo do innych metod, takich jak GetFunctionInfo2 i GetCodeInfo2, aby uzyskać więcej informacji o funkcji. Te informacje o funkcji można uzyskać od razu podczas stosu lub alternatywnie zapisać wartości funcId i uzyskać informacje o funkcji później, co zmniejsza wpływ na uruchomioną aplikację. Jeśli później uzyskasz informacje o funkcji, pamiętaj, że wartość frameInfo jest prawidłowa tylko wewnątrz wywołania zwrotnego, które daje. Mimo że można zapisać wartości funcId do późniejszego użycia, nie zapisuj ramkiInfo do późniejszego użycia.
Po powrocie z stackSnapshotCallback zazwyczaj zwraca się S_OK , a CLR będzie kontynuować chodzenie stosu. Jeśli chcesz, możesz zwrócić S_FALSE, co zatrzymuje spacer stosu. Wywołanie doStackSnapshot zwróci CORPROF_E_STACKSNAPSHOT_ABORTED.
Synchroniczne i asynchroniczne wywołania
Możesz wywołać metodę DoStackSnapshot na dwa sposoby, synchronicznie i asynchronicznie. Synchroniczne wywołanie jest najłatwiejszym rozwiązaniem. Wykonasz synchroniczne wywołanie, gdy CLR wywołuje jedną z metod ICorProfilerCallback(2) profilera profilera , a w odpowiedzi wywołasz metodę DoStackSnapshot , aby przejść stos bieżącego wątku. Jest to przydatne, gdy chcesz zobaczyć, jak wygląda stos w interesującym punkcie powiadomień, takim jak ObjectAllocated. Aby wykonać wywołanie synchroniczne, należy wywołać metodę DoStackSnapshot z metody ICorProfilerCallback(2), przekazując zero lub null dla parametrów, o których nie powiedziałem.
Asynchroniczny spacer stosu występuje, gdy przechodzisz stos innego wątku lub siłowo przerywasz wątek, aby wykonać spacer stosu (na samym lub w innym wątku). Przerywanie wątku polega na porwaniu wskaźnika instrukcji wątku, aby wymusić wykonanie własnego kodu w dowolnym czasie. Jest to szalenie niebezpieczne z zbyt wielu powodów, aby wymienić tutaj. Proszę, po prostu nie rób tego. Ograniczę mój opis asynchronicznego stosu przechodzi do nie porwania zastosowań DoStackSnapshot , aby chodzić oddzielny wątek docelowy. Nazywam to "asynchroniczne", ponieważ wątek docelowy był wykonywany w dowolnym punkcie w momencie rozpoczęcia stosu. Ta technika jest często używana przez profilery próbkowania.
Chodzenie przez kogoś innego
Podzielmy krzyżowy wątek — czyli asynchronicznie — stos idzie trochę. Istnieją dwa wątki: bieżący wątek i wątek docelowy. Bieżący wątek to wątek wykonujący element DoStackSnapshot. Wątek docelowy jest wątkiem, którego stos jest kierowany przez doStackSnapshot. Należy określić wątek docelowy, przekazując jego identyfikator wątku w parametrze wątku do doStackSnapshot. To, co się dzieje dalej, nie jest dla omdlenia serca. Pamiętaj, że wątek docelowy wykonywał dowolny kod po wyświetleniu monitu o przejście jego stosu. Dlatego CLR zawiesza wątek docelowy i pozostaje zawieszony przez cały czas, że jest chodzić. Czy można to zrobić bezpiecznie?
Dobre pytanie. Jest to rzeczywiście niebezpieczne, a ja porozmawiam później o tym, jak to zrobić bezpiecznie. Ale po pierwsze, mam zamiar dostać się do stosów trybu mieszanego.
Mieszanie go w górę
Aplikacja zarządzana prawdopodobnie nie spędza całego czasu w kodzie zarządzanym. Wywołania PInvoke i międzyoperacyjności MODELU COM umożliwiają wywołanie kodu zarządzanego do niezarządzanych kodów, a czasami z powrotem z delegatami. Kod zarządzany wywołuje się bezpośrednio do niezarządzanego środowiska uruchomieniowego (CLR), aby wykonać kompilację JIT, obsługiwać wyjątki, wykonywać odzyskiwanie pamięci itd. Dlatego podczas chodzenia stosu prawdopodobnie napotkasz stos trybu mieszanego — niektóre ramki są funkcjami zarządzanymi, a inne są funkcjami niezarządzanymi.
Dorastaj, już!
Zanim będę kontynuować, krótkie interludium. Każdy wie, że stosy na naszych nowoczesnych komputerach rosną (czyli "wypychanie") do mniejszych adresów. Ale kiedy wizualizujemy te adresy w naszych umysłach lub na tablicach, nie zgadzamy się z sortowaniem ich w pionie. Niektórzy z nas wyobrażają sobie dorastanie stosu (małe adresy na górze); niektórzy widzą go rosnąco (małe adresy na dole). Dzielimy się również na ten problem w naszym zespole. Wybieram po stronie każdego debugera, którego kiedykolwiek używałem — ślady stosu wywołań i zrzuty pamięci mówią mi, że małe adresy są "powyżej" dużych adresów. Więc stosy rosną; main znajduje się u dołu, obiekt wywoływany liścia znajduje się u góry. Jeśli nie zgadzasz się, musisz wykonać pewne zmiany psychiczne, aby przejść przez tę część artykułu.
Kelner, istnieją dziury w moim stosie
Teraz, gdy mówimy w tym samym języku, przyjrzyjmy się stosowi trybu mieszanego. Rysunek 2 ilustruje przykładowy stos trybu mieszanego.
Rysunek 2. Stos z ramkami zarządzanymi i niezarządzanymi
Krok wstecz trochę, warto zrozumieć, dlaczego DoStackSnapshot istnieje w pierwszej kolejności. Jest tam, aby pomóc w chodzenie zarządzanych ramek na stosie. Jeśli podjęto próbę samodzielnego chodzenia ramek zarządzanych, będziesz otrzymywać niewiarygodne wyniki, szczególnie w systemach 32-bitowych, ze względu na niektóre lekkomyślne konwencje wywoływania używane w kodzie zarządzanym. ClR rozumie te konwencje wywoływania, a aplikacja DoStackSnapshot może w związku z tym pomóc w ich dekodowaniu. Jednak doStackSnapshot nie jest kompletnym rozwiązaniem, jeśli chcesz mieć możliwość chodzenia po całym stosie, w tym ramek niezarządzanych.
Oto miejsce, w którym możesz wybrać:
Opcja 1: Nie rób nic i stosy raportów z "niezarządzanymi otworami" dla użytkowników lub ...
Opcja 2. Napisz własny niezarządzany przewodnik stosu, aby wypełnić te otwory.
Gdy doStackSnapshot występuje blok niezarządzanych ramek, wywołuje funkcję StackSnapshotCallback z funcId ustawionym na
0, jak wspomniano wcześniej. Jeśli zamierzasz z opcją 1, po prostu nie rób nic w wywołaniu zwrotnym, gdy funcId ma wartość 0. ClR wywoła cię ponownie dla następnej zarządzanej ramki i możesz obudzić się w tym momencie.
Jeśli niezarządzany blok składa się z więcej niż jednej niezarządzanej ramki, clR nadal wywołuje element StackSnapshotCallback tylko raz. Pamiętaj, że clR nie podejmuje żadnych starań, aby zdekodować niezarządzany blok — ma specjalne informacje wewnętrzne, które pomagają pominąć blok do następnej zarządzanej ramki i w ten sposób postępuje. ClR nie musi wiedzieć, co znajduje się w niezarządzanych blokach. To dla Ciebie, aby dowiedzieć się, stąd opcja 2.
Pierwszym krokiem jest doozy
Niezależnie od wybranej opcji wypełnianie niezarządzanych otworów nie jest jedyną twardą częścią. Po prostu rozpoczęcie spaceru może być wyzwaniem. Przyjrzyj się powyższej stosowi. U góry znajduje się niezarządzany kod. Czasami będziesz mieć szczęście, a niezarządzany kod będzie mieć kod COM lub PInvoke . Jeśli tak, clR jest wystarczająco inteligentny, aby wiedzieć, jak go pominąć, i rozpoczyna spacer w pierwszej zarządzanej ramce (D w przykładzie). Jednak nadal możesz chcieć chodzić po najbardziej niezarządzanym bloku, aby raportować jak najwięcej stosu, jak to możliwe.
Nawet jeśli nie chcesz chodzić po najbardziej górnym bloku, możesz być zmuszony do mimo to — jeśli nie masz szczęścia, ten niezarządzany kod nie jest kodem COM lub PInvoke , ale pomocnik kodu w samym środowisku CLR, na przykład kod do kompilowania lub odzyskiwania pamięci JIT. Jeśli tak jest, clR nie będzie w stanie znaleźć ramki D bez twojej pomocy. Dlatego niezamierzone wywołanie do aplikacji DoStackSnapshot spowoduje błąd CORPROF_E_STACKSNAPSHOT_UNMANAGED_CTX lub CORPROF_E_STACKSNAPSHOT_UNSAFE. (W ten sposób, to naprawdę warto odwiedzić corerror.h.)
Zwróć uwagę, że użyłem słowa "unseededed". DoStackSnapshot przyjmuje kontekst inicjujny przy użyciu parametrów contextSize
. Słowo "kontekst" jest przeciążone wieloma znaczeniami. W tym przypadku mówię o kontekście rejestru. Jeśli przejrzysz nagłówki okien zależnych od architektury (na przykład nti386.h), znajdziesz strukturę o nazwie CONTEXT. Zawiera wartości rejestrów procesora CPU i reprezentuje stan procesora CPU w określonym momencie w czasie. To jest typ kontekstu, o którym mówię.
Jeśli przekażesz wartość null dla parametru kontekstu , przewodnik stosu jest niedostępny, a clR rozpoczyna się u góry. Jeśli jednak przekażesz wartość inną niż null dla parametru kontekstu , reprezentując stan procesora CPU w pewnym miejscu w dolnej części stosu (na przykład wskazującą ramkę D), CLR wykonuje spacer stosu rozstawiony z kontekstem. Ignoruje on rzeczywistą górę stosu i zaczyna się wszędzie tam, gdzie go wskazujesz.
OK, nie do końca prawda. Kontekst przekazywany do aplikacji DoStackSnapshot jest bardziej wskazówką niż wprost dyrektywą. Jeśli clR jest pewien, że może znaleźć pierwszą zarządzaną ramkę (ponieważ najbardziej niezarządzany blok to PInvoke lub KOD COM), to zrobi to i zignoruje nasion. Nie weź go jednak osobiście. ClR próbuje pomóc, zapewniając najbardziej dokładny spacer stosu może. Twój nasion jest przydatny tylko wtedy, gdy najbardziej niezarządzany blok jest kodem pomocnika w samej CLR, ponieważ nie mamy informacji, aby pomóc nam go pominąć. W związku z tym nasion jest używany tylko wtedy, gdy CLR nie może określić, gdzie rozpocząć spacer.
Możesz się zastanawiać, jak można dostarczyć nam nasion w pierwszej kolejności. Jeśli wątek docelowy nie został jeszcze zawieszony, nie można po prostu chodzić stosem wątku docelowego, aby znaleźć ramkę D, a tym samym obliczyć kontekst inicjowania. I jeszcze mówię, aby obliczyć kontekst nasion, wykonując niezarządzany spacer przed wywołaniem DoStackSnapshot, a tym samym przedDoStackSnapshot zajmuje się zawieszeniem wątku docelowego dla Ciebie. Czy wątek docelowy musi zostać zawieszony przez Ciebie i przez CLR? Rzeczywiście, yeah.
Myślę, że nadszedł czas, aby choreografować ten balet. Ale zanim dostanę się zbyt głęboko, zwróć uwagę, że kwestia tego, czy i jak rozstawić spacer stosu ma zastosowanie tylko do asynchronicznych spacerów. Jeśli robisz synchroniczny spacer, DoStackSnapshot zawsze będzie w stanie znaleźć drogę do najbardziej zarządzanej ramki najlepiej zarządzanej bez pomocy — bez konieczności nasion.
Wszystko razem teraz
Dla naprawdę przygodowego profilera, który robi asynchroniczną, krzyżową, rozstawioną stos walkę podczas wypełniania niezarządzanych otworów, oto, jak wygląda spacer stosu. Załóżmy, że stos pokazany tutaj jest tym samym stosem, który pokazano na rysunku 2, po prostu rozbił się nieco.
Zawartość stosu | Akcje profilera i środowiska CLR |
---|---|
![]() |
1. Zawieszasz wątek docelowy. (Liczba wstrzymania wątku docelowego wynosi teraz 1). 2. Otrzymujesz bieżący kontekst rejestru wątku docelowego. 3. Określasz, czy kontekst rejestru wskazuje na niezarządzany kod, czyli wywołasz metodę ICorProfilerInfo2::GetFunctionFromIP i sprawdzisz, czy odzyskasz wartość FunctionID 0. 4. Ponieważ w tym przykładzie kontekst rejestru wskazuje niezarządzany kod, wykonujesz niezarządzany stos, aż znajdziesz najbardziej zarządzaną ramkę (Funkcja D). |
![]() |
5. Wywołasz element DoStackSnapshot z kontekstem inicjującego, a clR ponownie zawiesza wątek docelowy. (Liczba zawieszonych jest teraz 2).) Zaczyna się kanapka.
a. ClR wywołuje funkcję StackSnapshotCallback z identyfikatorem FunctionID dla języka D. |
![]() |
b. ClR wywołuje funkcję StackSnapshotCallback z identyfikatorem FunctionID równym 0. Musisz chodzić po tym bloku samodzielnie. Możesz zatrzymać się, gdy osiągniesz pierwszą zarządzaną ramkę. Alternatywnie możesz oszukać i opóźnić niezarządzany spacer do czasu po następnym wywołaniu zwrotnym, ponieważ następny wywołanie zwrotne poinformuje Cię dokładnie, gdzie rozpoczyna się następna zarządzana ramka, a tym samym miejsce, w którym powinien zakończyć się niezarządzany spacer. |
![]() |
c. ClR wywołuje funkcję StackSnapshotCallback z identyfikatorem FunctionID dla języka C. |
![]() |
d. ClR wywołuje funkcję StackSnapshotCallback z identyfikatorem FunctionID dla B. |
![]() |
e. ClR wywołuje funkcję StackSnapshotCallback z identyfikatorem FunctionID równym 0. Ponownie, musisz chodzić po tym bloku samodzielnie. |
![]() |
f. ClR wywołuje funkcję StackSnapshotCallback z identyfikatorem FunctionID for A. |
![]() |
g. ClR wywołuje funkcję StackSnapshotCallback z identyfikatorem FunctionID for Main. |
6. Wznów wątek docelowy. Jego liczba wstrzymania wynosi teraz 0, więc wątek fizycznie wznawia. |
Bądź na najlepszym zachowaniu
OK, jest to zbyt duża moc bez poważnej ostrożności. W najbardziej zaawansowanym przypadku odpowiadasz na przerwania czasomierza i zawieszasz wątki aplikacji dowolnie, aby chodzić ich stosy. Yikes!
Bycie dobrym jest trudne i wiąże się z zasadami, które nie są oczywiste na początku. Przyjrzyjmy się więc.
Zły nasion
Zacznijmy od łatwej reguły: nie używaj złego nasion. Jeśli profiler dostarcza nieprawidłowy (inny niż null) nasion podczas wywoływania doStackSnapshot, CLR daje złe wyniki. Przyjrzymy się stosowi, w którym go wskażesz, i założymy, jakie wartości na stosie mają reprezentować. Spowoduje to, że CLR wyłuskanie zakłada się, że adresy są na stosie. Biorąc pod uwagę zły nasion, CLR będzie wyłuskać wartości off do nieznanego miejsca w pamięci. CLR robi wszystko, co w jego celu, aby uniknąć all-out drugiej szansy AV, co spowoduje usunięcie procesu profilowania. Ale naprawdę powinieneś starać się, aby twoje nasion było właściwe.
Woes zawieszenia
Inne aspekty zawieszania wątków są wystarczająco skomplikowane, że wymagają wielu reguł. Po podjęciu decyzji o przejściu między wątkami decydujesz się co najmniej poprosić CLR o zawieszenie wątków w Twoim imieniu. Co więcej, jeśli chcesz chodzić niezarządzany blok w górnej części stosu, postanowiłeś zawiesić wątki samodzielnie bez wywoływania mądrości CLR, czy jest to dobry pomysł w tej chwili.
Jeśli wziąłeś zajęcia komputerowe, prawdopodobnie pamiętasz problem "filozofów jadalni". Grupa filozofów siedzi przy stole, każdy z jednym rozwidleniem po prawej stronie i jeden po lewej stronie. Według problemu każdy potrzebuje dwóch rozwidlenia do jedzenia. Każdy filozof podnosi prawy rozwidlenie, ale wtedy nikt nie może odebrać lewego rozwidlenia, ponieważ każdy filozof czeka na filozofa w lewo, aby odłożyć potrzebne rozwidlenie. A jeśli filozofowie siedzą na okrągłym stole, masz cykl oczekiwania i wiele pustych żołądków. Powodem, dla którego wszyscy głodują, jest to, że przerywają prostą regułę unikania zakleszczenia: jeśli potrzebujesz wielu blokad, zawsze weź je w tej samej kolejności. Po tej regule można uniknąć cyklu, w którym A czeka na B, B czeka na C i C czeka na A.
Załóżmy, że aplikacja jest zgodna z regułą i zawsze przyjmuje blokady w tej samej kolejności. Teraz składnik jest dostarczany (na przykład profiler) i rozpoczyna arbitralne zawieszenie wątków. Złożoność znacznie wzrosła. Co zrobić, jeśli zawieszony musi teraz podjąć blokadę przechowywaną przez zawieszenie? Albo co zrobić, jeśli zawieszony potrzebuje blokady przechowywanej przez wątek, który czeka na blokadę przechowywaną przez inny wątek, który czeka na blokadę przechowywaną przez zawieszenie? Zawieszenie dodaje nową krawędź do naszego grafu zależności wątków, który może wprowadzać cykle. Przyjrzyjmy się konkretnym problemom.
Problem 1: Zawieszenie jest właścicielem blokad, które są potrzebne przez zawieszenie lub które są potrzebne przez wątki, od których zależy zawieszenie.
Problem 1a: Blokady są blokadami CLR.
Jak można sobie wyobrazić, CLR wykonuje dużą synchronizację wątków i dlatego ma kilka blokad, które są używane wewnętrznie. Po wywołaniu polecenia DoStackSnapshot clR wykryje, że wątek docelowy jest właścicielem blokady CLR bieżącego wątku (wątku wywołującego doStackSnapshot) wymaga wykonania stosu. Gdy ten warunek pojawi się, CLR odmawia wykonania zawieszenia, a doStackSnapshot natychmiast zwraca błąd CORPROF_E_STACKSNAPSHOT_UNSAFE. W tym momencie, jeśli zawieszono wątek samodzielnie przed wywołaniem do aplikacji DoStackSnapshot, wznowisz wątek samodzielnie i uniknąć problemu.
Problem 1b: Blokady są blokadami własnego profilera.
Ten problem jest naprawdę bardziej problemem zdrowym rozsądku. Możesz mieć własną synchronizację wątków, aby wykonać tutaj i tam. Załóżmy, że wątek aplikacji (Thread A) napotyka wywołanie zwrotne profilera i uruchamia część kodu profilera, który przyjmuje jeden z blokad profilera. Następnie thread B musi chodzić Thread A, co oznacza, że Thread B zawiesi Thread A. Należy pamiętać, że gdy wątek A jest zawieszony, nie należy mieć wątku B spróbować użyć żadnych własnych blokad profilera, które może być właścicielem wątku A. Na przykład wątek B wykona funkcję StackSnapshotCallback podczas stosu, więc nie należy przyjmować żadnych blokad podczas tego wywołania zwrotnego, które mogą być własnością wątku A.
Problem 2: Podczas zawieszenia wątku docelowego wątek docelowy próbuje cię zawiesić.
Możesz powiedzieć: "To nie może się zdarzyć!" Wierzę w to lub nie, może, jeśli:
- Aplikacja działa w polu wieloprocesorowym i
- Thread A działa na jednym procesorze, a wątek B działa na innym i
- Thread A próbuje wstrzymać wątku B, podczas gdy thread B próbuje zawiesić wątku A.
W takim przypadku możliwe jest, że oba zawieszenia wygrają, a oba wątki kończą się zawieszone. Ponieważ każdy wątek czeka na drugie, aby go obudzić, pozostają zawieszone na zawsze.
Ten problem jest bardziej niepokojący niż Problem 1, ponieważ nie można polegać na CLR do wykrywania przed wywołaniem doStackSnapshot , że wątki zostaną zawieszone nawzajem. A po wykonaniu zawieszenia jest za późno!
Dlaczego wątek docelowy próbuje zawiesić profilera? W hipotetycznym, słabo napisanym profilerze kod stosu wraz z kodem zawieszenia może być wykonywany przez dowolną liczbę wątków w dowolnym czasie. Wyobraź sobie, że thread A próbuje chodzić Thread B w tym samym czasie, że Thread B próbuje chodzić Thread A. Obaj próbują wstrzymać się jednocześnie, ponieważ oboje wykonuje część SuspendThread procedury stosu profilera. Zarówno wygrana, jak i profilowana aplikacja jest zakleszczone. Reguła w tym miejscu jest oczywista — nie zezwalaj profilerowi na wykonywanie kodu chodzenia stosem (a tym samym kod zawieszenia) na dwóch wątkach jednocześnie!
Mniej oczywistym powodem, dla którego wątek docelowy może próbować zawiesić wątek chodzenia, wynika z wewnętrznych prac CLR. ClR zawiesza wątki aplikacji, aby ułatwić wykonywanie zadań, takich jak odzyskiwanie pamięci. Jeśli twój przewodnik próbuje chodzić (a tym samym zawiesić) wątek wykonujący odzyskiwanie pamięci w tym samym czasie, że wątek modułu odśmiecowania pamięci próbuje zawiesić walker, procesy zostaną zakleszczone.
Ale łatwo jest uniknąć problemu. ClR zawiesza tylko wątki, które muszą wstrzymać, aby wykonać swoją pracę. Wyobraź sobie, że istnieją dwa wątki związane z spacerem stosu. Thread W to bieżący wątek (wątek wykonujący spacer). Wątek T to wątek docelowy (wątek, którego stos jest kierowany). Tak długo, jak thread W nigdy nie wykonał zarządzanego kodu i dlatego nie podlega odzyskiwania pamięci CLR, CLR nigdy nie spróbuje zawiesić wątku W. Oznacza to, że profiler jest bezpieczny do wstrzymania wątku W wątku T.
Jeśli piszesz profiler próbkowania, jest to dość naturalne, aby zapewnić to wszystko. Zazwyczaj będziesz mieć oddzielny wątek własnego tworzenia, który reaguje na przerwania czasomierza i przechodzi stosy innych wątków. Wywołaj ten wątek próbkatora. Ponieważ samodzielnie tworzysz wątek sampler i masz kontrolę nad tym, co wykonuje (i dlatego nigdy nie wykonuje kodu zarządzanego), clR nie będzie miał powodu, aby go zawiesić. Projektowanie profilera w taki sposób, aby tworzył własny wątek próbkowania, aby wykonać wszystkie spacery stosu, unika również problemu "źle napisanego profilera" opisanego wcześniej. Wątek próbkatora jest jedynym wątkiem profilera, który próbuje chodzić lub zawiesić inne wątki, więc profiler nigdy nie będzie próbował bezpośrednio zawiesić wątek próbkatora.
Jest to nasza pierwsza reguła nietrywialna, więc podkreślenie pozwoli mi to powtórzyć:
Reguła 1: Tylko wątek, który nigdy nie uruchamiał kodu zarządzanego, powinien zawiesić inny wątek.
Nikt nie lubi chodzić po zwłokach
Jeśli wykonujesz spacer między wątkami, musisz upewnić się, że wątek docelowy pozostaje żywy przez cały czas trwania tego przewodnika. Tylko dlatego, że przekazujesz wątek docelowy jako parametr do wywołania DoStackSnapshot , nie oznacza, że niejawnie dodano do niego odwołanie okresu istnienia. Aplikacja może sprawić, że wątek zniknie w dowolnym momencie. Jeśli tak się stanie podczas próby przejścia wątku, możesz łatwo spowodować naruszenie dostępu.
Na szczęście clR powiadamia profileers, gdy wątek ma zostać zniszczony, przy użyciu trafnie nazwanego wywołania zwrotnego ThreadDestroyed zdefiniowanego za pomocą interfejsu ICorProfilerCallback(2). To Twoja odpowiedzialność za zaimplementowanie wątku ThreadDestroyed i poczekaj na zakończenie każdego procesu chodzenia po tym wątku. Jest to wystarczająco interesujące, aby zakwalifikować się do następnej reguły:
Reguła 2. Przesłoń wywołanie zwrotne ThreadDestroyed i poczekaj na wdrożenie do momentu zakończenia chodzenia stosu wątku do zniszczenia.
Poniższa reguła 2 blokuje clR zniszczenie wątku, dopóki nie skończysz chodzić stos tego wątku.
Odzyskiwanie pamięci pomaga w cyklu
W tym momencie rzeczy mogą być nieco mylące. Zacznijmy od tekstu następnej reguły i rozszyfrujmy ją stamtąd:
Reguła 3: Nie przechowuj blokady podczas wywołania profilera, które może wyzwalać odzyskiwanie pamięci.
Wspomniałem wcześniej, że jest to zły pomysł, aby profiler trzymał go, jeśli jego własne blokady, jeśli wątek właściciel może zostać zawieszony, a jeśli wątek może być chodzić przez inny wątek, który wymaga tej samej blokady. Reguła 3 pomaga uniknąć bardziej subtelnego problemu. Tutaj mówię, że nie należy przechowywać żadnych własnych blokad, jeśli wątek właściciel ma wywołać metodę ICorProfilerInfo(2), która może wyzwolić odzyskiwanie pamięci.
Kilka przykładów powinno pomóc. W pierwszym przykładzie przyjęto założenie, że wątek B wykonuje odzyskiwanie pamięci. Sekwencja to:
- Thread A przyjmuje, a teraz jest właścicielem jednego z blokad profilera.
- Wątek B wywołuje wywołanie zwrotne profilera GarbageCollectionStarted .
- Bloki wątku B na blokadzie profilera z kroku 1.
- Thread A wykonuje funkcję GetClassFromTokenAndTypeArgs .
- Wywołanie GetClassFromTokenAndTypeArgs próbuje wyzwolić odzyskiwanie pamięci, ale wykrywa, że odzyskiwanie pamięci jest już w toku.
- Wątek A blokuje, oczekując na zakończenie odzyskiwania pamięci (wątek B). Jednak wątek B czeka na wątku A ze względu na blokadę profilera.
Rysunek 3 ilustruje scenariusz w tym przykładzie:
Rysunek 3. Zakleszczenie między profilerem a modułem odśmiecaczem pamięci
Drugi przykład to nieco inny scenariusz. Sekwencja to:
- Thread A przyjmuje, a teraz jest właścicielem jednego z blokad profilera.
- Wątek B wywołuje wywołanie zwrotne moduleLoadStarted profilera.
- Bloki wątku B na blokadzie profilera z kroku 1.
- Thread A wykonuje funkcję GetClassFromTokenAndTypeArgs .
- Wywołanie GetClassFromTokenAndTypeArgs wyzwala odzyskiwanie pamięci.
- Wątek A (który teraz wykonuje odzyskiwanie pamięci) czeka na przygotowanie wątku B do zebrania. Jednak wątek B czeka na wątku A z powodu blokady profilera.
- Rysunek 4 ilustruje drugi przykład.
Rysunek 4. Zakleszczenie między profilerem a oczekującym odzyskiwaniem pamięci
Czy przetrawiłeś szaleństwo? Problemem jest to, że odzyskiwanie pamięci ma własne mechanizmy synchronizacji. Wynik w pierwszym przykładzie występuje, ponieważ w danym momencie może wystąpić tylko jedno odzyskiwanie pamięci. Jest to wprawdzie przypadek frędzl, ponieważ odzyskiwanie pamięci zwykle nie występuje tak często, że jeden musi czekać na inny, chyba że działasz w warunkach stresujących. Mimo to, jeśli profilujesz wystarczająco długo, ten scenariusz zostanie przygotowany i musisz go przygotować.
Wynik w drugim przykładzie występuje, ponieważ wątek wykonujący odzyskiwanie pamięci musi czekać, aż inne wątki aplikacji będą gotowe do zbierania. Problem pojawia się, gdy wprowadzasz jeden z własnych zamków do mieszanki, tworząc w ten sposób cykl. W obu przypadkach reguła 3 jest uszkodzona, zezwalając wątkowi A na posiadanie jednej z blokad profilera, a następnie wywołaj metodę GetClassFromTokenAndTypeArgs. (W rzeczywistości wywołanie dowolnej metody, która może wyzwolić odzyskiwanie pamięci, wystarczy, aby doom tego procesu).
Prawdopodobnie masz teraz kilka pytań.
PYTANIE: Jak sprawdzić, które metody ICorProfilerInfo(2) mogą wyzwolić odzyskiwanie pamięci?
A. Planujemy dokumentować to w witrynie MSDN lub przynajmniej w moim blogu lub blogu Jonathana Keljo.
PYTANIE: Co to ma związek z chodzeniem stosem? Nie ma wzmianki o DoStackSnapshot.
A. Prawda. I DoStackSnapshot nie jest nawet jedną z tych metod ICorProfilerInfo(2), które wyzwalają odzyskiwanie pamięci. Powodem, dla którego omawiam regułę 3, jest to, że to właśnie ci przygodowi programiści asynchronicznie spacerują stosami z dowolnych próbek, które będą najbardziej prawdopodobne, aby zaimplementować własne blokady profilera, a tym samym być podatne na wpadnięcie w tę pułapkę. W rzeczywistości reguła 2 zasadniczo informuje o dodaniu synchronizacji do profilera. Istnieje prawdopodobieństwo, że profiler próbkowania będzie miał również inne mechanizmy synchronizacji, być może koordynować odczytywanie i zapisywanie udostępnionych struktur danych w dowolnym czasie. Oczywiście nadal jest możliwe, aby profiler nigdy nie dotykał doStackSnapshot , aby napotkać ten problem.
Wystarczająca ilość
Mam zamiar zakończyć z szybkim podsumowaniem najważniejszych atrakcji. Oto ważne kwestie do zapamiętania:
- Synchroniczne spacery stosu obejmują przejście bieżącego wątku w odpowiedzi na wywołanie zwrotne profilera. Nie wymagają one wysiewu, zawieszenia ani żadnych specjalnych zasad.
- Asynchroniczne spacery wymagają inicjowania, jeśli górna część stosu jest kodem niezarządzanym, a nie częścią wywołania PInvoke lub COM. Podasz nasion, bezpośrednio zawieszony wątek docelowy i chodzenie go samodzielnie, aż znajdziesz najbardziej zarządzaną ramkę. Jeśli w tym przypadku nie podasz inicjuj, doStackSnapshot może zwrócić kod błędu lub pominąć niektóre ramki w górnej części stosu.
- Jeśli musisz zawiesić wątki, pamiętaj, że tylko wątek, który nigdy nie uruchamiał kodu zarządzanego, powinien zawiesić inny wątek.
- Podczas wykonywania asynchronicznych spacerów zawsze przesłaniaj wywołanie zwrotne ThreadDestroyed , aby zablokować clR zniszczenie wątku do momentu ukończenia stosu tego wątku.
- Nie należy przechowywać blokady, gdy profiler wywołuje funkcję CLR, która może wyzwolić odzyskiwanie pamięci.
Aby uzyskać więcej informacji na temat interfejsu API profilowania, zobacz Profilowanie (niezarządzane) w witrynie sieci Web MSDN.
Środki, w przypadku których środki są należne
Chciałbym dołączyć notatkę dzięki pozostałej części zespołu interfejsu API profilowania CLR, ponieważ pisanie tych reguł było naprawdę nakładem pracy zespołowej. Specjalne podziękowania Sean Selitrennikoff, który dostarczył wcześniejsze wcielenie dużej części tej zawartości.
Informacje o autorze
David od dłuższego czasu jest deweloperem w firmie Microsoft, biorąc pod uwagę ograniczoną wiedzę i dojrzałość. Chociaż nie wolno już zaewidencjonować kodu, nadal oferuje pomysły na nowe nazwy zmiennych. David jest zapalonym fanem Count Chocula i jest właścicielem własnego samochodu.