Ricerca e analisi dell'utilizzo elevato della CPU nelle applicazioni .NET
Pubblicato: 2022-03-11Lo sviluppo del software può essere un processo molto complicato. Come sviluppatori, dobbiamo prendere in considerazione molte variabili diverse. Alcuni non sono sotto il nostro controllo, altri ci sono sconosciuti al momento dell'effettiva esecuzione del codice e altri sono direttamente controllati da noi. E gli sviluppatori .NET non fanno eccezione a questo.
Data questa realtà, le cose di solito vanno come previsto quando lavoriamo in ambienti controllati. Un esempio è la nostra macchina di sviluppo o un ambiente di integrazione a cui abbiamo pieno accesso. In queste situazioni, abbiamo a nostra disposizione strumenti per analizzare diverse variabili che stanno influenzando il nostro codice e software. In questi casi, inoltre, non abbiamo a che fare con carichi pesanti del server o utenti simultanei che cercano di fare la stessa cosa contemporaneamente.
Nelle situazioni descritte e sicure il nostro codice funzionerà bene, ma in produzione con carichi pesanti o altri fattori esterni potrebbero verificarsi problemi imprevisti. Le prestazioni del software in produzione sono difficili da analizzare. Il più delle volte abbiamo a che fare con potenziali problemi in uno scenario teorico: sappiamo che un problema può verificarsi, ma non possiamo verificarlo. Ecco perché dobbiamo basare il nostro sviluppo sulle migliori pratiche e sulla documentazione per il linguaggio che stiamo utilizzando ed evitare errori comuni.
Come accennato, quando il software diventa attivo, le cose potrebbero andare storte e il codice potrebbe iniziare a essere eseguito in un modo non pianificato. Potremmo finire nella situazione in cui dobbiamo affrontare problemi senza la possibilità di eseguire il debug o sapere con certezza cosa sta succedendo. Cosa possiamo fare in questo caso?
In questo articolo analizzeremo uno scenario reale di utilizzo elevato della CPU di un'applicazione Web .NET sul server basato su Windows, i processi coinvolti per identificare il problema e, cosa più importante, perché questo problema si è verificato in primo luogo e come abbiamo risolvilo.
L'utilizzo della CPU e il consumo di memoria sono argomenti ampiamente discussi. Di solito è molto difficile sapere con certezza quale sia la giusta quantità di risorse (CPU, RAM, I/O) che un processo specifico dovrebbe utilizzare e per quale periodo di tempo. Anche se una cosa è certa: se un processo utilizza più del 90% della CPU per un periodo di tempo prolungato, siamo nei guai solo perché il server non sarà in grado di elaborare altre richieste in questa circostanza.
Questo significa che c'è un problema con il processo stesso? Non necessariamente. Potrebbe essere che il processo richieda una maggiore potenza di elaborazione o stia gestendo molti dati. Tanto per cominciare, l'unica cosa che possiamo fare è cercare di identificare il motivo per cui questo sta accadendo.
Tutti i sistemi operativi hanno diversi strumenti per monitorare cosa sta succedendo in un server. I server Windows hanno specificamente il task manager, Performance Monitor, o nel nostro caso abbiamo usato New Relic Servers che è un ottimo strumento per monitorare i server.
Primi sintomi e analisi del problema
Dopo aver distribuito la nostra applicazione, durante un intervallo di tempo delle prime due settimane abbiamo iniziato a vedere che il server ha picchi di utilizzo della CPU, il che ha impedito al server di rispondere. Abbiamo dovuto riavviarlo per renderlo nuovamente disponibile e questo evento si è verificato tre volte durante quel lasso di tempo. Come accennato in precedenza, abbiamo utilizzato New Relic Servers come monitor del server e ha dimostrato che il processo w3wp.exe
utilizzava il 94% della CPU al momento in cui il server si è bloccato.
Un processo di lavoro di Internet Information Services (IIS) è un processo Windows ( w3wp.exe
) che esegue applicazioni Web ed è responsabile della gestione delle richieste inviate a un server Web per un pool di applicazioni specifico. Il server IIS può avere diversi pool di applicazioni (e diversi processi w3wp.exe
) che potrebbero generare il problema. In base all'utente che aveva il processo (questo è stato mostrato nei rapporti New Relic), abbiamo identificato che il problema era la nostra applicazione legacy del modulo Web .NET C#.
Il .NET Framework è strettamente integrato con gli strumenti di debug di Windows, quindi la prima cosa che abbiamo provato a fare è stata guardare il visualizzatore di eventi e i file di registro dell'applicazione per trovare alcune informazioni utili su cosa stava succedendo. Indipendentemente dal fatto che alcune eccezioni siano state registrate nel visualizzatore eventi, non hanno fornito dati sufficienti per l'analisi. Ecco perché abbiamo deciso di fare un passo in più e di raccogliere più dati, così quando l'evento si fosse ripresentato saremmo stati preparati.
Raccolta dati
Il modo più semplice per raccogliere i dump dei processi in modalità utente è con Debug Diagnostic Tools v2.0 o semplicemente DebugDiag. DebugDiag dispone di una serie di strumenti per la raccolta dei dati (DebugDiag Collection) e l'analisi dei dati (DebugDiag Analysis).
Quindi, iniziamo a definire le regole per la raccolta dei dati con Debug Diagnostic Tools:
Apri DebugDiag Collection e seleziona
Performance
.- Selezionare
Performance Counters
e fare clic suNext
. - Fare clic su
Add Perf Triggers
. - Espandi l'oggetto
Processor
(nonProcess
) e seleziona% Processor Time
. Tieni presente che se utilizzi Windows Server 2008 R2 e hai più di 64 processori, scegli l'oggettoProcessor Information
invece dell'oggettoProcessor
. - Nell'elenco delle istanze, seleziona
_Total
. - Fare clic su
Add
e quindi suOK
. Seleziona il trigger appena aggiunto e fai clic su
Edit Thresholds
.- Seleziona
Above
nel menu a discesa. - Cambia la soglia a
80
. Immettere
20
per il numero di secondi. È possibile modificare questo valore se necessario, ma fare attenzione a non specificare un numero ridotto di secondi per evitare falsi trigger.- Fare clic su
OK
. - Fare clic su
Next
. - Fare clic su
Add Dump Target
. - Seleziona
Web Application Pool
dal menu a discesa. - Seleziona il tuo pool di applicazioni dall'elenco dei pool di app.
- Fare clic su
OK
. - Fare clic su
Next
. - Fare di nuovo clic su
Next
. - Se lo desideri, inserisci un nome per la tua regola e prendi nota della posizione in cui verranno salvati i dump. È possibile modificare questa posizione se lo si desidera.
- Fare clic su
Next
. - Selezionare
Activate the Rule Now
e fare clic suFinish
.
La regola descritta creerà un set di file minidump che saranno di dimensioni abbastanza ridotte. Il dump finale sarà un dump con memoria piena e i dump saranno molto più grandi. Ora, dobbiamo solo aspettare che l'evento CPU alta si ripeta.
Una volta che avremo i file di dump nella cartella selezionata, utilizzeremo lo strumento di analisi DebugDiag per analizzare i dati raccolti:

