Suggerimenti per il miglioramento del codice critico
Per scrivere codice che deve essere eseguito rapidamente è necessario comprendere tutti gli aspetti dell'applicazione e come essa interagisce con il sistema. In questo argomento verranno suggerite alternative ad alcune delle più comuni tecniche di scrittura del codice, al fine di garantire che le porzioni di codice che devono essere eseguite rapidamente forniscano i risultati desiderati.
In breve, per migliorare la scrittura di codice da eseguire rapidamente è necessario:
Sapere quali sono le parti del programma che devono essere eseguite velocemente.
Conoscere le dimensioni e la velocità di esecuzione del codice.
Conoscere l'impatto di nuove funzionalità.
Sapere quali sono le operazioni minime che consentono di eseguire l'operazione.
Per ottenere informazioni sulle prestazioni del codice, è possibile utilizzare Performance Monitor (perfmon.exe).
Tentativi di accesso alla cache ed errori di pagina
Ordinamento e ricerca
Librerie di classi e MFC
Librerie condivise
Heap
Thread
Working set di dimensioni inferiori
Tentativi di accesso alla cache ed errori di pagina
I tentativi di accesso alla cache, sia interna che esterna, non riusciti, nonché gli errori di pagina (le chiamate ad aree di archiviazione secondaria per istruzioni e dati) rallentano l'esecuzione di un programma.
Un tentativo di accesso alla cache della CPU può richiedere al programma 10–20 cicli di clock. Un tentativo di accesso alla cache esterna può richiedere 20–40 cicli di clock. Con un processore che gestisce 500 milioni di istruzioni al secondo e un tempo di 2 millisecondi per un errore di pagina, un errore di pagina può richiedere un milione di cicli di clock. È quindi importante, per ottimizzare l'esecuzione del programma, scrivere codice che consenta di ridurre il più possibile il numero dei tentativi di accesso alla cache non riusciti e degli errori di pagina.
Uno dei motivi della lentezza di alcuni programmi è il fatto che si verifica un numero di errori di pagina o di tentativi di accesso alla cache non riusciti più alto del necessario. Per evitare che questo accada, è importante far uso di strutture di dati con indirizzi contigui o presenti sempre nelle stesse pagine di memoria. Talvolta una struttura di dati che sembra rappresentare la soluzione migliore non fornisce i risultati sperati proprio a causa del cattivo posizionamento dei riferimenti, e viceversa. Di seguito sono riportati due esempi:
Le liste collegate allocate in modo dinamico possono provocare una riduzione delle prestazioni del programma in quanto, quando si esegue la ricerca di un elemento o si passa alla fine della lista, ogni collegamento ignorato potrebbe causare un tentativo di accesso alla cache non riuscito o un errore di pagina. L'implementazione di una lista basata su matrici può risultare molto più veloce perché l'accesso alla cache risulta migliorato e gli errori di pagina vengono ridotti. Nonostante l'aumento di dimensioni della matrice sia più difficile da gestire, alla fine il risultato può comunque rivelarsi più veloce.
Le prestazioni di tabelle hash che utilizzano liste collegate allocate in modo dinamico possono risultare ridotte. Per estensione, le tabelle hash che utilizzano liste collegate allocate in modo dinamico per l'archiviazione dei contenuti possono subire un'ulteriore riduzione di prestazioni. Una semplice ricerca lineare all'interno di una matrice potrebbe, in ultima analisi e a seconda delle circostanze, risultare più rapida. Le tabelle hash basate su matrici a volte non vengono prese in considerazione sebbene offrano spesso prestazioni superiori.
Ordinamento e ricerca
In confronto ad altre operazioni comuni, l'ordinamento è un'operazione che richiede molto tempo. Il modo migliore per evitare rallentamenti non necessari è quello di non eseguire le operazioni di ordinamento nei momenti critici. In genere possibile organizzare il codice in modo da:
Rimandare l'ordinamento a un momento non critico dal punto di vista delle prestazioni.
Ordinare i dati in un momento precedente, non critico dal punto di vista delle prestazioni.
Ordinare solo la parte dei dati necessaria.
Talvolta è possibile compilare una lista già ordinata. Prestare attenzione perché, per l'inserimento di dati ordinati, potrebbe essere necessaria una struttura di dati più complessa con un cattivo posizionamento dei riferimenti, che può causare tentativi di accesso alla cache non riusciti o errori di pagina. Non esiste una soluzione perfetta per tutti i casi. È sempre necessario provare diversi approcci e valutarne le differenze.
Di seguito sono riportati alcuni suggerimenti generici per l'ordinamento:
Utilizzare un ordinamento predefinito per ridurre al minimo i bug.
Per ridurre la complessità dell'ordinamento è sempre consigliabile eseguire in anticipo tutte le operazioni possibili. Se un unico passaggio sui dati semplifica il confronto e riduce l'ordinamento da O(n log n) a O(n), quasi certamente i tempi complessivi ne risulteranno ridotti*.*
Tenere sempre presente il problema della vicinanza dei riferimenti dell'algoritmo di ordinamento e i dati su cui questo verrà eseguito.
Per le ricerche sono disponibili meno alternative che per l'ordinamento. Se la velocità della ricerca è un fattore critico, una ricerca binaria o una ricerca in una tabella hash rappresenta quasi sempre il metodo preferibile, ma, come per l'ordinamento, è necessario tenere presente anche il problema della vicinanza dei dati. Una ricerca lineare in una matrice di piccole dimensioni può essere più veloce rispetto a una ricerca binaria in una struttura di dati con numerosi puntatori, con conseguenti errori di pagina o tentativi di accesso alla cache non riusciti.
Librerie di classi e MFC
Le classi MFC (Microsoft Foundation Classes) possono semplificare notevolmente la scrittura del codice. Quando si scrive codice che deve essere eseguito rapidamente, è opportuno essere consapevoli dell'overhead proprio di alcune classi. Esaminare il codice MFC utilizzato dal codice che deve essere eseguito rapidamente per verificare che soddisfi i requisiti richiesti. L'elenco che segue riporta le funzioni e le classi MFC che è opportuno conoscere:
CString MFC chiama la libreria di runtime C per allocare memoria in modo dinamico per una CString. In termini generali, l'efficacia di CString non è diversa da quella di qualsiasi altra stringa allocata in modo dinamico. Come le altre stringhe allocate in modo dinamico, infatti, presenta l'overhead dovuto all'allocazione dinamica e al conseguente rilascio. Una semplice matrice di char sullo stack spesso può servire al medesimo scopo e garantire maggiore velocità. Non utilizzare una CString per memorizzare una stringa costante. In questo caso, utilizzare const char *. Qualsiasi operazione effettuata con un oggetto CString presenta un certo overhead. L'utilizzo delle funzioni su stringhe della libreria di runtime può risultare più veloce.
CArray Una matrice di tipo CArray garantisce una flessibilità superiore rispetto a una matrice standard, ma è possibile che il programma non richieda tale flessibilità. Se si conoscono i limiti specifici della matrice, è possibile utilizzare una matrice fissa globale. Se si utilizza CArray, utilizzare CArray::SetSize per impostarne le dimensioni e specificare il numero di elementi aggiungibili quando sarà necessaria una riallocazione. In caso contrario, l'aggiunta di elementi potrebbe causare una frequente riallocazione e copia della matrice, con conseguente riduzione delle prestazioni e frammentazione della memoria. Tenere inoltre presente che, se si inserisce una voce in una matrice, con CArray le voci successive verranno spostate in memoria e potrà essere necessario aumentare le dimensioni della matrice. Queste operazioni possono causare tentativi di accesso alla cache non riusciti ed errori di pagina. Attraverso un esame preventivo del codice MFC ci si potrebbe quindi rendere conto della necessità di scrivere codice più specifico per le proprie esigenze al fine di ottenere prestazioni migliori. Dal momento che, ad esempio, CArray è un template, sarà possibile fornirne specifiche più particolari per i singoli casi.
CList CList è un elenco a doppio collegamento, che consente un inserimento di elementi rapido nella parte iniziale, nella parte finale e in corrispondenza di una posizione nota (POSITION) nell'elenco. La ricerca di un elemento per valore o indice richiede tuttavia una ricerca sequenziale che può essere lenta se l'elenco è lungo. Se il codice non richiede un elenco a doppio collegamento, può essere opportuno riconsiderare l'utilizzo di CList. Il ricorso a una lista monodirezionale evita infatti l'overhead dovuto all'aggiornamento di un puntatore supplementare per tutte le operazioni, nonché la memoria necessaria per tale puntatore. La memoria supplementare richiesta non è molta, ma può rappresentare un'ulteriore causa di tentativi di accesso alla cache non riusciti o errori di pagina.
IsKindOf Questa funzione può generare numerose chiamate e accedere a una notevole quantità di memoria in diverse aree di dati. È utile nel caso di una build di debug, ad esempio in una chiamata ASSERT, ma è preferibile non utilizzarla in una build di rilascio.
PreTranslateMessage Utilizzare PreTranslateMessage quando una determinata struttura ad albero di finestre necessita di tasti di scelta rapida diversi o quando è necessario inserire una gestione dei messaggi nel message pump. PreTranslateMessage modifica i messaggi di invio MFC. Eseguire l'override di PreTranslateMessage, se necessario, solo al livello richiesto. Se, ad esempio, si è interessati solo ai messaggi indirizzati ai figli di una determinata visualizzazione, non è necessario eseguire l'override di CMainFrame::PreTranslateMessage. Eseguire invece l'override di PreTranslateMessage per la classe della visualizzazione.
Non cercare di escludere il normale percorso di invio utilizzando PreTranslateMessage per gestire qualsiasi messaggio inviato a qualsiasi finestra. A questo scopo, utilizzare le routine di finestra e le mappe dei messaggi MFC.
OnIdle Gli eventi di inattività possono verificarsi in momenti imprevisti, ad esempio tra gli eventi WM_KEYDOWN e WM_KEYUP. I timer possono rappresentare un modo più efficace per attivare il codice. Non forzare chiamate ripetute di OnIdle generando messaggi falsi o restituendo sempre TRUE da un override di OnIdle, perché in questo modo il thread non sarebbe mai inattivo. Anche in questo caso sarebbe più appropriato utilizzare un timer o un thread separato.
Librerie condivise
Riutilizzare il codice può essere utile. Tuttavia, se il codice che si desidera riutilizzare è stato scritto da altri, sarà opportuno verificarne l'esatto funzionamento nei casi in cui le prestazioni sono essenziali. Il metodo migliore consiste nell'esaminare il codice sorgente un'istruzione alla volta o nell'eseguire misurazioni mediante strumenti quali PView o Performance Monitor.
Heap
È consigliabile ricorrere all'utilizzo di più heap solo in casi particolari. Gli heap aggiuntivi creati con HeapCreate e HeapAlloc consentono di gestire e quindi eliminare un gruppo di allocazioni correlato. Non impegnare una quantità eccessiva di memoria. Se si utilizzano più heap, prestare particolare attenzione alla quantità di memoria inizialmente impegnata.
Anziché più heap, è possibile utilizzare oggetti wrapper di altre funzioni come collegamento tra il codice e l'heap predefinito. Gli oggetti wrapper di altre funzioni facilitano eventuali strategie di allocazione personalizzate che possono migliorare le prestazioni dell'applicazione. Se ad esempio si eseguono frequentemente piccole allocazioni, sarà possibile confinare queste allocazioni a una parte dell'heap predefinito. È possibile allocare un blocco di memoria di grandi dimensioni, quindi utilizzare un oggetto wrapper di altre funzioni per effettuare sottoallocazioni da tale blocco. In questo modo non verranno prodotti heap aggiuntivi con memoria inutilizzata, perché l'allocazione ha origine dall'heap predefinito.
In alcuni casi, tuttavia, il ricorso all'heap predefinito può ridurre la vicinanza dei riferimenti. Utilizzare Process Viewer, Spy++ o Performance Monitor per misurare gli effetti dello spostamento degli oggetti da un heap all'altro.
Misurare gli heap in modo da controllare ogni allocazione su di essi. Utilizzare le routine degli heap di debug runtime di C per definire un checkpoint ed eseguire il dump dell'heap. È possibile esportare l'output in un programma di foglio di calcolo, quale Microsoft Excel, e utilizzare tabelle pivot per visualizzare i risultati. Verificare il numero totale, la dimensione e la distribuzione delle allocazioni. Confrontare questi valori con le dimensioni dei working set. Esaminare inoltre i cluster degli oggetti di dimensioni correlate.
È inoltre possibile utilizzare i contatori delle prestazioni per monitorare l'utilizzo della memoria.
Thread
Per le attività eseguite in background, un'efficace gestione dell'inattività degli eventi può risultare più veloce rispetto all'utilizzo dei thread. I dati correlati sono infatti notevolmente più vicini in un programma a thread singolo.
In genere è buona norma utilizzare un thread solo se la notifica del sistema operativo che si sta analizzando rappresenta la base delle attività in background. In questo caso,i thread rappresentano la miglior soluzione perché non è pratico bloccare un thread principale su un evento.
I thread presentano inoltre problemi di comunicazione. È necessario gestire il collegamento di comunicazione tra i thread mediante un elenco di messaggi oppure allocando e utilizzando memoria condivisa. La gestione del collegamento di comunicazione richiede in genere una sincronizzazione al fine di evitare race condition e problemi di deadlock. Questa complessità può facilmente causare bug e problemi di prestazioni.
Per ulteriori informazioni, vedere Elaborazione di cicli di inattività e Multithreading.
Working set di dimensioni inferiori
Dimensioni inferiori dei working set danno luogo a indirizzi più vicini e provocano meno errori di pagina e più tentativi riusciti di accesso alla cache. Il working set del processo è la metrica più precisa fornita direttamente dal sistema operativo per valutare l'estensione degli indirizzi.
Per impostare i limiti superiore e inferiore del working set, utilizzare SetProcessWorkingSetSize.
Per ottenere i limiti superiore e inferiore del working set, utilizzare GetProcessWorkingSetSize.
Per visualizzare le dimensioni del working set, utilizzare Spy++.