Condividi tramite


Profiler Stack Walking in .NET Framework 2.0: Nozioni di base e oltre

 

Settembre 2006

David Broman
Microsoft Corporation

Si applica a:
   Microsoft .NET Framework 2.0
   Common Language Runtime (CLR)

Riepilogo: Descrive come è possibile programmare il profiler per eseguire la procedura dettagliata di stack gestiti in Common Language Runtime (CLR) di .NET Framework. (14 pagine stampate)

Contenuto

Introduzione
Chiamate sincrone e asincrone
Mixarlo
Essere sul tuo comportamento migliore
Quando è troppo, è troppo
Credito a causa del quale il credito è dovuto
Informazioni sull'autore

Introduzione

Questo articolo è destinato a chiunque sia interessato alla creazione di un profiler per esaminare le applicazioni gestite. Verrà descritto come è possibile programmare il profiler per eseguire la procedura dettagliata di stack gestiti in Common Language Runtime (CLR) di .NET Framework. Cercherò di mantenere la luce dell'umore, perché l'argomento stesso può essere pesante a volte.

L'API di profilatura nella versione 2.0 di CLR ha un nuovo metodo denominato DoStackSnapshot che consente al profiler di seguire lo stack di chiamate dell'applicazione profilata. La versione 1.1 di CLR ha esposto funzionalità simili tramite l'interfaccia di debug in-process. Ma a piedi lo stack di chiamate è più semplice, più accurato e più stabile con DoStackSnapshot. Il metodo DoStackSnapshot usa lo stesso stack walker usato dal Garbage Collector, dal sistema di sicurezza, dal sistema di eccezione e così via. Quindi sai che deve essere giusto.

L'accesso a una traccia dello stack completo offre agli utenti del profiler la possibilità di ottenere l'immagine generale di ciò che succede in un'applicazione quando accade qualcosa di interessante. A seconda dell'applicazione e di ciò che un utente vuole profilare, è possibile immaginare che un utente voglia uno stack di chiamate quando viene allocato un oggetto, quando viene caricata una classe, quando viene generata un'eccezione e così via. Anche ottenere uno stack di chiamate per un evento diverso da un evento dell'applicazione, ad esempio un evento timer, sarebbe interessante per un profiler di campionamento. L'analisi dei punti caldi nel codice diventa più illuminante quando è possibile vedere chi ha chiamato la funzione che ha chiamato la funzione che ha chiamato la funzione contenente l'hot spot.

Mi concentrerò sul recupero delle tracce dello stack con l'API DoStackSnapshot . Un altro modo per ottenere le tracce dello stack consiste nel creare stack shadow: è possibile associare FunctionEnter e FunctionLeave per mantenere una copia dello stack di chiamate gestite per il thread corrente. La compilazione dello stack shadow è utile se sono necessarie informazioni sullo stack in qualsiasi momento durante l'esecuzione dell'applicazione e, se non si ha mente il costo delle prestazioni di avere il codice del profiler eseguito in ogni chiamata gestita e restituire. Il metodo DoStackSnapshot è preferibile se è necessaria una segnalazione leggermente sparse degli stack, ad esempio in risposta agli eventi. Anche un profiler di campionamento che accetta snapshot dello stack ogni pochi millisecondi è molto più sparse rispetto alla creazione di stack shadow. Quindi DoStackSnapshot è adatto per i profiler di campionamento.

Fare una passeggiata dello stack sul lato selvaggio

È molto utile essere in grado di ottenere stack di chiamate ogni volta che si desidera. Ma con il potere viene responsabilità. Un utente del profiler non vuole che lo stack cammina per causare una violazione di accesso (AV) o un deadlock nel runtime. Come scrittore del profiler, devi esercitare il tuo potere con cura. Parlerò di come usare DoStackSnapshot e come farlo con attenzione. Come si noterà, più si vuole fare con questo metodo, più è difficile farlo correttamente.