Seleziona Analizzatori di prestazioni.
Aggiungi i file di dump.
Avvia analisi.
DebugDiag impiegherà alcuni (o diversi) minuti per analizzare i dump e fornire un'analisi. Una volta completata l'analisi, vedrai una pagina web con un riepilogo e molte informazioni sui thread, simile alla seguente:
Come puoi vedere nel riepilogo, c'è un avviso che dice "È stato rilevato un utilizzo elevato della CPU tra i file di dump su uno o più thread". Se facciamo clic sulla raccomandazione, inizieremo a capire dove si trova il problema con la nostra applicazione. Il nostro rapporto di esempio si presenta così:
Come possiamo vedere nel rapporto, esiste uno schema relativo all'utilizzo della CPU. Tutti i thread che hanno un utilizzo elevato della CPU sono correlati alla stessa classe. Prima di saltare al codice, diamo un'occhiata al primo.
Questo è il dettaglio del primo thread con il nostro problema. La parte che ci interessa è la seguente:
Qui abbiamo una chiamata al nostro codice GameHub.OnDisconnected()
che ha attivato l'operazione problematica, ma prima di quella chiamata abbiamo due chiamate Dictionary, che possono dare un'idea di cosa sta succedendo. Diamo un'occhiata al codice .NET per vedere cosa sta facendo quel metodo:
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(); }
Ovviamente abbiamo un problema qui. Lo stack di chiamate dei rapporti diceva che il problema riguardava un dizionario e in questo codice stiamo accedendo a un dizionario, e in particolare la riga che causa il problema è questa:
if (onlineSessions.TryGetValue(userId, out connId))
Questa è la dichiarazione del dizionario:
static Dictionary<int, string> onlineSessions = new Dictionary<int, string>();
Qual è il problema con questo codice .NET?
Tutti coloro che hanno esperienza di programmazione orientata agli oggetti sanno che le variabili statiche saranno condivise da tutte le istanze di questa classe. Diamo uno sguardo più approfondito su cosa significa statico nel mondo .NET.
Secondo la specifica .NET C#:
Utilizzare il modificatore static per dichiarare un membro statico, che appartiene al tipo stesso anziché a un oggetto specifico.
Questo è ciò che dicono le specifiche di .NET C# langunge per quanto riguarda le classi statiche e i membri:
Come nel caso di tutti i tipi di classe, le informazioni sul tipo per una classe statica vengono caricate dal Common Language Runtime (CLR) di .NET Framework quando viene caricato il programma che fa riferimento alla classe. Il programma non può specificare esattamente quando viene caricata la classe. Tuttavia, è garantito che venga caricato e che i suoi campi vengano inizializzati e il suo costruttore statico venga chiamato prima che la classe venga referenziata per la prima volta nel programma. Un costruttore statico viene chiamato solo una volta e una classe statica rimane in memoria per la durata del dominio dell'applicazione in cui risiede il programma.
Una classe non statica può contenere metodi, campi, proprietà o eventi statici. Il membro statico è richiamabile su una classe anche quando non è stata creata alcuna istanza della classe. Al membro statico si accede sempre dal nome della classe, non dal nome dell'istanza. Esiste solo una copia di un membro statico, indipendentemente dal numero di istanze della classe create. I metodi e le proprietà statici non possono accedere a campi ed eventi non statici nel tipo che li contiene e non possono accedere a una variabile di istanza di alcun oggetto a meno che non venga passato in modo esplicito in un parametro del metodo.
Ciò significa che i membri statici appartengono al tipo stesso, non all'oggetto. Vengono inoltre caricati nel dominio dell'applicazione dal CLR, pertanto i membri statici appartengono al processo che ospita l'applicazione e non a thread specifici.
Dato che un ambiente web è un ambiente multithread, perché ogni richiesta è un nuovo thread che viene generato dal processo w3wp.exe
; e dato che i membri statici fanno parte del processo, potremmo avere uno scenario in cui diversi thread tentano di accedere ai dati di variabili statiche (condivise da più thread), che possono eventualmente portare a problemi di multithreading.
La documentazione del dizionario in thread safety afferma quanto segue:
Un
Dictionary<TKey, TValue>
può supportare più lettori contemporaneamente, purché la raccolta non venga modificata. Anche così, l'enumerazione tramite una raccolta non è intrinsecamente una procedura thread-safe. Nel raro caso in cui un'enumerazione sia in conflitto con accessi in scrittura, la raccolta deve essere bloccata durante l'intera enumerazione. Per consentire l'accesso alla raccolta da più thread per la lettura e la scrittura, è necessario implementare la propria sincronizzazione.
Questa affermazione spiega perché potremmo avere questo problema. Sulla base delle informazioni sui dump, il problema riguardava il metodo FindEntry del dizionario:
Se osserviamo l'implementazione del dizionario FindEntry, possiamo vedere che il metodo itera attraverso la struttura interna (bucket) per trovare il valore.
Quindi il codice .NET seguente enumera la raccolta, che non è un'operazione thread-safe.
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(); }
Conclusione
Come abbiamo visto nei dump, ci sono diversi thread che tentano di iterare e modificare una risorsa condivisa (dizionario statico) allo stesso tempo, il che alla fine ha fatto sì che l'iterazione entrasse in un ciclo infinito, facendo sì che il thread consumasse più del 90% della CPU .
Ci sono diverse possibili soluzioni per questo problema. Quello che abbiamo implementato per primo è stato quello di bloccare e sincronizzare l'accesso al dizionario a costo di perdere le prestazioni. Il server si bloccava ogni giorno in quel momento, quindi dovevamo risolverlo il prima possibile. Anche se questa non era la soluzione ottimale, ha risolto il problema.
Il prossimo passo per risolvere questo problema sarebbe analizzare il codice e trovare la soluzione ottimale per questo. Il refactoring del codice è un'opzione: la nuova classe ConcurrentDictionary potrebbe risolvere questo problema perché si blocca solo a livello di bucket che migliorerà le prestazioni complessive. Tuttavia, questo è un grande passo e sarebbero necessarie ulteriori analisi.