Vânarea și analizarea utilizării ridicate a procesorului în aplicațiile .NET
Publicat: 2022-03-11Dezvoltarea de software poate fi un proces foarte complicat. Noi, ca dezvoltatori, trebuie să luăm în considerare o mulțime de variabile diferite. Unele nu sunt sub controlul nostru, altele ne sunt necunoscute în momentul executării propriu-zise a codului, iar altele sunt controlate direct de noi. Și dezvoltatorii .NET nu fac excepție de la aceasta.
Având în vedere această realitate, lucrurile merg de obicei conform planului atunci când lucrăm în medii controlate. Un exemplu este mașina noastră de dezvoltare sau un mediu de integrare la care avem acces deplin. În aceste situații, avem la dispoziție instrumente pentru analiza diferitelor variabile care ne afectează codul și software-ul. În aceste cazuri, de asemenea, nu trebuie să ne confruntăm cu încărcături grele ale serverului sau cu utilizatori concurenți care încearcă să facă același lucru în același timp.
În situațiile descrise și sigure, codul nostru va funcționa bine, dar în producție sub sarcină grea sau alți factori externi, pot apărea probleme neașteptate. Performanța software-ului în producție este greu de analizat. De cele mai multe ori trebuie să ne confruntăm cu potențiale probleme într-un scenariu teoretic: știm că o problemă se poate întâmpla, dar nu o putem testa. De aceea, trebuie să ne bazăm dezvoltarea pe cele mai bune practici și pe documentația pentru limba pe care o folosim și să evităm greșelile comune.
După cum am menționat, atunci când software-ul este disponibil, lucrurile ar putea merge prost, iar codul ar putea începe să se execute într-un mod pe care nu l-am planificat. Am putea ajunge în situația în care trebuie să ne confruntăm cu probleme fără capacitatea de a depana sau să știm sigur ce se întâmplă. Ce putem face în acest caz?
În acest articol vom analiza un caz real de utilizare ridicată a CPU a unei aplicații web .NET pe serverul bazat pe Windows, procesele implicate pentru identificarea problemei și, mai important, de ce s-a întâmplat această problemă în primul rând și cum am rezolv-o.
Utilizarea procesorului și consumul de memorie sunt subiecte dezbătute pe larg. De obicei, este foarte dificil să știi cu siguranță care este cantitatea potrivită de resurse (CPU, RAM, I/O) pe care ar trebui să o folosească un anumit proces și pentru ce perioadă de timp. Deși un lucru este sigur - dacă un proces folosește mai mult de 90% din CPU pentru o perioadă lungă de timp, avem probleme doar din cauza faptului că serverul nu va putea procesa nicio altă solicitare în această circumstanță.
Înseamnă asta că există o problemă cu procesul în sine? Nu neaparat. Este posibil ca procesul să aibă nevoie de mai multă putere de procesare sau să manipuleze o mulțime de date. Pentru început, singurul lucru pe care îl putem face este să încercăm să identificăm de ce se întâmplă acest lucru.
Toate sistemele de operare au mai multe instrumente diferite pentru monitorizarea a ceea ce se întâmplă pe un server. Serverele Windows au în mod specific managerul de activități, Performance Monitor sau, în cazul nostru, am folosit New Relic Servers, care este un instrument excelent pentru monitorizarea serverelor.
Primele simptome și analiza problemelor
După ce am implementat aplicația noastră, într-un interval de timp din primele două săptămâni am început să vedem că serverul are vârfuri de utilizare a CPU, ceea ce a făcut ca serverul să nu răspundă. A trebuit să-l repornim pentru a-l face din nou disponibil, iar acest eveniment s-a întâmplat de trei ori în acel interval de timp. După cum am menționat mai devreme, am folosit New Relic Servers ca monitor de server și a arătat că procesul w3wp.exe
folosea 94% din CPU în momentul în care serverul s-a prăbușit.
Un proces de lucru pentru Internet Information Services (IIS) este un proces Windows ( w3wp.exe
) care rulează aplicații web și este responsabil pentru gestionarea cererilor trimise către un server web pentru un anumit grup de aplicații. Serverul IIS poate avea mai multe pool-uri de aplicații (și mai multe procese w3wp.exe
diferite) care ar putea genera problema. Pe baza utilizatorului pe care l-a avut procesul (acest lucru a fost arătat în rapoartele New Relic), am identificat că problema era aplicația noastră moștenită a formularului web .NET C#.
.NET Framework este strâns integrat cu instrumentele de depanare Windows, așa că primul lucru pe care am încercat să-l facem a fost să ne uităm la vizualizatorul de evenimente și la fișierele jurnal ale aplicației pentru a găsi câteva informații utile despre ceea ce se întâmplă. Indiferent dacă am avut unele excepții conectate în vizualizatorul de evenimente, acestea nu au furnizat suficiente date pentru a le analiza. De aceea am decis să facem un pas mai departe și să colectăm mai multe date, astfel încât atunci când evenimentul va apărea din nou să fim pregătiți.
Colectare de date
Cel mai simplu mod de a colecta depozite de proces în modul utilizator este cu Instrumentele de diagnosticare de depanare v2.0 sau pur și simplu DebugDiag. DebugDiag are un set de instrumente pentru colectarea datelor (DebugDiag Collection) și analiza datelor (DebugDiag Analysis).
Deci, să începem să definim reguli pentru colectarea datelor cu Instrumentele de diagnosticare de depanare:
Deschideți DebugDiag Collection și selectați
Performance
.- Selectați
Performance Counters
și faceți clic peNext
. - Faceți clic pe
Add Perf Triggers
. - Extindeți obiectul
Processor
(nuProcess
) și selectați% Processor Time
. Rețineți că, dacă sunteți pe Windows Server 2008 R2 și aveți mai mult de 64 de procesoare, alegeți obiectulProcessor Information
în loc de obiectulProcessor
. - În lista de instanțe, selectați
_Total
. - Faceți clic pe
Add
și apoi faceți clic peOK
. Selectați declanșatorul nou adăugat și faceți clic pe
Edit Thresholds
.- Selectați
Above
în meniul drop-down. - Schimbați pragul la
80
. Introduceți
20
pentru numărul de secunde. Puteți ajusta această valoare dacă este necesar, dar aveți grijă să nu specificați un număr mic de secunde pentru a preveni declanșările false.- Faceți clic pe
OK
. - Faceți clic pe
Next
. - Faceți clic pe
Add Dump Target
. - Selectați
Web Application Pool
din meniul drop-down. - Selectați pool-ul de aplicații din lista de pool-uri de aplicații.
- Faceți clic pe
OK
. - Faceți clic pe
Next
. - Faceți clic din nou pe
Next
. - Introduceți un nume pentru regula dvs. dacă doriți și notați locația în care vor fi salvate depozitele. Puteți schimba această locație dacă doriți.
- Faceți clic pe
Next
. - Selectați
Activate the Rule Now
și faceți clic peFinish
.
Regula descrisă va crea un set de fișiere minidump care vor avea dimensiuni destul de mici. Dump-ul final va fi un dump cu memorie plină, iar aceste depozite vor fi mult mai mari. Acum, trebuie doar să așteptăm ca evenimentul CPU ridicat să se întâmple din nou.

