Dopo aver letto questo mio precedente post , dovrebbe essere chiaro come sia possibile che stores/loads possano essere riordinati nel rispetto delle regole del memory model in cui il nostro codice gira.
Se ci pensiamo un attimo, la cosa non dovrebbe toccarci, nel senso che se il compilatore/processore aggiunge/toglie/sposta istruzioni per rendere tutto più performante a noi dovrebbe andare più che bene. Questo è effettivamente vero finchè non facciamo uso di tecniche di programmazione “lock free”, ovvero quando non viene fatto uso di primitive di sincronizzazione(lock(…),Monitor,Events,Mutex) per sincronizzare l’accesso a zone di memoria condivise da più thread. Questa tecnica, rende molto più scalabili e performanti le applicazioni multithread su più processori, in quanto il fatto di non mettere mai il thread in “wait” permette di avere un “throughput” maggiore a confronto di tecniche che utilizzano i lock(nessun thread “blocca” nessun altro thread, se non a basso livello, ma è trasparente a noi e viene fatto dal processore, niente context switch insomma).
Perchè dovrebbero essere un problema quindi queste ottimizzazioni?
Perchè, mentre muovere istruzioni dal punto di vista di un solo thread quindi di un solo percorso di esecuzione non da nessuna inconsistenza, quando sono più di uno i thread che devono eseguire questo percorso in modo parallelo/contemporaneo il riordinamento di una store/load potrebbe inficiare il corretto funzionamento dell’algoritmo portando a inconsistenze e a difficili bugs da scovare/risolvere.
Per poter avere il controllo sul riordinamento(ottimizzazione) fatto da processore/compilatore, possiamo usare delle istruzioni cosiddette fences/barriers(recinto/barriera), attraverso le quali possiamo indicare al processore/compilatore quali tipi ottimizzazioni(spostamenti) sono possibili.
Ricordiamoci che le ottimizzazioni possono essere fatte a qualsiasi livello dello stack software, quindi sia a livello di compilazione che a livello di esecuzione(processore), mettere delle fence a livello di codice(compilatore) non significa metterle anche a livello di processore, tutto questo dipende dal runtime su cui state sviluppando, es:.NET, VC++, C etc..
Detto questo esistono vari tipi di fence(da ora in poi li chiamerò così e non barrier, ma sono la stessa cosa, dipende dal runtime/ambiente in cui state lavorando):
Full fence: nessuna stores o loads possono essere spostate al di là della fence, in tutte e due le direzioni(molte architetture mettono ad disposizione delle istruzioni come ad esempio MFENCE) quindi(pseudo codice):
load A
load B
store A
Fence
store B
store C
load C
le istruzioni stores/loads ABA e BCC non possono essere spostate al di là della fence in nessuna delle due direzioni, le istruzioni dopo la fence non possono muoversi prima, quelle prima non possono spostarsi dopo. Le full fence sono quelle più “rigide” e tutti gli altri tipi vengono usati per non sacrificare troppo le ottimizzazioni.
Store fence: simili alle full fence eccetto che si applicano solo alle istruzioni di stores e permettono lo spostamento delle loads. Quindi(pseudo codice):
load A
load B
store A
Store fence
store B
store C
load C
Le istruzioni loads A,B,C possono essere spostate prima/dopo della fence(questa fence è disponibile su x86 e x64 e viene generalmente esposta con l’istruzione SFENCE)
Load fence: sono il perfetto contrario delle store fence(e vengono comunemente espresse con l’istruzione LFENCE)
load A
load B
store A
Load fence
store B
store C
load C
Le istruzioni di stores A,B,C possono essere spostate prima/dopo della fence.
Come abbiamo detto prima le fence possono essere utilizzate a livello di processore(full fence, store fence, load fence), ma anche a livello di compilazione:
Acquire fence: assicurano che ne le stores ne le loads che ci sono dopo la fence possano essere spostate prima di questa. Le istruzioni che vengono prima possono essere spostate dopo.
load A
load B
store A
Acquire fence
store B
store C
load C
store B,c e load C non possono essere spostate prima della fence.
Release fence: assicurano che ne le stores ne le loads che ci sono prima della fence possano muoversi dopo della stessa. Le istruzioni dopo la fence possono essere spostate prima
load A
load B
store A
Release fence
store B
store C
load C
load A,B e store A non possono essere spostate dopo la fence.
Queste ultime due fence vengono usate dai complilatori e dall’architettura IA64, e hanno la peculiarità di essere applicate solo alla direzione e non al tipo di istruzione.
Dobbiamo assicurarci di applicare la fence sia a livello di compilatore che a livello di processore, in quanto sono 2 layer separati che possono in modo indipendente effettuare dei riordinamenti(in gergo si dice “move” o meglio “move after..” “move before..”).
Esistono vari modi di introdurre delle fence nel nostro codice e questo varia dalla piattaforma che stiamo usando(.NET,VC++, C), come ad esempio il marcare “volatile” una variabile con .NET o usare delle macro in VC++, ma ce ne sono altri e questo è argomento per un’altro post.
Per concludere è bene sottolineare che il riordinamento che viene effettuato a livello di compilatore/processore, rispetta sempre la “data dependence”(dipendenza dei dati) per non provocare errori logici durante le stores/loads(altro argomento un pò complesso che è bene trattare separatamente).
Fonti: Concurrent programming on Windows(Joe Duffy)