Polowanie i analiza wysokiego wykorzystania procesora w aplikacjach .NET

Opublikowany: 2022-03-11

Tworzenie 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?

Wysokie użycie procesora ma miejsce, gdy proces używa więcej niż 90% procesora przez dłuższy czas - a my mamy kłopoty

Jeśli proces używa więcej niż 90% procesora przez dłuższy czas, mamy kłopoty
Ćwierkać

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:

  1. Otwórz kolekcję DebugDiag i wybierz Performance .

    Narzędzie diagnostyczne debugowania

  2. Wybierz Performance Counters i kliknij Next .
  3. Kliknij Add Perf Triggers .
  4. Rozwiń obiekt Processor (nie Process ) i wybierz % Processor Time . Pamiętaj, że jeśli korzystasz z systemu Windows Server 2008 R2 i masz więcej niż 64 procesory, wybierz obiekt Processor Information zamiast obiektu Processor .
  5. Z listy instancji wybierz _Total .
  6. Kliknij Add , a następnie kliknij OK .
  7. Wybierz nowo dodany wyzwalacz i kliknij Edit Thresholds .

    Liczniki wydajności

  8. Wybierz Above z listy rozwijanej.
  9. Zmień próg na 80 .
  10. 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.

    Właściwości wyzwalacza monitora wydajności

  11. Kliknij OK .
  12. Kliknij Next .
  13. Kliknij opcję Add Dump Target .
  14. Z listy rozwijanej wybierz opcję Web Application Pool .
  15. Wybierz pulę aplikacji z listy pul aplikacji.
  16. Kliknij OK .
  17. Kliknij Next .
  18. Ponownie kliknij Next .
  19. 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ę.
  20. Kliknij Next .
  21. Wybierz opcję Activate the Rule Now i kliknij przycisk Finish .

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:

  1. Wybierz Analizatory wydajności.

    Narzędzie do analizy debugowania

  2. Dodaj pliki zrzutu.

    Pliki zrzutu opłat za usługę DebugDiag Analysis

  3. 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:

Podsumowanie analizy

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:

10 najlepszych wątków według średniego procesora

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.

Stos wywołań platformy .NET

To jest szczegół pierwszego wątku naszego problemu. Część, która nas interesuje, to:

Szczegóły stosu wywołań platformy .NET

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:

Szczegóły stosu wywołań platformy .NET

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.