Polowanie i analiza wysokiego wykorzystania procesora w aplikacjach .NET
Opublikowany: 2022-03-11Tworzenie oprogramowania może być bardzo skomplikowanym procesem. Jako programiści musimy brać pod uwagę wiele różnych zmiennych. Niektóre nie są pod naszą kontrolą, niektóre są nam nieznane w momencie faktycznego wykonania kodu, a niektóre są bezpośrednio przez nas kontrolowane. A programiści .NET nie są pod tym względem wyjątkiem.
Biorąc pod uwagę tę rzeczywistość, gdy pracujemy w kontrolowanych środowiskach, sprawy zwykle idą zgodnie z planem. Przykładem jest nasza maszyna deweloperska, czyli środowisko integracyjne, do którego mamy pełny dostęp. W takich sytuacjach mamy do dyspozycji narzędzia do analizy różnych zmiennych, które wpływają na nasz kod i oprogramowanie. W takich przypadkach nie mamy też do czynienia z dużym obciążeniem serwera lub równoczesnymi użytkownikami próbującymi zrobić to samo w tym samym czasie.
W opisanych i bezpiecznych sytuacjach nasz kod będzie działał dobrze, ale w produkcji pod dużym obciążeniem lub innymi czynnikami zewnętrznymi mogą wystąpić nieoczekiwane problemy. Wydajność oprogramowania w produkcji jest trudna do analizy. W większości przypadków mamy do czynienia z potencjalnymi problemami w scenariuszu teoretycznym: wiemy, że problem może się zdarzyć, ale nie możemy go przetestować. Dlatego musimy opierać nasz rozwój na najlepszych praktykach i dokumentacji dla używanego języka i unikać typowych błędów.
Jak wspomniano, kiedy oprogramowanie zostanie uruchomione, coś może pójść nie tak, a kod może zacząć działać w sposób, którego nie planowaliśmy. Możemy skończyć w sytuacji, gdy mamy do czynienia z problemami bez możliwości debugowania lub wiedzy na pewno, co się dzieje. Co możemy zrobić w tym przypadku?
W tym artykule przeanalizujemy prawdziwy przypadek dużego wykorzystania procesora przez aplikację sieciową .NET na serwerze Windows, procesy zaangażowane w identyfikację problemu, a co ważniejsze, dlaczego ten problem wystąpił w pierwszej kolejności i w jaki sposób rozwiązać.
Wykorzystanie procesora i zużycie pamięci to szeroko dyskutowane tematy. Zwykle bardzo trudno jest z całą pewnością stwierdzić, jaka jest właściwa ilość zasobów (procesor, pamięć RAM, I/O), z których dany proces powinien korzystać i przez jaki okres czasu. Chociaż jedno jest pewne - jeśli proces używa więcej niż 90% procesora przez dłuższy czas, mamy kłopoty tylko dlatego, że serwer nie będzie w stanie przetworzyć żadnego innego żądania w tej sytuacji.
Czy to oznacza, że jest problem z samym procesem? Niekoniecznie. Możliwe, że proces wymaga większej mocy obliczeniowej lub obsługuje dużo danych. Na początek jedyne, co możemy zrobić, to spróbować określić, dlaczego tak się dzieje.
Wszystkie systemy operacyjne mają kilka różnych narzędzi do monitorowania tego, co dzieje się na serwerze. Serwery Windows mają w szczególności menedżera zadań, Monitor wydajności lub w naszym przypadku wykorzystaliśmy New Relic Servers, który jest doskonałym narzędziem do monitorowania serwerów.
Pierwsze symptomy i analiza problemu
Po wdrożeniu naszej aplikacji, w ciągu pierwszych dwóch tygodni zauważyliśmy, że serwer ma szczyty wykorzystania procesora, co spowodowało, że serwer przestaje odpowiadać. Musieliśmy go ponownie uruchomić, aby był ponownie dostępny, a to wydarzenie miało miejsce trzy razy w tym czasie. Jak wspomniałem wcześniej, używaliśmy New Relic Servers jako monitora serwera i pokazało to, że proces w3wp.exe
94% procesora w momencie awarii serwera.
Proces roboczy Internetowych usług informacyjnych (IIS) to proces systemu Windows ( w3wp.exe
), który uruchamia aplikacje sieci Web i jest odpowiedzialny za obsługę żądań wysyłanych do serwera sieci Web dla określonej puli aplikacji. Serwer IIS może mieć kilka pul aplikacji (i kilka różnych procesów w3wp.exe
), które mogą generować problem. Na podstawie użytkownika, którego miał proces (pokazano to w raportach New Relic), stwierdziliśmy, że problem dotyczył naszej starszej aplikacji formularza internetowego .NET C#.
.NET Framework jest ściśle zintegrowany z narzędziami do debugowania systemu Windows, więc pierwszą rzeczą, którą próbowaliśmy zrobić, było przyjrzenie się przeglądarce zdarzeń i plikom dziennika aplikacji, aby znaleźć przydatne informacje o tym, co się dzieje. Niezależnie od tego, czy w przeglądarce zdarzeń były zarejestrowane jakieś wyjątki, nie dostarczały one wystarczającej ilości danych do analizy. Dlatego zdecydowaliśmy się pójść o krok dalej i zebrać więcej danych, aby gdy wydarzenie zaistniało ponownie, bylibyśmy przygotowani.
Zbieranie danych
Najłatwiejszym sposobem zbierania zrzutów procesów w trybie użytkownika jest użycie Debug Diagnostic Tools v2.0 lub po prostu DebugDiag. DebugDiag posiada zestaw narzędzi do zbierania danych (DebugDiag Collection) i analizy danych (DebugDiag Analysis).
Zacznijmy więc od definiowania reguł zbierania danych za pomocą narzędzi diagnostycznych debugowania:
Otwórz kolekcję DebugDiag i wybierz
Performance
.- Wybierz
Performance Counters
i kliknijNext
. - Kliknij
Add Perf Triggers
. - Rozwiń obiekt
Processor
(nieProcess
) i wybierz% Processor Time
. Pamiętaj, że jeśli korzystasz z systemu Windows Server 2008 R2 i masz więcej niż 64 procesory, wybierz obiektProcessor Information
zamiast obiektuProcessor
. - Z listy instancji wybierz
_Total
. - Kliknij
Add
, a następnie kliknijOK
. Wybierz nowo dodany wyzwalacz i kliknij
Edit Thresholds
.- Wybierz
Above
z listy rozwijanej. - Zmień próg na
80
. Wpisz
20
jako liczbę sekund. W razie potrzeby możesz dostosować tę wartość, ale uważaj, aby nie określać małej liczby sekund, aby zapobiec fałszywym wyzwalaczom.- Kliknij
OK
. - Kliknij
Next
. - Kliknij opcję
Add Dump Target
. - Z listy rozwijanej wybierz opcję
Web Application Pool
. - Wybierz pulę aplikacji z listy pul aplikacji.
- Kliknij
OK
. - Kliknij
Next
. - Ponownie kliknij
Next
. - Wprowadź nazwę swojej reguły, jeśli chcesz i zanotuj lokalizację, w której zrzuty zostaną zapisane. W razie potrzeby możesz zmienić tę lokalizację.
- Kliknij
Next
. - Wybierz opcję
Activate the Rule Now
i kliknij przyciskFinish
.
Opisana reguła utworzy zestaw plików minidump, które będą miały dość mały rozmiar. Ostateczny zrzut będzie zrzutem z pełną pamięcią, a zrzuty te będą znacznie większe. Teraz musimy tylko poczekać na ponowne wystąpienie zdarzenia wysokiego CPU.
Po umieszczeniu plików zrzutu w wybranym folderze użyjemy narzędzia DebugDiag Analysis w celu przeanalizowania zebranych danych:

