Il qualificatore volatile

2 luglio 2018

La parola chiave volatile in C++ si applica a particolari casi d’uso in cui si rende necessario impartire al compilatore indicazioni specifiche sulle modalità di ottimizzazione di alcune parti sensibili dei nostri sorgenti.

Nelle lezioni introduttive abbiamo fatto cenno al processo di compilazione ed alle varie fasi che lo caratterizzano, senza addentrarci prematuramente nei dettagli.

Tuttavia, per comprendere l’effetto di questo qualificatore non frequentemente usato, è necessario soffermarsi sulla fase di ottimizzazione dei sorgenti, che viene svolta contestualmente alla fase di traduzione da linguaggio C++ a direttive assembly.

Lo standard C++, infatti, contempla la possibilità che i compilatori effettuino delle ottimizzazioni al codice assembly generato applicando le regole di traduzione specifiche per ogni piattaforma, al fine di rendere più efficiente l’esecuzione di un programma o di ridurne le dimensioni.

Tale libertà è vincolata, ovviamente, soltanto ad alterazioni che non ne modificano il comportamento apparente, e che quindi non inficiano in alcun modo nè la capacità espressiva del linguaggio, nè la codifica fatta dai suoi utenti, cioè il progettista ed il programmatore. Ciò, ovviamente, include anche gli errori di programmazione di natura logica e sintattica.

Pur essendo, in linea di principio, opzionale, in molti contesti la fase di ottimizzazione risulta cruciale ed in alcuni casi indispensabile, ad esempio per lo sviluppo di applicazioni su sistemi embedded con limitate risorse di calcolo.

D’altro canto, esistono vari livelli di ottimizzazione del codice, che possono trovare utilità in alcuni contesti ma non in altri, ed in generale, poichè l’ottimizzazione stessa è un processo oneroso, è desiderabile avere il controllo sul tipo di ottimizzazioni che vogliamo per il nostro programma, di modo da limitare il tempo speso dal compilatore in questa attività.

Il compilatore GCC, ad esempio, espone tale parametro con l’opzione -OX dove X è un numero compreso tra 0 e 3 che rappresenta livelli di ottimizzazione crescenti.

Come esempio si consideri il listato seguente, in cui il controllo di flusso rimane intrappolato all’interno, della funzione main(), in un ciclo infinito la cui condizione di uscita è il valore di una variabile booleana che non viene mai alterata dopo la prima inizializzazione.

 
int main() 
{
	bool x = true;
	while (x);

	return 0;
}

La compilazione di questo sorgente produce eseguibili differenti, a seconda del livello di ottimizzazione prescelto. Ad esempio, i listati seguenti sono generati da GCC 8.1 per architettura Intel x64_x86 con l’opzione -O0 (a sinistra) e -01 (a destra) rispettivamente.

Livello ottimizzazioni: nessuna (-O0) Livello ottimizzazioni: minimo (-O1)
 
		main:
			push    rbp
			mov     rbp, rsp
			mov     BYTE PTR [rbp-1], 1
		.L3:
			cmp     BYTE PTR [rbp-1], 0
			je      .L2
			jmp     .L3
		.L2:
			mov     eax, 0
			pop     rbp
			ret
		
 
		main:
		.L2:
			jmp     .L2
		

Anche senza avere idea della sintassi e della semantica delle istruzioni tradotte in linguaggio assembly, questo caso molto semplice consente di osservare già a colpo d’occhio l’effetto delle ottimizzazioni apportate dal compilatore alla nostra codifica.

A sinistra abbiamo le istruzioni per l’inizializzazione dei registri relativi al segmento di memoria dello stack, l’inizializzazione della variabile booleana nello stack frame corrente, una sezione con un’istruzione che testa il valore booleano e le istruzioni che determinano il ritorno all’istruzione precendete per reiterare il ciclo, o la restituzione del controllo di flusso all’istruzione successiva.