Esaminiamo il nostro argomento. Ecco le chiamate del profiler (è possibile trovare questa opzione nell'interfaccia ICorProfilerInfo2 in 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); 

Il codice seguente è quello che viene chiamato da CLR nel profiler. (È anche possibile trovare questo in Corprof.idl.) Passare un puntatore all'implementazione di questa funzione nel parametro callback dell'esempio precedente.

typedef HRESULT __stdcall StackSnapshotCallback( 
  FunctionID funcId, 
  UINT_PTR ip, 
  COR_PRF_FRAME_INFO frameInfo, 
  ULONG32 contextSize, 
  BYTE context[], 
  void *clientData); 

È come un panino. Quando il profiler vuole seguire lo stack, si chiama DoStackSnapshot. Prima che CLR venga restituito da tale chiamata, chiama la funzione StackSnapshotCallback più volte, una volta per ogni frame gestito o per ogni esecuzione di frame non gestiti nello stack. La figura 1 mostra questo sandwich.

Figura 1. Un "sandwich" di chiamate durante la profilatura

Come si può vedere dalle mie notazioni, CLR notifica i fotogrammi nell'ordine inverso dal modo in cui sono stati spostati nello stack, fotogramma foglia prima (pushed last), frame main last (pushed first).

Che cosa significano tutti i parametri per queste funzioni? Non sono ancora pronto a discutere di loro, ma parlerò di alcuni di loro, a partire da DoStackSnapshot. (mi renderà il resto in pochi momenti). Il valore infoFlags proviene dall'enumerazione COR_PRF_SNAPSHOT_INFO in Corprof.idl e consente di controllare se CLR ti darà i contesti di registrazione per i frame che segnala. È possibile specificare qualsiasi valore desiderato per clientData e CLR lo restituirà nella chiamata StackSnapshotCallback .

In StackSnapshotCallback, CLR usa il parametro funcId per passare il valore FunctionID del frame attualmente a piedi. Questo valore è 0 se il frame corrente è un'esecuzione di frame non gestiti, che parlerò più avanti. Se funcId è diverso da zero, è possibile passare funcId e frameInfo ad altri metodi, ad esempio GetFunctionInfo2 e GetCodeInfo2, per ottenere altre informazioni sulla funzione. È possibile ottenere immediatamente queste informazioni sulla funzione, durante la procedura dettagliata dello stack o salvare in alternativa i valori funcId e ottenere le informazioni sulla funzione in un secondo momento, riducendo l'impatto sull'applicazione in esecuzione. Se si ottengono le informazioni sulla funzione in un secondo momento, tenere presente che un valore frameInfo è valido solo all'interno del callback che lo dà all'utente. Anche se è consigliabile salvare i valori funcId per un uso successivo, non salvare frameInfo per un uso successivo.

Quando si torna da StackSnapshotCallback, in genere si restituirà S_OK e CLR continuerà a camminare lo stack. Se si desidera, è possibile restituire S_FALSE, che arresta la passeggiata dello stack. La chiamata DoStackSnapshot restituirà quindi CORPROF_E_STACKSNAPSHOT_ABORTED.

Chiamate sincrone e asincrone

È possibile chiamare DoStackSnapshot in due modi, in modo sincrono e asincrono. Una chiamata sincrona è la più semplice da ottenere correttamente. Si effettua una chiamata sincrona quando il CLR chiama uno dei metodi ICorProfilerCallback(2) del profiler e in risposta si chiama DoStackSnapshot per seguire lo stack del thread corrente. Questa operazione è utile quando si vuole visualizzare l'aspetto dello stack in un punto di notifica interessante come ObjectAllocated. Per eseguire una chiamata sincrona, si chiama DoStackSnapshot dall'interno del metodo ICorProfilerCallback(2), passando zero o null per i parametri che non ti ho detto.

Una procedura dettagliata dello stack asincrono si verifica quando si cammina lo stack di un thread diverso o si interrompe in modo forzato un thread per eseguire una procedura di stack (su se stessa o su un altro thread). L'interruzione di un thread comporta l'hijacking del puntatore delle istruzioni del thread per forzarlo per eseguire il proprio codice in momenti arbitrari. Questo è insaneamente pericoloso per troppi motivi per elencare qui. Per favore, non farlo. Limiterò la mia descrizione dello stack asincrono che illustra come usare non hijacking di DoStackSnapshot per seguire un thread di destinazione separato. Chiamare questo "asincrono" perché il thread di destinazione è stato eseguito in un punto arbitrario al momento dell'inizio della procedura dettagliata dello stack. Questa tecnica viene comunemente usata dai profiler di campionamento.

Camminare tutto su qualcun altro

Si suddivide il thread incrociato, ovvero l'asincrono, lo stack cammina un po'. Sono presenti due thread: il thread corrente e il thread di destinazione. Il thread corrente è il thread che esegue DoStackSnapshot. Il thread di destinazione è il thread il cui stack viene eseguito a piedi da DoStackSnapshot. Specificare il thread di destinazione passando il relativo ID thread nel parametro thread a DoStackSnapshot. Ciò che accade di seguito non è per il debole del cuore. Tenere presente che il thread di destinazione esegue codice arbitrario quando viene chiesto di eseguire lo stack. Quindi CLR sospende il thread di destinazione e rimane sospeso tutto il tempo in cui viene eseguito il passaggio. Questa operazione può essere eseguita in modo sicuro?

Sono felice che ti abbia chiesto. Questo è davvero pericoloso, e parlerò più avanti di come farlo in modo sicuro. Ma prima di tutto, vado a entrare in stack in modalità mista.

Mixarlo

Un'applicazione gestita non è probabile che passi tutto il tempo nel codice gestito. Le chiamate PInvoke e l'interoperabilità COM consentono al codice gestito di chiamare il codice non gestito e a volte di nuovo con delegati. E il codice gestito chiama direttamente nel runtime non gestito (CLR) per eseguire la compilazione JIT, gestire le eccezioni, eseguire Garbage Collection e così via. Quindi, quando si esegue una procedura dettagliata dello stack, è probabile che si verifichi uno stack in modalità mista: alcuni frame sono funzioni gestite e altri sono funzioni non gestite.

Crescere, già!

Prima di continuare, un breve interlude. Tutti sanno che gli stack nei nostri PC moderni crescono (ovvero "push") a indirizzi più piccoli. Ma quando visualizziamo questi indirizzi nella nostra mente o nelle lavagne, non siamo d'accordo con come ordinarli verticalmente. Alcuni di noi immaginano che la pila cresce ( piccoli indirizzi in cima); alcuni lo vedono in crescita (piccoli indirizzi sul fondo). Questo problema è suddiviso anche nel nostro team. Ho scelto di affiancare qualsiasi debugger che abbia mai usato: le tracce dello stack di chiamate e i dump di memoria indicano che i piccoli indirizzi sono "sopra" i grandi indirizzi. Così gli stack crescono; main è nella parte inferiore, il chiamato foglia si trova nella parte superiore. Se non sei d'accordo, dovrai fare qualche ridisposizione mentale per passare attraverso questa parte dell'articolo.

Cameriere, ci sono buchi nel mio stack

Ora che stiamo parlando della stessa lingua, esaminiamo uno stack in modalità mista. La figura 2 illustra un esempio di stack in modalità mista.

Figura 2. Stack con frame gestiti e non gestiti

Tornare indietro, è utile capire perché DoStackSnapshot esiste in primo luogo. È disponibile per facilitare l'spostamento dei fotogrammi gestiti nello stack. Se si tenta di eseguire manualmente i frame gestiti, si otterrebbero risultati inaffidabili, in particolare nei sistemi a 32 bit, a causa di alcune convenzioni di chiamata wacky usate nel codice gestito. CLR riconosce queste convenzioni di chiamata e DoStackSnapshot può quindi aiutare a decodificarli. Tuttavia, DoStackSnapshot non è una soluzione completa se si vuole essere in grado di camminare per l'intero stack, inclusi i fotogrammi non gestiti.

Ecco dove è possibile scegliere:

Opzione 1: Non eseguire alcuna operazione e creare stack di report con "fori non gestiti" agli utenti o ...

Opzione 2: scrivere il proprio stack walker non gestito per riempire quei fori.

Quando DoStackSnapshot si trova in un blocco di fotogrammi non gestiti, chiama la funzione StackSnapshotCallback con funcId impostato su 0, come accennato in precedenza. Se si usa l'opzione 1, è sufficiente eseguire alcuna operazione nel callback quando funcId è 0. CLR chiamerà di nuovo per il frame gestito successivo ed è possibile riattivarsi a quel punto.

Se il blocco non gestito è costituito da più frame non gestiti, CLR chiama ancora StackSnapshotCallback una sola volta. Tenere presente che CLR non si impegna a decodificare il blocco non gestito, con informazioni interne speciali che consentono di ignorare il blocco al frame gestito successivo ed è così che procede. CLR non sa necessariamente cosa c'è all'interno del blocco non gestito. Questo è per voi capire, quindi opzione 2.

Il primo passaggio è un doozy

Indipendentemente dall'opzione scelta, riempire i fori non gestiti non è l'unica parte difficile. Solo iniziando la passeggiata può essere una sfida. Esaminare lo stack sopra riportato. Nella parte superiore è presente codice non gestito. A volte sarai fortunato e il codice non gestito sarà CODICE COM o PInvoke . In tal caso, CLR è abbastanza intelligente da sapere come ignorarlo e inizia la procedura al primo frame gestito (D nell'esempio). Tuttavia, è comunque possibile eseguire una procedura dettagliata per il blocco più alto non gestito per segnalare il più possibile uno stack.

Anche se non si vuole percorrere il blocco più alto, potrebbe essere necessario comunque, se non si è fortunati, il codice non gestito non è COM o PInvoke , ma il codice helper in CLR stesso, ad esempio il codice per eseguire la compilazione JIT o l'operazione di Garbage Collection. In questo caso, CLR non sarà in grado di trovare il frame D senza il proprio aiuto. Pertanto, una chiamata non eseguita a DoStackSnapshot genererà l'errore CORPROF_E_STACKSNAPSHOT_UNMANAGED_CTX o CORPROF_E_STACKSNAPSHOT_UNSAFE. (A proposito, è davvero utile visitare corerror.h.)

Si noti che ho usato la parola "unseeded". DoStackSnapshot accetta un contesto di inizializzazione usando i parametri context e contextSize . La parola "context" è sovraccaricata con molti significati. In questo caso, sto parlando di un contesto di registro. Se si utilizzano le intestazioni delle finestre dipendenti dall'architettura (ad esempio, nti386.h) si troverà una struttura denominata CONTEXT. Contiene valori per i registri della CPU e rappresenta lo stato della CPU in un determinato momento. Questo è il tipo di contesto di cui sto parlando.

Se si passa Null per il parametro di contesto , la procedura dettagliata dello stack viene annullata e CLR inizia in alto. Tuttavia, se si passa un valore non Null per il parametro di contesto , che rappresenta lo stato della CPU in un punto inferiore nello stack (ad esempio puntando al frame D), CLR esegue un seeding dello stack con il contesto. Ignora la parte superiore reale dello stack e inizia ovunque punti.

Ok, non è proprio vero. Il contesto passato a DoStackSnapshot è più di un suggerimento rispetto a una direttiva specifica. Se CLR è certo che può trovare il primo frame gestito (perché il blocco più alto non gestito è PInvoke o codice COM), lo eseguirà e ignorerà il valore di inizializzazione. Non prenderlo personalmente, però. CLR sta cercando di aiutarti fornendo la procedura dello stack più accurata possibile. Il valore di inizializzazione è utile solo se il blocco più importante non gestito è il codice helper in CLR stesso, perché non sono disponibili informazioni che consentono di ignorarlo. Pertanto, il valore di inizializzazione viene usato solo quando CLR non è in grado di determinare da solo dove iniziare la procedura.

Ci si potrebbe chiedere come si può fornire il seme a noi in primo luogo. Se il thread di destinazione non è ancora sospeso, non è possibile semplicemente camminare lo stack del thread di destinazione per trovare il frame D e quindi calcolare il contesto di inizializzazione. E tuttavia ti sto dicendo di calcolare il contesto di inizializzazione eseguendo la procedura dettagliata non gestita prima di chiamare DoStackSnapshot e quindi prima che DoStackSnapshot si occupi della sospensione del thread di destinazione per te. Il thread di destinazione deve essere sospeso dall'utente e da CLR? In realtà, sì.

Credo sia il momento di coreografare questo balletto. Ma prima di arrivare troppo approfondita, si noti che il problema di se e come eseguire il seeding di una procedura dettagliata stack si applica solo alle passeggiate asincrone . Se si esegue una procedura sincrona, DoStackSnapshot sarà sempre in grado di trovare il suo modo per raggiungere il fotogramma più gestito senza il tuo aiuto, nessun valore di inizializzazione necessario.

Tutti insieme ora

Per il profiler davvero adventuresome che esegue una passeggiata asincrona, cross-thread, stack seeded durante il riempimento dei fori non gestiti, ecco come apparirebbe una passeggiata stack. Si supponga che lo stack illustrato qui sia lo stesso stack visto nella figura 2, ma solo suddiviso un po'.

Contenuto dello stack Azioni profiler e CLR

1. Sospendi il thread di destinazione. Il conteggio di sospensione del thread di destinazione è ora 1.

2. Si ottiene il contesto di registro corrente del thread di destinazione.

3. Determinare se il contesto del registro punta al codice non gestito, ovvero chiamare ICorProfilerInfo2::GetFunctionFromIP e verificare se si ottiene un valore FunctionID pari a 0.

4. Poiché in questo esempio il contesto del registro punta al codice non gestito, si esegue una procedura dettagliata dello stack non gestito fino a trovare il frame più gestito (Funzione D).

5. Si chiama DoStackSnapshot con il contesto di inizializzazione e CLR sospende nuovamente il thread di destinazione. Il numero di sospensioni è ora 2. Inizia il panino.
a. CLR chiama la funzione StackSnapshotCallback con FunctionID per D.
b. CLR chiama la funzione StackSnapshotCallback con FunctionID uguale a 0. Devi camminare da solo questo blocco. È possibile arrestarsi quando si raggiunge il primo frame gestito. In alternativa, è possibile imbrogliare e ritardare la procedura non gestita fino a un certo momento dopo il callback successivo, perché il callback successivo vi indicherà esattamente dove inizia il frame gestito successivo e quindi dove terminerà la procedura non gestita.
c. CLR chiama la funzione StackSnapshotCallback con FunctionID per C.
d. CLR chiama la funzione StackSnapshotCallback con functionID per B.
e. CLR chiama la funzione StackSnapshotCallback con FunctionID uguale a 0. Anche in questo caso, è necessario camminare manualmente questo blocco.
f. CLR chiama la funzione StackSnapshotCallback con functionID per A.
g. CLR chiama la funzione StackSnapshotCallback con FunctionID per Main.

h. DoStackSnapshot "riprende" il thread di destinazione chiamando l'API Win32 ResumeThread(), che decrementa il conteggio delle sospensioni del thread (il conteggio delle sospensioni è ora 1) e restituisce. Il panino è completo.
6. Si riprende il thread di destinazione. Il conteggio delle sospensioni è ora 0, quindi il thread riprende fisicamente.

Essere sul vostro comportamento migliore

Ok, questo è un modo troppo potere senza una seria cautela. Nel caso più avanzato, si risponde agli interrupt timer e si sospende arbitrariamente i thread dell'applicazione per eseguire il walking degli stack. Yikes!

Essere buoni è difficile e comporta regole che non sono ovvie all'inizio. Quindi diamo un'immersione.

Il seme cattivo

Si inizierà con una regola semplice: non usare un valore di inizializzazione errato. Se il profiler fornisce un valore di inizializzazione non valido (non Null) quando si chiama DoStackSnapshot, CLR restituirà risultati non validi. Esaminerà lo stack in cui viene puntato e farà ipotesi su quali valori nello stack dovrebbero rappresentare. In questo modo, CLR dereferenzia ciò che si presuppone che siano indirizzi nello stack. Dato un valore di inizializzazione non valido, CLR dereferenzierà i valori in una posizione sconosciuta in memoria. CLR esegue tutto il possibile per evitare un av di seconda probabilità all-out, che elimina il processo di profilatura. Ma dovresti davvero fare uno sforzo per ottenere il tuo seme giusto.

Problemi di sospensione

Altri aspetti della sospensione dei thread sono sufficientemente complessi da richiedere più regole. Quando si decide di eseguire il cross-thread walking, si è deciso almeno di chiedere a CLR di sospendere i thread per conto dell'utente. Inoltre, se si vuole camminare il blocco non gestito nella parte superiore dello stack, si è deciso di sospendere i thread da soli senza richiamare la saggezza di CLR su se questo è una buona idea al momento.

Se hai preso lezioni di informatica, probabilmente ricordi il problema dei "filosofi da pranzo". Un gruppo di filosofi è seduto a un tavolo, ognuno con un fork a destra e uno a sinistra. Secondo il problema, ognuno ha bisogno di due forchette per mangiare. Ogni filosofo prende la forcella destra, ma poi nessuno può prendere la forcella sinistra perché ogni filosofo è in attesa del filosofo a sinistra per mettere giù la forchetta necessaria. E se i filosofi sono seduti in un tavolo circolare, avete un ciclo di attesa e un sacco di stomaco vuoti. Il motivo per cui tutti hanno fame è che rompono una semplice regola di evitare deadlock: se hai bisogno di più blocchi, li prendono sempre nello stesso ordine. Seguendo questa regola si evita il ciclo in cui A attende B, B attende C e C attende A.

Si supponga che un'applicazione segua la regola e acquisisca sempre blocchi nello stesso ordine. Ora viene fornito un componente (ad esempio, il profiler) e avvia la sospensione arbitraria dei thread. La complessità è aumentata notevolmente. Cosa succede se il sospenditore deve ora prendere un blocco bloccato dall'utente sospeso? O se il sospenditore necessita di un blocco mantenuto da un thread in attesa di un blocco mantenuto da un altro thread in attesa di un blocco bloccato dall'utente sospeso? La sospensione aggiunge un nuovo bordo al grafico delle dipendenze del thread, che può introdurre cicli. Esaminiamo alcuni problemi specifici.

Problema 1: l'oggetto suspendee possiede blocchi necessari per il sospenditore o necessari per i thread da cui dipende il sospenditore.

Problema 1a: i blocchi sono blocchi CLR.

Come si può immaginare, CLR esegue una grande quantità di sincronizzazione dei thread e pertanto ha diversi blocchi usati internamente. Quando si chiama DoStackSnapshot, CLR rileva che il thread di destinazione possiede un blocco CLR che il thread corrente (il thread che chiama DoStackSnapshot) deve eseguire la procedura dettagliata dello stack. Quando si verifica tale condizione, CLR rifiuta di eseguire la sospensione e DoStackSnapshot restituisce immediatamente con l'errore CORPROF_E_STACKSNAPSHOT_UNSAFE. A questo punto, se il thread è stato sospeso manualmente prima della chiamata a DoStackSnapshot, si riprenderà il thread manualmente ed è stato evitato un problema.

Problema 1b: i blocchi sono i blocchi del profiler.

Questo problema è molto più di un problema di buon senso. Potrebbe essere disponibile la sincronizzazione dei thread personalizzata da eseguire qui e lì. Si supponga che un thread dell'applicazione (Thread A) incontri un callback del profiler ed esegua parte del codice del profiler che accetta uno dei blocchi del profiler. Il thread B deve quindi eseguire la procedura thread A, il che significa che il thread B sospende il thread A. È necessario ricordare che mentre il thread A è sospeso, non è consigliabile che thread B tenti di eseguire uno dei blocchi del profiler che thread A potrebbe possedere. Ad esempio, il thread B eseguirà StackSnapshotCallback durante la procedura dettagliata dello stack, quindi non è consigliabile eseguire blocchi durante il callback che potrebbe essere di proprietà del thread A.

Problema 2: durante la sospensione del thread di destinazione, il thread di destinazione tenta di sospendere l'utente.

Potresti dire: "Questo non può accadere!" Crederci o no, può, se:

  • L'applicazione viene eseguita in una casella multiprocessore e
  • Il thread A viene eseguito su un processore e il thread B viene eseguito su un altro e
  • Il thread A tenta di sospendere il thread B mentre thread B tenta di sospendere il thread A.

In tal caso, è possibile che entrambe le sospensioni vincono e che entrambi i thread vengano sospesi. Poiché ogni thread è in attesa che l'altro lo svegli, rimangono sospesi per sempre.

Questo problema è più scertante del problema 1, perché non è possibile affidarsi a CLR per rilevare prima di chiamare DoStackSnapshot che i thread sospendono l'uno dall'altro. E dopo aver eseguito la sospensione, è troppo tardi!

Perché il thread di destinazione tenta di sospendere il profiler? In un profiler ipotetico, scritto in modo non appropriato, il codice stack-walking, insieme al codice di sospensione, potrebbe essere eseguito da un numero qualsiasi di thread in momenti arbitrari. Si supponga che thread A stia tentando di eseguire la procedura thread B contemporaneamente che il thread B sta tentando di eseguire l'esecuzione del thread A. Entrambi tentano di sospendere l'uno l'altro contemporaneamente, perché stanno eseguendo entrambe la parte SuspendThread della routine stack-walking del profiler. Sia win che l'applicazione sottoposta a profilatura è deadlock. La regola qui è ovvia: non consentire al profiler di eseguire codice stack-walking (e quindi il codice di sospensione) su due thread contemporaneamente.

Un motivo meno ovvio per cui il thread di destinazione potrebbe tentare di sospendere il thread a piedi è dovuto ai lavori interni di CLR. CLR sospende i thread dell'applicazione per facilitare attività come Garbage Collection. Se l'utente tenta di camminare (e quindi sospendere) il thread che esegue l'operazione di Garbage Collection contemporaneamente al thread di Garbage Collector tenta di sospendere il processo, i processi verranno bloccati.

Ma è facile evitare il problema. CLR sospende solo i thread che deve sospendere per svolgere il proprio lavoro. Si supponga che ci siano due thread coinvolti nella procedura dettagliata dello stack. Thread W è il thread corrente (il thread che esegue la procedura dettagliata). Thread T è il thread di destinazione (il thread il cui stack viene camminato). Se Thread W non ha mai eseguito codice gestito e pertanto non è soggetto a Garbage Collection CLR, CLR non tenterà mai di sospendere Thread W. Ciò significa che è sicuro che il profiler abbia Thread W suspend Thread T.

Se si sta scrivendo un profiler di campionamento, è abbastanza naturale garantire tutto questo. In genere si avrà un thread separato della propria creazione che risponde agli interrupt timer e che guida gli stack di altri thread. Chiamare questo thread di campionatore. Poiché si crea manualmente il thread sampler e si ha il controllo su ciò che viene eseguito (e quindi non esegue mai codice gestito), CLR non avrà alcun motivo per sospenderlo. La progettazione del profiler in modo che crei il proprio thread di campionamento per eseguire l'esecuzione di tutti gli stack walking evita anche il problema del profiler "scritto in modo non appropriato" descritto in precedenza. Il thread del campionatore è l'unico thread del profiler che tenta di camminare o sospendere altri thread, quindi il profiler non tenterà mai di sospendere direttamente il thread del campionatore.

Questa è la nostra prima regola nontriviale, quindi per enfasi mi permette di ripeterlo:

Regola 1: solo un thread che non ha mai eseguito codice gestito deve sospendere un altro thread.

Nessuno ama camminare un cadavere

Se si esegue una procedura dettagliata dello stack tra thread, è necessario assicurarsi che il thread di destinazione rimanga attivo per tutta la durata della procedura. Solo perché si passa il thread di destinazione come parametro alla chiamata DoStackSnapshot non significa che al thread di destinazione sia stato aggiunto implicitamente alcun tipo di riferimento di durata. L'applicazione può allontanare il thread in qualsiasi momento. Se ciò si verifica durante il tentativo di camminare sul thread, è possibile causare facilmente una violazione di accesso.

Fortunatamente, CLR notifica ai profiler quando un thread sta per essere eliminato definitivamente, usando il callback con nome appropriato ThreadDestroyed definito con l'interfaccia ICorProfilerCallback(2). È responsabilità dell'utente implementare ThreadDestroyed e attendere il completamento di qualsiasi processo che cammina il thread. Ciò è abbastanza interessante da qualificarsi come regola successiva:

Regola 2: eseguire l'override del callback ThreadDestroyed e attendere l'implementazione fino a quando non si esegue l'esecuzione dello stack del thread da distruggere.

La regola 2 impedisce a CLR di distruggere il thread fino a quando non si esegue l'esecuzione dello stack del thread.

Garbage Collection consente di creare un ciclo

A questo punto le cose possono creare confusione. Si inizierà con il testo della regola successiva e decifrarlo da qui:

Regola 3: Non contenere un blocco durante una chiamata del profiler che può attivare l'operazione di Garbage Collection.

Ho accennato in precedenza che è una cattiva idea per il profiler di tenere uno se il proprio blocco se il thread proprietario potrebbe essere sospeso, e se il thread potrebbe essere camminato da un altro thread che necessita dello stesso blocco. La regola 3 consente di evitare un problema più sottile. In questo caso, sto dicendo che non dovresti contenere blocchi personalizzati se il thread proprietario sta per chiamare un metodo ICorProfilerInfo(2) che potrebbe attivare un'operazione di Garbage Collection.

Un paio di esempi dovrebbero essere utili. Per il primo esempio, si supponga che Thread B esempli l'operazione di Garbage Collection. La sequenza è:

  1. Thread A accetta e ora possiede uno dei blocchi del profiler.
  2. Il thread B chiama il callback GarbageCollectionStarted del profiler.
  3. Il thread B si blocca nel blocco del profiler dal passaggio 1.
  4. Thread A esegue la funzione GetClassFromTokenAndTypeArgs .
  5. La chiamata GetClassFromTokenAndTypeArgs tenta di attivare un'operazione di Garbage Collection, ma rileva che un'operazione di Garbage Collection è già in corso.
  6. Blocchi A del thread, in attesa del completamento dell'operazione di Garbage Collection (Thread B). Il thread B è tuttavia in attesa del thread A a causa del blocco del profiler.

La figura 3 illustra lo scenario in questo esempio:

Figura 3. Un deadlock tra il profiler e il Garbage Collector

Il secondo esempio è uno scenario leggermente diverso. La sequenza è:

  1. Thread A accetta e ora possiede uno dei blocchi del profiler.
  2. Il thread B chiama il callback ModuleLoadStarted del profiler.
  3. Il thread B si blocca nel blocco del profiler dal passaggio 1.
  4. Thread A esegue la funzione GetClassFromTokenAndTypeArgs .
  5. La chiamata GetClassFromTokenAndTypeArgs attiva un'operazione di Garbage Collection.
  6. Il thread A (che ora esegue l'operazione di Garbage Collection) attende che il thread B sia pronto per la raccolta. Il thread B è in attesa del thread A a causa del blocco del profiler.
  7. La figura 4 illustra il secondo esempio.

Figura 4. Un deadlock tra il profiler e un'operazione di Garbage Collection in sospeso

Hai digerito la follia? Il problema è che Garbage Collection dispone di meccanismi di sincronizzazione propri. Il risultato nel primo esempio si verifica perché è possibile eseguire una sola operazione di Garbage Collection alla volta. Questo è certamente un caso marginale, perché le Garbage Collection in genere non si verificano così spesso che uno deve aspettare un altro, a meno che non si stia operando in condizioni stressanti. Anche in questo caso, se si profila abbastanza a lungo questo scenario, e sarà necessario prepararsi per tale scenario.

Il risultato nel secondo esempio si verifica perché il thread che esegue l'operazione di Garbage Collection deve attendere che gli altri thread dell'applicazione siano pronti per la raccolta. Il problema si verifica quando si introduce uno dei propri blocchi nella miscela, formando così un ciclo. In entrambi i casi la regola 3 viene interrotta consentendo al thread A di possedere uno dei blocchi del profiler e quindi chiamare GetClassFromTokenAndTypeArgs. In realtà, la chiamata a qualsiasi metodo che potrebbe attivare un'operazione di Garbage Collection è sufficiente per eliminare il processo.

Probabilmente ci sono diverse domande.

Q. Come si sa quali metodi ICorProfilerInfo(2) potrebbero attivare un'operazione di Garbage Collection?

A. Microsoft prevede di documentare questo documento su MSDN o almeno nel blogdi Jonathan Keljo.

Q. Cosa ha a che fare con l'impalazione a piedi? Non c'è menzione di DoStackSnapshot.

A. Risposta esatta. E DoStackSnapshot non è nemmeno uno dei metodi ICorProfilerInfo(2) che attivano un'operazione di Garbage Collection. Il motivo per cui sto parlando della regola 3 è che è proprio quei programmatori avventuriere che camminano in modo asincrono da campioni arbitrari che probabilmente implementeranno i propri blocchi del profiler, e quindi sono soggetti a cadere in questa trappola. La regola 2 indica essenzialmente di aggiungere la sincronizzazione al profiler. È molto probabile che un profiler di campionamento abbia anche altri meccanismi di sincronizzazione, forse per coordinare la lettura e la scrittura di strutture di dati condivise in momenti arbitrari. Naturalmente, è comunque possibile che un profiler non tocchi mai DoStackSnapshot per riscontrare questo problema.

Quando è troppo, è troppo

Finirò con un breve riepilogo dei punti salienti. Ecco i punti importanti da ricordare:

  • Le passeggiate sincrone dello stack implicano l'esecuzione del thread corrente in risposta a un callback del profiler. Non richiedono seeding, sospensione o regole speciali.
  • Le procedure asincrone richiedono un valore di inizializzazione se l'inizio dello stack è codice non gestito e non fa parte di una chiamata PInvoke o COM. È possibile fornire un valore di inizializzazione sospendendo direttamente il thread di destinazione e passandolo manualmente fino a trovare il frame più gestito. Se in questo caso non si specifica un valore di inizializzazione, DoStackSnapshot può restituire un codice di errore o ignorare alcuni fotogrammi nella parte superiore dello stack.
  • Se è necessario sospendere i thread, tenere presente che solo un thread che non ha mai eseguito codice gestito deve sospendere un altro thread.
  • Quando si eseguono guide asincrone, eseguire sempre l'override del callback ThreadDestroyed per impedire a CLR di distruggere un thread fino al completamento della procedura dettagliata dello stack del thread.
  • Non tenere premuto un blocco mentre il profiler chiama in una funzione CLR che può attivare un'operazione di Garbage Collection.

Per altre informazioni sull'API di profilatura, vedere Profilatura (non gestita) nel sito Web MSDN.

Credito in cui è dovuto il credito

Vorrei includere una nota di grazie al resto del team dell'API di profilatura CLR, perché la scrittura di queste regole è stata veramente un lavoro di team. Grazie speciale a Sean Selitrennikoff, che ha fornito una prima incarnazione di gran parte di questo contenuto.

 

Informazioni sull'autore

David è stato uno sviluppatore di Microsoft per più tempo di quanto si pensi, data la sua limitata conoscenza e maturità. Anche se non è più consentito archiviare il codice, offre ancora idee per i nuovi nomi di variabili. David è un appassionato di Count Chocula e possiede la sua auto.