Wybierz Analizatory wydajności.
Dodaj pliki zrzutu.
Rozpocznij analizę.
DebugDiag zajmie kilka (lub kilka) minut, aby przeanalizować zrzuty i dostarczyć analizę. Po zakończeniu analizy zobaczysz stronę internetową z podsumowaniem i dużą ilością informacji dotyczących wątków, podobną do poniższej:
Jak widać w podsumowaniu, pojawia się ostrzeżenie „Wykryto wysokie zużycie procesora między plikami zrzutu w jednym lub więcej wątkach”. Jeśli klikniemy w rekomendację, zaczniemy rozumieć, gdzie jest problem z naszą aplikacją. Nasz przykładowy raport wygląda tak:
Jak widać w raporcie, istnieje wzorzec dotyczący wykorzystania procesora. Wszystkie wątki, które mają duże użycie procesora, są powiązane z tą samą klasą. Zanim przejdziemy do kodu, spójrzmy na pierwszy.
To jest szczegół pierwszego wątku naszego problemu. Część, która nas interesuje, to:
Tutaj mamy wywołanie naszego kodu GameHub.OnDisconnected()
, które wywołało problematyczną operację, ale przed tym wywołaniem mamy dwa wywołania słownika, które mogą dać wyobrażenie o tym, co się dzieje. Zajrzyjmy do kodu .NET, aby zobaczyć, co robi ta metoda:
public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }
Oczywiście mamy tutaj problem. Stos wywołań raportów powiedział, że problem dotyczył słownika, a w tym kodzie uzyskujemy dostęp do słownika, a konkretnie linii, która powoduje problem, jest ta:
if (onlineSessions.TryGetValue(userId, out connId))
To jest deklaracja słownikowa:
static Dictionary<int, string> onlineSessions = new Dictionary<int, string>();
Jaki jest problem z tym kodem .NET?
Każdy, kto ma doświadczenie w programowaniu obiektowym, wie, że zmienne statyczne będą współdzielone przez wszystkie instancje tej klasy. Przyjrzyjmy się dokładniej, co oznacza statyka w świecie .NET.
Zgodnie ze specyfikacją .NET C#:
Użyj modyfikatora static, aby zadeklarować static element członkowski, który należy do samego typu, a nie do określonego obiektu.
Oto, co mówi specyfikacja języka .NET C# w odniesieniu do klas statycznych i elementów członkowskich:
Podobnie jak w przypadku wszystkich typów klas, informacje o typie dla klasy statycznej są ładowane przez środowisko uruchomieniowe języka wspólnego (CLR) .NET Framework podczas ładowania programu, który odwołuje się do klasy. Program nie może dokładnie określić, kiedy klasa jest ładowana. Jednak gwarantuje się, że zostanie załadowany i zainicjowane zostaną jego pola, a jego statyczny konstruktor zostanie wywołany przed pierwszym odwołaniem do klasy w programie. Konstruktor statyczny jest wywoływany tylko raz, a klasa statyczna pozostaje w pamięci przez okres istnienia domeny aplikacji, w której znajduje się program.
Klasa niestatyczna może zawierać statyczne metody, pola, właściwości lub zdarzenia. Składowa statyczna jest wywoływana w klasie, nawet jeśli nie utworzono żadnej instancji klasy. Dostęp do statycznego elementu członkowskiego zawsze uzyskuje się za pomocą nazwy klasy, a nie nazwy instancji. Istnieje tylko jedna kopia statycznego elementu członkowskiego, niezależnie od liczby utworzonych wystąpień klasy. Statyczne metody i właściwości nie mogą uzyskać dostępu do niestatycznych pól i zdarzeń w ich typie zawierającym i nie mogą uzyskać dostępu do zmiennej wystąpienia dowolnego obiektu, chyba że zostanie ona jawnie przekazana w parametrze metody.
Oznacza to, że statyczne elementy członkowskie należą do samego typu, a nie do obiektu. Są one również ładowane do domeny aplikacji przez środowisko CLR, dlatego statyczne elementy członkowskie należą do procesu, który obsługuje aplikację, a nie do określonych wątków.
Biorąc pod uwagę fakt, że środowisko sieciowe jest środowiskiem wielowątkowym, ponieważ każde żądanie to nowy wątek, który jest generowany przez proces w3wp.exe
; a biorąc pod uwagę, że statyczne elementy członkowskie są częścią procesu, możemy mieć scenariusz, w którym kilka różnych wątków próbuje uzyskać dostęp do danych zmiennych statycznych (współdzielonych przez kilka wątków), co może ostatecznie prowadzić do problemów z wielowątkowością.
Dokumentacja słownika w obszarze bezpieczeństwa wątków stwierdza, co następuje:
Dictionary<TKey, TValue>
może jednocześnie obsługiwać wiele czytników, o ile kolekcja nie jest modyfikowana. Mimo to wyliczanie w kolekcji z natury nie jest procedurą bezpieczną wątkowo. W rzadkich przypadkach, gdy wyliczenie rywalizuje z prawami do zapisu, kolekcja musi być zablokowana podczas całego wyliczania. Aby umożliwić dostęp do kolekcji przez wiele wątków w celu odczytu i zapisu, należy zaimplementować własną synchronizację.
To stwierdzenie wyjaśnia, dlaczego możemy mieć ten problem. Na podstawie informacji zrzutów problem dotyczył słownikowej metody FindEntry:
Jeśli spojrzymy na implementację FindEntry słownika, zobaczymy, że metoda iteruje przez wewnętrzną strukturę (zasobniki) w celu znalezienia wartości.
Tak więc poniższy kod .NET wylicza kolekcję, która nie jest operacją bezpieczną dla wątków.
public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }
Wniosek
Jak widzieliśmy na zrzutach, istnieje kilka wątków próbujących jednocześnie iterować i modyfikować współdzielony zasób (słownik statyczny), co ostatecznie spowodowało, że iteracja weszła w nieskończoną pętlę, powodując, że wątek zużywa ponad 90% procesora .
Istnieje kilka możliwych rozwiązań tego problemu. Ten, który wdrożyliśmy jako pierwszy, polegał na blokowaniu i synchronizowaniu dostępu do słownika kosztem utraty wydajności. Serwer zawieszał się wtedy codziennie, więc musieliśmy to naprawić jak najszybciej. Nawet jeśli nie było to optymalne rozwiązanie, to rozwiązało problem.
Kolejnym krokiem w rozwiązaniu tego problemu byłaby analiza kodu i znalezienie optymalnego rozwiązania tego problemu. Refaktoryzacja kodu jest opcją: nowa klasa ConcurrentDictionary może rozwiązać ten problem, ponieważ blokuje tylko na poziomie zasobnika, co poprawi ogólną wydajność. Chociaż jest to duży krok i wymagana byłaby dalsza analiza.