A destra invece, abbiamo una sola istruzione di salto incondizionato che riconduce a se stessa.

Per quanto è osservabile dall’esterno, entrambi gli eseguibili risultanti esibirebbero lo stesso comportamento apparente, anche se sono molto diversi come struttura. In particolare la seconda variante sarebbe molto più efficiente (nel fare niente di particolare!) rispetto alla prima.

È bene chiarire che non c’è nessuna intelligenza da parte del compilatore nel modo in cui il codice viene ottimizzato. Solo una serie di regole che, applicate in sequenza, determinano questo risultato basandosi sulla sola ispezione delle istruzioni riguardanti la variabile x nel suo ambito di visibilità.

L’effetto del qualificatore volatile è quello di inibire la capacità di ottimizzazione del compilatore selettivamente, per le variabili cui esso è applicato. Quando una variabile è volatile quindi, i blocchi di istruzioni in cui essa è impiegata saranno esclusi dall’applicazione di alcune ottimizzazioni, in particolare tutte quelle che riguardano l’ordine di esecuzione, le operazioni di dereferenziazione, scrittura e lettura del suo valore.

Il listato seguente è una versione leggermente modificata del primo, in cui la variabile x è qualificata con volatile:

 
int main() 
{
	volatile bool x = true;
	while (x);

	return 0;
}

Il codice assembly non ottimizzato risultante rimane invariato rispetto la versione precedente, tuttavia quello ottimizzato risulta molto differente.

Livello ottimizzazioni: 0 Livello ottimizzazioni: 1
 
		main:
			push    rbp
			mov     rbp, rsp
			mov     BYTE PTR [rbp-1], 1
		.L3:
			cmp     BYTE PTR [rbp-1], 0
			je      .L2
			jmp     .L3
		.L2:
			mov     eax, 0
			pop     rbp
			ret
		
 
		main:
			mov     BYTE PTR [rsp-1], 1
		.L2:
			movzx   eax, BYTE PTR [rsp-1]
			test    al, al
			jne     .L2
			mov     eax, 0
			ret
		

Anche in questo caso, nella variante ottimizzata, sono scomparse le istruzioni che inizializzano i registri che puntano rispettivamente alla base ad allo stack frame corrente dello stack.

Tuttavia, l’istruzione di salto incondizionato jmp è stata sostituita da una sequenza di istruzioni che effettuano il caricamento del valore booleano nel registro eax, e modificano i flag della unità di calcolo della CPU, in particolare lo zero flag usato per determinare il valore vero o falso per la condizione di uscita dal ciclo.

Poichè il compilatore è comunque autorizzato ad apportare un certo tipo di ottimizzazioni, anzicchè comparare il valore del registro eax nella sua interezza, ne viene testato solo il byte meno significativo (al è lo mnemonico gli otto bit meno significativi di eax per le architetture Intel). Le ultime due istruzioni assembly infine sono relative al valore di ritorno (zero) e all’uscita dalla funzione main().

Sebbene in questo esempio, l’uso di volatile sia del tutto indifferente, esso è rilevante nel caso di applicazioni per le quali sia richiesto l’interfacciamento con dispositivi di I/O mappati sulla memoria centrale (memory-mapped I/O device), al fine di evitare che la limitata capacità di analisi statica dei sorgenti da parte del compilatore produca ottimizzazioni degeneri, travisando l’intento del programmatore.

Si consideri ad esempio il caso di una variabile la cui locazione di memoria è scrivibile da un dispositivo in maniera asincrona rispetto l’esecuzione del programma. Senza l’uso esplicito di volatile, sarebbe impossibile usare tale variabile in qualsiasi istruzione di controllo di flusso, poichè le politiche di ottimizzazione del compilatore potrebbero degenerare nella completa elisione di una sezione di codice, violando le aspettative del programmatore e la semantica stessa della sua codifica.

volatile e multi-threading