Odată ce avem fișierele dump în folderul selectat, vom folosi instrumentul de analiză DebugDiag pentru a analiza datele colectate:
Selectați Analizoare de performanță.
Adăugați fișierele dump.
Începeți analiza.
DebugDiag va dura câteva (sau câteva) minute pentru a analiza depozitele și a furniza o analiză. Când va finaliza analiza, veți vedea o pagină web cu un rezumat și multe informații despre fire, asemănătoare cu următoarea:
După cum puteți vedea în rezumat, există un avertisment care spune „Utilizarea ridicată a procesorului între fișierele de descărcare a fost detectată pe unul sau mai multe fire”. Dacă facem clic pe recomandare, vom începe să înțelegem unde este problema cu aplicația noastră. Raportul nostru exemplu arată astfel:
După cum putem vedea în raport, există un model în ceea ce privește utilizarea procesorului. Toate firele de execuție care au o utilizare mare a procesorului sunt legate de aceeași clasă. Înainte de a trece la cod, să aruncăm o privire la primul.
Acesta este detaliul pentru primul thread cu problema noastră. Partea care ne interesează este următoarea:
Aici avem un apel la codul nostru GameHub.OnDisconnected()
care a declanșat operația problematică, dar înainte de acel apel avem două apeluri la Dicționar, care pot da o idee despre ce se întâmplă. Să aruncăm o privire în codul .NET pentru a vedea ce face acea metodă:
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(); }
Evident că avem o problemă aici. Stiva de apeluri a rapoartelor spunea că problema a fost cu un dicționar, iar în acest cod accesăm un dicționar și, în special, linia care provoacă problema este aceasta:
if (onlineSessions.TryGetValue(userId, out connId))
Aceasta este declarația din dicționar:
static Dictionary<int, string> onlineSessions = new Dictionary<int, string>();
Care este problema cu acest cod .NET?
Toți cei care au experiență în programarea orientată pe obiecte știu că variabilele statice vor fi împărtășite de toate instanțele acestei clase. Să aruncăm o privire mai profundă asupra a ceea ce înseamnă static în lumea .NET.
Conform specificației .NET C#:
Utilizați modificatorul static pentru a declara un membru static, care aparține mai degrabă tipului decât unui obiect specific.
Iată ce spun specificațiile .NET C# langunge cu privire la clasele și membrii statici:
Ca și în cazul tuturor tipurilor de clasă, informațiile de tip pentru o clasă statică sunt încărcate de .NET Framework common language runtime (CLR) atunci când programul care face referire la clasa este încărcat. Programul nu poate specifica exact când este încărcată clasa. Cu toate acestea, se garantează că va fi încărcat și că va avea câmpurile inițializate și constructorul static numit înainte ca clasa să fie referită pentru prima dată în programul dumneavoastră. Un constructor static este apelat o singură dată, iar o clasă statică rămâne în memorie pe durata de viață a domeniului aplicației în care se află programul dumneavoastră.
O clasă non-statică poate conține metode statice, câmpuri, proprietăți sau evenimente. Membrul static este apelabil pe o clasă chiar și atunci când nu a fost creată nicio instanță a clasei. Membrul static este întotdeauna accesat de numele clasei, nu de numele instanței. Există o singură copie a unui membru static, indiferent de câte instanțe ale clasei sunt create. Metodele și proprietățile statice nu pot accesa câmpuri și evenimente non-statice în tipul lor și nu pot accesa o variabilă de instanță a oricărui obiect decât dacă aceasta este transmisă explicit într-un parametru de metodă.
Aceasta înseamnă că membrii statici aparțin tipului în sine, nu obiectului. Ele sunt, de asemenea, încărcate în domeniul aplicației de către CLR, prin urmare membrii statici aparțin procesului care găzduiește aplicația și nu fire specifice.
Având în vedere faptul că un mediu web este un mediu multithreaded, deoarece fiecare cerere este un fir nou care este generat de procesul w3wp.exe
; și având în vedere că membrii statici fac parte din proces, este posibil să avem un scenariu în care mai multe fire diferite încearcă să acceseze datele variabilelor statice (partajate de mai multe fire), ceea ce poate duce în cele din urmă la probleme de multithreading.
Documentația dicționarului sub fire safety afirmă următoarele:
Un
Dictionary<TKey, TValue>
poate accepta mai mulți cititori simultan, atâta timp cât colecția nu este modificată. Chiar și așa, enumerarea printr-o colecție nu este în mod intrinsec o procedură sigură pentru fire. În cazul rar în care o enumerare se luptă cu accesele de scriere, colecția trebuie să fie blocată pe parcursul întregii enumerari. Pentru a permite accesul colecției de mai multe fire pentru citire și scriere, trebuie să implementați propria sincronizare.
Această afirmație explică de ce este posibil să avem această problemă. Pe baza informațiilor de depozitare, problema a fost cu metoda FindEntry de dicționar:
Dacă ne uităm la implementarea dicționarului FindEntry, putem vedea că metoda iterează prin structura internă (buckets) pentru a găsi valoarea.
Deci următorul cod .NET enumerează colecția, care nu este o operațiune sigură pentru fire.
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(); }
Concluzie
După cum am văzut în dump-uri, există mai multe fire de execuție care încearcă să itereze și să modifice o resursă partajată (dicționar static) în același timp, ceea ce a făcut ca iterația să intre într-o buclă infinită, determinând firul să consume mai mult de 90% din CPU. .
Există mai multe soluții posibile pentru această problemă. Cea pe care am implementat-o mai întâi a fost să blocăm și să sincronizăm accesul la dicționar cu prețul pierderii performanței. Serverul se prăbușea în fiecare zi la acea oră, așa că trebuia să remediam acest lucru cât mai curând posibil. Chiar dacă aceasta nu a fost soluția optimă, a rezolvat problema.
Următorul pas în rezolvarea acestei probleme ar fi analizarea codului și găsirea soluției optime pentru aceasta. Refactorizarea codului este o opțiune: noua clasă ConcurrentDictionary ar putea rezolva această problemă, deoarece se blochează doar la un nivel de compartiment, ceea ce va îmbunătăți performanța generală. Deși, acesta este un pas mare și ar fi necesare analize suplimentare.