Storicamente, il qualificatore volatile è stato impropriamente usato per implementare schemi di programmazione asincrona, come meccanismo passivo di comunicazione tra thread differenti, immune dalle ottimizzazioni del compilatore.

Tale uso improprio, probabilmente incentivato in passato dall’assenza di costrutti sintattici alternativi efficienti, soffre di una serie di problemi inerenti il fatto che le istruzioni eseguite su variabili volatile non sono atomiche. Senza cioè opportune politiche di sincronizzazione degli accessi, la condivisione di tali variabili in un contesto multi-programmato può facilemente produrre effetti inconsistenti.

Lo standard C++11 ha risolto la diatriba sull’uso di volatile relegandolo al caso di interfacciamento con dispositivi memory-mapped, e introducendo una serie di tipi stardard che integrano il supporto alle operazioni atomiche e che risultano quindi adatti a questo caso d’uso.

Membri e metodi volatile

Anche i membri di una classe possono essere qualificati come volatile. Ad esempio, una classe può incapsulare un dato membro da associare ad un valore letto da un memory-mapped I/O device.

Inoltre, similarmente a quanto accade con il qualificatore const, istanze qualificate come volatile possono richiamare solo ed esclusivamente metodi qualificati volatile a loro volta, come illustrato nell’esempio seguente in cui la dichiarazione della classe Dummy contiene un metodo qualificato con volatile:

 
// dummy.h
#include <iostream>

class Dummy
{
	public:
		Dummy() 
		{
			data = 123;
		}
				  
		int getData() const
		{
			return data;
		}
	
		// metodo volatile
		void printData() volatile 
		{
			std::cout << data << "\n";
		}

	protected:
		int data;
};

Le istanze volatile delle classe Dummy sono limitate all’uso delle sole API della classe che sono anch’esse qualificate con volatile, come illustrato nel seguente caso d’uso:

 
#include "dummy.h"

int main()
{
	volatile Dummy v;   // istanza volatile della classe Dummy
	v.getData();        // errore! metodo non-volatile richiamato su istanza volatile
	v.printData();      // ok
	
	Dummy d;            // istanza non volatile della classe Dummy
	d.getData();        // ok
	d.printData();      // ok
	
	return 0;
}

Il compilatore, infatti, applica i medesimi vincoli di ottimizzazione al blocco di istruzioni contenute in un metodo qualificato con volatile, anche per ciò che riguarda l’ordine delle istruzioni. Quindi un oggetto qualificato con volatile, deve essere manipolato da metodi membri che non hanno subito ottimizzazioni potenzialmente lesive del comportamento atteso.

Funzioni con argomenti volatile

Una funzione o un metodo può accettare parametri passati per riferimento qualificati con volatile, con le stesse limitazioni di cui sopra. Un esempio è dato nel listato seguente in cui una reference ad un’istanza della classe Dummy è usata come argomento di una funzione:

 
#include "dummy.h"

// funzione con argomento volatile passato per reference
void func(volatile Dummy& d)
{
	 d.printData(); // accesso alla sola interfaccia volatile della classe
}

int main()
{
	volatile Dummy v;       // istanza volatile della classe Dummy
	func(v);                
	
	return 0;
}

La semantica di volatile impone l’uso di puntatori o reference al fine di evitare la copia in una variabile temporanea nel passaggio per valore alla funzione, che di fatto invaliderebbe ogni logica di gestione degli accessi alla memoria da parte di entità esterne al programma stesso.

Tutte le lezioni

1 ... 55 56 57 ... 74

Se vuoi aggiornamenti su Il qualificatore volatile inserisci la tua e-mail nel box qui sotto:
Tags:
 
X
Se vuoi aggiornamenti su Il qualificatore volatile

inserisci la tua e-mail nel box qui sotto:

Ho letto e acconsento l'informativa sulla privacy

Acconsento al trattamento dei dati per attività di marketing