Programmazione concorrente
Transcript
Programmazione concorrente
LEZIONE 23 (39:00 - 1:23:00) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Capitolo complicato. Programmazione concorrente A.A. 2013-14 Programmazione di Sistema Obiettivi © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • • • • • • Definire il concetto di thread e comprenderne il modello di esecuzione ed i problemi che solleva Conoscere i meccanismi offerti dai sistemi operativi della classe Win32 per la creazione di programmi multithread Comprendere il significato di interferenza, ordinamento, accesso, blocco e le situazioni in cui si verificano tali anomalie Utilizzare il paradigma ad oggetti per modellare il concetto di attività e di flusso di esecuzione in modo indipendente dal sistema operativo utilizzando le astrazioni fornite dal C++11 Conoscere strategie di progetto di algoritmi concorrenti e valutare le scelte relative Mettere a punto meccanismi di conversione di algoritmi sequenziali in concorrenti Programmazione di Sistema 2 Programmazione sequenziale © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Un programma in esecuzione (processo) ◦ opera all’interno di un proprio spazio di memoria ◦ eseguendo una singola sequenza di istruzioni che inizia in corrispondenza del punto di ingresso del programma • la funzione main() o, nel caso del linguaggio C++, gli eventuali costruttori delle variabili globali • Un programma sequenziale ha un comportamento deterministico • Il sistema operativo garantisce la separazione degli spazi di memoria, permettendo lo svolgimento indipendente di più processi contemporaneamente ◦ a patto di ricevere gli stessi dati in ingresso Programmazione di Sistema 3 Programmazione concorrente © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Un programma concorrente contiene due o più flussi di esecuzione (thread) che si svolgono contemporaneamente ◦ all’interno dello stesso spazio di memoria ◦ per perseguire un obiettivo comune • Il flusso di esecuzione principale richiede la creazione di thread secondari • Il sistema operativo e/o le librerie di supporto all’esecuzione provvedono ad allocare le risorse fisiche necessarie (CPU, area destinata allo stack, …) ◦ All'interno dello stesso processo ◦ alternando l’esecuzione dei diversi thread in modo non deterministico • Ad ogni thread è associata una struttura dati protetta ◦ contiene informazioni quali prossima istruzione da eseguire, cima dello stack, cima della tabella di gestione delle eccezioni,… Programmazione di Sistema 4 ===================================================== Normalmente noi consideriamo programmi in esecuzione, che vivono nel contesto di un processo del SO (= il SO fornisce delle astrazioni al programma, per le quali il programma vede uno spazio di indirizzamento uniforme da 0 a 2**32 e apparentemente crede di essere l'unico ad avere le risorse). E' il SO che usa i suoi superpoteri (kernel mode) per bloccare un programma e dare lo start all'altro. I programmi sequenziali hanno un comportamento deterministico. Il fatto che ci sia un SO garantisce l'esecuzione di molti programmi sequenziali, ognuno nel suo contesto d'esecuzione. Non ci sono impicci perchè ognuno agisce su spazi di indirizzamento differenti. ===================================================== E allora perchè mai farsi del male? Perchè l'evoluzione dell'HW ha fatto sì che nella stessa quantità di silicio si possano mettere molti più blocchi logici, mettendo più core in parallelo con i quali posso potenzialmente eseguire più processi contemporaneamente. Anche nel contesto del singolo programma posso avere vantaggi dall'esecuzione parallela: laddove posso spezzare il mio algoritmo in cose che non si danno fastidio l'un l'altra, posso fare il mio mestiere in meno tempo. In questo senso nascono i thread: a differenza dei processi, in cui ho spazi di indirizzamento differenti e nei quali ci si può parlare utilizzando uno spazio di memoria comune a un costo elevato, in un programma concorrente si hanno due o più flussi di elaborazione che si svolgono nello stesso spazio di indirizzamento. Ci si "parla" molto prima, passandosi puntatori. La cosa ha ovviamente un senso quando i thread sono pensati per eseguire un obiettivo comune, dividendosi i compiti. Nel momento in cui un SO crea un processo alloca uno spazio di indirizzamento virtuale, allocando un thread. Se il flusso di elaborazione vuole essere affiancato, il programma va dal SO e chiede di creare un altro. Quando ciò avviene, tra tutte le cose schedulabili c'è l'attività di due flussi nell'ambito del processo corrente. Non ho bisogno di rimappare la memoria! I due o più thread si alternano in modo deterministico, dettato dallo scheduler del SO, utilizzando le normali regole di scheduling. Si cambierà thread quando si cerca di fare un' operazione bloccante o quando il thread lavora molto (e se ne rende conto da solo o per mezzo del SO). Per fare questo sono segnate alcune informazioni in una zona di memoria protetta. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Vantaggi della programmazione concorrente • Sovrapposizione tra computazione e operazioni di I/O ◦ suddividendo opportunamente il programma in sotto-attività, è possibile sfruttare i tempi di attesa delle operazioni di I/O per eseguire altre parti dell’algoritmo • Riduzione del sovraccarico dovuto alla comunicazione tra processi ◦ poiché lo spazio di memoria è condiviso, non occorre copiare i risultati parziali ricavati da ciascuna attività né serializzarne i contenuti • Utilizzo delle CPU multicore (vero parallelismo) ◦ più flussi di esecuzione possono svolgersi contemporaneamente, riducendo il tempo totale di elaborazione Programmazione di Sistema 5 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Svantaggi della programmazione concorrente • Complessità ◦ la memoria non può più essere pensata come un 䇾deposito statico䇿 in cui i dati non cambiano fino a che non sono esplicitamente modificati dal flusso di esecuzione ◦ le diverse sotto-attività devono coordinare il proprio accesso alla memoria attraverso opportuni costrutti di sincronizzazione • Errori ◦ l’uso superficiale dei costrutti di sincronizzazione porta a blocchi passivi o attivi del programma, impedisce che un dato algoritmo termini o da' origine a malfunzionamenti casuali, estremamente difficili da riprodurre e da eliminare • causati dal comportamento non deterministico e asincrono dell’esecuzione concorrente • gli errori possono manifestarsi cambiando la piattaforma di esecuzione oppure soltanto dopo numerose esecuzioni Programmazione di Sistema 6 Thread in Win32 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Stack separati per i vari thread • Nel mondo Win32 si creano thread utilizzando la funzione di sistema CreateThread(…) • Essa richiede i seguenti parametri ◦ Definita nel file <Windows.h> ◦ security: Il contesto di sicurezza in cui il thread verrà eseguito (NULL indica quello corrente) ◦ stackSize: la dimensione dello stack delle chiamate (0 indica la dimensione di default) ◦ funcStart: L’indirizzo della funzione principale del thread ◦ argList: un puntatore ad un eventuale parametro della funzione principale ◦ initFlags: indica se il thread dovrà essere attivato subito (0) o posto in attesa (CREATE_SUSPENDED) ◦ tIdAddr: l’indirizzo di una variabile in cui sarà memorizzato l’identificativo del thread (un numero intero) • La funzione ritorna un riferimento opaco (handle) che rappresenta l’immagine che il kernel del sistema operativo ha del thread ◦ Attraverso tale handle, si potrà richiedere al sistema operativo di eseguire operazioni sul thread Programmazione di Sistema 7 Creare un Thread © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Thread 1 Thread 2 CreateThread(..., ThreadFunc, p, ...); ThreadFunc(void * p) { ... return; } Programmazione di Sistema 8 Perchè usare la programmazione concorrente? Innanzitutto ho un guadagno perchè il programma può alternare operazioni di computazione pura con operazioni che riguardano l'I/O: si usa il tempo in cui si aspetterebbe il risultato dell'I/O per fare altro. Inoltre creare più thread è meglio che creare più processi: questo perchè se i due si devono scambiare informazioni per co-operare fanno un'operazione costosa (in quanto il SO deve buttare un occhio) Infine, avendo CPU multicore si va davvero "insieme": posso avere più flussi eseguiti in parallelo, e quindi il tempo di elaborazione scende. ===================================================== Tutti questi vantaggi hanno un contraltare. Il primo, grosso, limite è la complessità: quella che vedevamo come memoria (deposito statico in cui scrivo un dato e lì resta) non è più così, perchè in pratica condivido la mia stanza con qualcun altro. Ha senso comunque condividere, ma bisogna prevedere dei meccanismi d'accesso (se sto scrivendo io, magari non farlo anche tu: in pratica ti chiudo una porta). Crescendo la complessità, crescono gli errori. Accanto a quelli tipici della programmazione sequenziale nascono errori aggiuntivi molto peggiori: usando i meccanismi di sincronizzazione a caso potrebbe succedere che il nostro programma vada in deadlock (io aspetto te, tu aspetti me fino alla fine dei tempi) o in livelock (io continuo a fare cose perchè non vedo che tu ci sei, mentre tu sei contento che io stia facendo qualcosa). Sincronizzandosi male è pari ad attraversare un incrocio col semaforo rosso: magari arriviamo dall'altra parte bene, magari no. Questi errori sono difficili da riprodurre e magari quello che sul mio computer funziona, su quello di un altro no: bisogna dimostrare in anticipo la robustezza dell'elaborazione progettata. ===================================================== Se il sistema operativo non offrisse superpoteri, noi non potremmo creare programmi concorrenti. Siccome l'SO eredita poteri dalla CPU col timer che di tanto in tanto manda in supervisor e offre a noi programmatori system calls per giocare con questa cosa, allora il tutto si può fare. Nel mondo Win32 si creano thread paralleli con CreateThread(), definita in <Windows.h>. In Unix si usa la pthread_create(). CreateThread ha bisogno di alcuni parametri che specificano come dev'essere eseguito il thread: - puntatore a una struttura security: chi è l'owner del thread? che diritti ha? Normalmente si scrive NULL, in modo da dargli lo stesso owner del processo corrente. Di default è 1 MB. - stackSize: intero positivo. Quanto è grosso lo stack del thread? - funcStart: indirizzo di una funzione che rappresenta il punto di ingresso del nostro thread. Occhio: una funzione, non un oggetto funzionale. - argList: parametro da passare alla funzione. Possiamo fare un oggetto complicato quanto vogliamo, poi nel thread lo "smontiamo" - initFlags: faccio partire subito il thread oppure no perchè, ad esempio, mi mancano dati? - tldAddr: per memorizzare l'identificativo univoco del thread. Viene ritornato un handle, un intero interpretabile come riferimento opaco che può essere usato per chiedere al sistema operativo di fare qualche operazione col thread. ==================================================== Thread principale, fa un po' di cose sue. A un certo punto invoco CreateThread. Se è tutto lecito esiste un nuovo thread che compete nella lista di quelli schedulabili. Il thread comincia con PC settato sull'indirizzo della funzione - che può ricevere un parametro - che abbiamo indicato. Il thread cesserà di esistere quando la funzione invocata ritornerà. Dettagli dei Thread © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Thread stack Thread Kernel Object pvParam pfnStartAddr Contesto . . SP IP High Low NTDLL.dll registri Proprietà Usage count=2 Suspend count=1 Exit code=STILL_ACTIVE Signaled=FALSE VOID RTLUserThreadStart(f,p) { __try { ExitThread( (*f)(p) ); } __except(e) { ExitProcess(…); } } Programmazione di Sistema 9 Terminare un thread • Un thread termina quando © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 ◦ ◦ ◦ ◦ la funzione associata al thread ritorna il thread invoca ExitThread al proprio interno un altro thread del processo invoca TerminateThread vengono invocate le funzioni ExitProcess(...) o TerminateProcess(...) specificando il processo cui il thread appartiene • Attenzione al codice di startup! N.B.: in C++ usare solo la prima soluzione, altrimenti non vengono chiamati i distruttori di oggetti allocati nello stack del thread Programmazione di Sistema 10 Terminare un thread (2) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • La corretta terminazione di un thread da parte di un altro thread può essere complessa ◦ per garantire la corretta distruzione di tutti gli oggetti presenti nello stack del thread, occorre basarsi su una variabile condivisa, che deve essere ispezionata periodicamente dal thread che si vuole terminare ◦ quando questa assume un dato valore, il thread fa in modo di ritornare dalla propria routine principale Programmazione di Sistema 11 Attenzione attenzione. Se si usano le librerie del C ci sono alcuni impicci, questo perchè ai tempi i sistemi erano monothread. La strtok ad esempio conserva al suo interno una variabile di stato (e non una per ciascun thread). Per questo motivo, se dobbiamo creare thread che potrebbero usare la libreria standard del C bisogna chiamare le funzioni thread qui riportate, in modo da allocare un blocco di memoria chiamato TLS (Thread Local Storage) in modo che i thread non si impiccino. Per qualche motivo non noto CreateThread non è perfettamente sovrapponibile a _beginthreadex, quindi potrebbe essere necessario fare un po' di cast. Thread e librerie C/C++ © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Le librerie standard del C utilizzano variabili globali il cui accesso non è sincronizzato ◦ Se un thread invoca (direttamente o indirettamente) una di tali funzioni, il comportamento risultante non è deterministico • Quando si usano i compilatori Microsoft, sono disponibili le funzioni wrapper ◦ _beginthreadex(…) e _exitthreadex(…) al posto di CreateThread(…) e ExitThread(…) ◦ Garantiscono che vengano create versioni locali al thread delle variabili globali della libreria standard ◦ Definite nel file <process.h> Programmazione di Sistema LEZIONE 24 Cosa fa il sistema operativo quando al suo interno viene invocata la funzione CreateThread()? Internamente alloca un certo numero di strutture dati: - un blocco, della dimensione indicata, che servirà come stack del thread. Con 0 viene assunta la dimensione di default, ovvero 1 MB. Calcolare bene lo spazio di cui si ha bisogno non è molto semplice. D'altro canto non va nemmeno bene sovradimensionare: se faccio un thread con uno stack di 100 MB e ne creo 20, saturo tutta la memoria utente (questo nel caso di architetture a 32 bit). Allocato in memoria utente. - nella zona visibile solo al privilegio kernel è allocato un oggetto con alcune informazioni, una parte meta-informazioni sul thread in quanto tale (Usage count: chi conosce il thread?, Exit code: questo thread è finito? Se sì come è tornato) e una parte di dati fisici del contesto (non è detto che un thread sia sempre in esecuzione, quindi ho bisogno di congelarne lo stato). Quando il thread viene creato, l'IP è fatto puntare a un blocco di codice del sistema operativo, ovvero una funzione che prende un puntatore a funzione e un puntatore a void. Viene dereferenziato f e usato per chiamare la funzione all'indirizzo f passando p. Quando il thread termina avviso il sistema operativo (ExitThread). Se invocando f si termina il thread corrente e tutto il processo. ---------------------------------------------------------------------------------------------Creare thread è facile, terminarli un po' meno. L'unico modo pulito per cui un thread termina è quando la funzione che gli abbiamo passato compie una return. Ci sono degli altri modi per forzare la terminazione di thread, ma incasinano un po' tutto: - invocare ExitThread: di per sè dal punto di vista del SO va bene, dal punto di vista dell'organizzazione delle risorse può essere un problema (perchè magari il costruttore di un oggetto apre un file e il distruttore lo chiude). Quindi fattibile, ma con giudizio. - uccidere un thread conoscendone l'handle, con TerminateThread: occhio, è completamente asincrona, quindi non sappiamo cosa stava facendo quel thread. - ExitProcess e TerminateProcess, che si applicano a tutti i thread del processo: in generale non vanno utilizzate, se non in casi eccezionali. OCCHIO: il main che viene invocato non è il punto di inizio di un processo, ma è la startup function. Quando il main termina i thread secondari vengono uccisi. Quindi è bene non uscire dal main a meno di non essere certi che i thread secondari abbiano finito il loro mestiere. ---------------------------------------------------------------------------------------------A volte capita di dover costruire thread che ad esempio scaricano file da Internet. A un certo punto ci accorgiamo che la cosa diventa lunga e vogliamo stopparla. Il thread principale deve poter dire al thread secondario di piantarla. La soluzione base prevede che prima che il thread secondario venga creato si scelga una variabile globale condivisa o usando il parametro passato al thread. Nel caso più semplice c'è un qualcosa di assimilabile a un booleano che ogni tanto dev'essere controllato nel thread secondario. La terminazione non è istantanea, quindi bisogna aspettare con waitForSingleObject. 12 Scheduler © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Componente del sistema operativo che assegna ad ogni thread un quantum di tempo di CPU in funzione della sua priorità ◦ Nell’architettura win32 non è implementato da un singolo modulo, ma da un insieme di funzioni presenti all’interno del kernel e che collettivamente vengono dette “message dispatcher” • La schedulazione avviene in modalità pre-emptive ◦ Thread con la stessa priorità vengono schedulati con una politica round-robin fino allo scadere del quantum di tempo o fino a che un thread con priorità maggiore diventa disponibile • Una funzione di sistema permette di cambiare la priorità di un thread, che di default è quella associata al processo che lo contiene ◦ Modificare la priorità senza capire cosa si sta facendo, spesso ha conseguenze disastrose: conviene evitare di farlo Programmazione di Sistema 13 Schedulazione © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Lo stato del 䇾processore virtuale䇿 – quando questo non è attivo – viene conservato dallo scheduler nella memoria kernel ◦ le pagine corrispondenti non vengono mai scaricate sul file di paginazione • Quando si verifica un context-switch ◦ lo stato corrente del processore viene salvato nella pagina kernel corrispondente ◦ si seleziona un altro thread, il cui stato viene ripristinato nel processore Programmazione di Sistema Il context-switch è un'operazione che richiede un certo numero di cicli macchina, quindi è bene non abusarne. 14 I thread possono trovarsi in tre stati differenti. - RUNNING: thread attualmente in esecuzione. Tanti thread running quanti sono i core (limite superiore) - READY: thread non in esecuzione da nessuna parte. Se in qualunque momento ci sarà un core libero è lecito che diventi RUNNING (numero indefinito). - WAITING: mentre è RUNNING cerco di leggere da un file e devo aspettare che sia pronto. Congelo il thread corrente e mi segno cosa sta aspettando. Quando è pronto lo metto READY. Stato di un Thread © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 RUNNING Si esegue un'operazione bloccante q Accesso ad un file q Operazione di rete q Attesa su una primitiva di sincronizzazione Quando c䇻è un cambiamento di stato, il primo thread della lista a maggiore priorità viene selezionato un thread a più alta priorità diventa disponibile termina il quantum di tempo Si cede volontariamente il posto ad un altro thread (yield) win32 non ha un'operazione specifica di yield ma viene simulata con un'operazione di wait che ha timeout pari a 0. resumeThread WAITING Condizione di attesa soddisfatta READY Sospensione forzata di un thread (basso livello) Programmazione di Sistema suspendThread 15 Ogni thread ha un suo stack delle chiamate, un puntatore al contesto della gestione delle eccezioni e l'ultima copia aggiornata dei registri. Ci sono poi informazioni condivise da tutti i thread dello stesso processo, ovvero le variabili globali, l'area in cui è memorizzato il codice, le costanti e lo heap. Un thread può sfruttare la condivisione dell'heap per comunicare con altri thread. Thread e memoria • © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Nel momento in cui viene creato un oggetto thread, viene allocata la memoria e il SO prende atto che c'è un thread in più. Lo scheduler quindi lo prende in considerazione e comincia a utilizzarlo. Il thread sta in esecuzione finchè non esegue una funzione bloccante (read su un file e file non pronto) o finchè non scade un limite massimo di tempo (chiamato quantum, stimato in circa 10 ms = 40 milioni di istruzioni). A differenza di quanto succede in altri sistemi operativi, lo scheduler non è un unico blocco di codice ma è formato dalla cooperazione di tante routine diverse annegate nels sistema operativo (message dispatcher). La cosa rende la programmazione multi-thread estremamente complessa. Quando un thread viene selezionato tra tutti quelli eseguibili, viene eseguito e prima o poi si interrompe. A questo punto va al fondo di una coda protetta. Il turno viene riguadagnato prima o poi in quanto la coda è circolare. Ogni thread ha associato nel suo oggetto kernel anche un numerino che indica la priorità, utilizzata per decidere chi va in esecuzione (non c'è un'unica coda, ma tante in base alle priorità). Non dicendo niente di particolare, un thread ha una priorità normal (a metà). E' possibile alzarla o abbassarla: facciamolo con giudizio. Dicendo che il thread tiziocaio ha più priorità non è detto che il sistema vada più veloce, anzi si rischia di far macello rispetto alle risorse. ---------------------------------------------------------------------------------------------Quando il thread non è in esecuzione, la copia dei registri è salvata nell'oggetto kernel. Quando torna ad essere in esecuzione si copiano i valori nei registri e si riparte. La pagina di memoria in cui sono contenuti questi registri è locked e non può mai essere paginata. Occhio quindi a non fare un sacco di zone privilegiate. Ogni thread dispone di ◦ un proprio stack delle chiamate ◦ un proprio puntatore all’ultimo contesto per la gestione delle eccezioni ◦ un proprio 䇾processore virtuale䇿 • I thread dello stesso processo condividono ◦ ◦ ◦ ◦ le variabili globali l’area in cui è memorizzato il codice l’area delle costanti lo heap Programmazione di Sistema 16 Thread e memoria Call Stack 2 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 EP SP IP Altri registri Call Stack 1 Thread 2 EP SP IP Altri registri Thread 1 Heap Var. globali Codice macchina Programmazione di Sistema 17 Eseguire i thread in quasi contemporaneità (vera contemporaneità solo se ho più core) fa sì che l'esecuzione diventi concorrente. Grosso vantaggio perchè si coopera per raggiungere un obiettivo comune. Viene il momento in cui hanno bisogno di parlarsi, e lo fanno usando blocchi che hanno assunto come condivisi. Se però uno legge mentre l'altro scrive è un bel problema: un thread deve dunque regolarsi in modo da capire che quello che sta facendo non impicci gli altri. Esecuzione concorrente © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • L’esecuzione di più thread si alterna nel tempo ◦ Ogni thread accede a variabili locali elementari, memorizzate nello stack (il cui puntatore può essere dischiuso ad altri thread), a dati condivisi sullo heap ed alle variabili globali • Se due o più attività stanno cooperando per raggiungere un obiettivo comune... C'è dunque bisogno di creare meccanismi di sincronizzazione, del tipo "voglio fare una cosa se non la stai già facendo tu", oppure "voglio rimanere bloccato fin quando tu non hai fatto". ◦ ...occorre regolare lo svolgimento di un thread in base anche a quanto sta succedendo negli altri thread! ◦ Questo comporta essere in grado di comunicare delle informazioni e sapere quando esse sono valide/disponibili Programmazione di Sistema 18 Nell'esempio c'è un thread secondario che fa capo alla routine Run. I due thread in questo caso possono andare in parallelo. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Esempio #include <windows.h> #include <stdio.h> #include <process.h> static DWORD WINAPI Run(LPVOID lpParam){ for (int j = 0; j < 10; j++){ _tprintf(_T(" bbbb%d\n"), j); } return 0; } int _tmain(int argc, _TCHAR* argv[]){ HANDLE t1 = (HANDLE)_beginthreadex(NULL,0,Run,NULL,0,NULL); for (int i = 0; i < 10; i++){ _tprintf(_T("aaaa%d\n"), i); } int n; _tscanf(_T("%d"), &n); CloseHandle(t1); return 0; } aaaa0 bbbb0 aaaa1 bbbb1 aaaa2 bbbb2 aaaa3 bbbb3 aaaa4 bbbb4 aaaa5 aaaa6 aaaa7 bbbb5 aaaa8 aaaa9 bbbb6 bbbb7 bbbb8 bbbb9 Programmazione di Sistema 19 Attenzione che l'output riportato è un possibile esempio. Il modo con cui i due thread si interscambiano riguarda tutto quello che sta avvenendo sul nostro PC. Nel caso precedente, l'unica certezza che abbiamo è che gli a sono in ordine (0 > 9) e così i b. L'ordine relativo però è un mistero. Osservazioni © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • L’output del programma precedente è solo uno dei possibili risultati ◦ Se lo stesso programma viene eseguito più volte, si ottengono risultati differenti • • È assolutamente possibile (e a volte succede) che tutte le righe del thread principale precedano quelle del thread secondario L’unica certezza è che le righe che cominciano con 䇾aaaa䇿 sono tra loro ordinate in modo crescente ◦ Così come le righe che cominciano con 䇾bbbb䇿 Programmazione di Sistema 20 Esecuzione e non determinismo © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Una volta iniziata, l’esecuzione di un thread procede secondo le normali regole sequenziali ◦ Se più thread sono in esecuzione, non è possibile fare assunzioni sulle velocità relative di avanzamento • Questo dà origine a comportamenti del tutto inattesi in un contesto di elaborazione sequenziale ◦ In particolare, l’esecuzione ripetuta dello stesso programma (con gli stessi ingressi) può portare a risultati differenti a seguito delle interazioni (complesse) tra il sistema operativo e i dispositivi HW presenti sull’elaboratore Programmazione di Sistema 21 Nell'esempio il main crea due thread che eseguono la stessa funzione e partono uno dopo l'altro. Dopodichè il main si mette in attesa che tutti e due finiscano, per un tempo infinito. Quando sono sicuro che tutti abbiano finito chiudo le loro handle (così da rilasciare il blocco di memoria) e stampo quando vale n, che è una variabile globale. Inizialmente n vale 0. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Esempio (1) int n; int _tmain(int argc, _TCHAR* argv[]) { n = 0; HANDLE hThread[2]; hThread[0] = (HANDLE) _beginthreadex(NULL,0,Run,NULL,0,NULL); hThread[1] = (HANDLE) _beginthreadex(NULL,0,Run,NULL,0,NULL); DWORD dwEvent = WaitForMultipleObjects(2,hThread,TRUE,INFINITE); CloseHandle(hThread[0]); CloseHandle(hThread[1]); cout<<"Risultato: "<< n <<endl; return 0; } Programmazione di Sistema 22 I due thread, per un miliardo di volte: - prendono n e se lo salvano in n0 - incrementano n - prendono il nuovo valore di n e lo salvano in n1 A questo punto se il programma fosse sequenziale, n1 - n0 = 1. - se c'è una differenza si stampa © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Esempio (2) static UINT WINAPI Run(LPVOID lpParam) { for(int i = 0; i < 1000000000; i++){ int n0 = n; //valore precedente La differenza può non venire 1 perchè fotografo n (= 10), lo incremento (= 11) e prima di rifotografarlo un altro thread l'ha incrementato (= 12). Se eseguo questa roba ottengo differenze tra parentesi anche considerevoli. n1 - n0 può venire anche negativa. ++n; int n1 = n; //valore successivo if (n1-n0 != 1){ std::cout<<n0<<" -> "<<n1<<" ("<<n1-n0<<")\n"; } } return 0; } Programmazione di Sistema 23 Esecuzione © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Quindi il thread avanza sequenzialmente per i cazzi suoi, ma non posso dire con certezza che uno finisca prima dell'altro (a meno di non fare le cose in un certo modo). Succedono poi delle cose che non hanno un corrispettivo nel mondo sequenziale: se io ho un certo programma e lo eseguo con gli stessi ingressi tante volte posso vedere risultati diversi. 19372019 -> 19372022 (3) 19416622 -> 19416626 (4) 102589973 -> 102589998 (25) 108792639 -> 108792609 (-30) 109759022 -> 109759094 (72) 117917314 -> 117917399 (85) 119944578 -> 119944552 (-26) 123397102 -> 123397584 (482) 128314912 -> 128314956 (44) 395835151 -> 395835236 (85) 396049424 -> 396098482 (49058) 412859791 -> 412859826 (35) 419214490 -> 419214537 (47) 419406880 -> 419406877 (-3) 433982464 -> 433982472 (8) 436005364 -> 436215900 (210536) 441453011 -> 441454010 (999) 446802106 -> 446802106 (0) Programmazione di Sistema 24 Che la differenza venga grande è abbastanza ovvio. Che venga negativa meno, perchè può succedere la seguente cosa: - io leggo il valore di n ( = 50) - lo incremento (ovvero: n va in un registro, incremento il registro, prendo il registro e lo salvo in n). Prima che n vada a 51 parte l'altro che prende n e lo incrementa, magari fino a 54. La cosa è detta interferenza e succede quando più thread modificano lo stesso dato senza precauzioni. Le interferenze non sono curabili nè predicibili. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Domande • Esaminando l’uscita del programma precedente si vedono molti casi in cui la differenza tra n0 e n1 è superiore a 1 • Talora capita che la differenza sia nulla o negativa ◦ In alcuni casi tale valore è anche molto grande: perché? ◦ Come è possibile, se entrambi i flussi incrementano sempre l'attributo n? • Questo fenomeno viene detto interferenza ◦ Si verifica quando più thread fanno accesso ad un medesimo dato, modificandolo ◦ La sua presenza dà origine a malfunzionamenti casuali, molto difficili da identificare: occorre prevenirli Programmazione di Sistema Dettagli 25 Sembra un’azione innocente, ma nasconde due operazioni in cascata: int temp = n; © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 static UINT WINAPI Run(LPVOID lpParam) n{ = temp+1; for(int i = 0; i < 1000000000; i++){ int n0 = n; //valore precedente ++n; int n1 = n; //valore successivo if (n1-n0 != 1){ std::cout<<n0<<" -> "<<n1<<" ("<<n1-n0<<")\n"; } } return 0; } Programmazione di Sistema Introduciamo la sincronizzazione quando abbiamo dati condivisi tra due thread, in cui il primo modifica e il secondo legge. Se il secondo thread legge mentre si sta scrivendo potrebbe leggere puttanate qualsiasi. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Sincronizzazione Thread1: modifica Thread2: lettura Dato condiviso Programmazione di Sistema 27 Come si risolve il problema? I SO offrono meccanismi elementari di protezione. In Win32 ho oggetti di livello user (CriticalSection e ConditionVariabile) che si appoggiano su oggetti kernel (Mutex, Event e roba del genere). Dentro unix ci sono cose simili ma non uguali, quindi si crea anche la rogna di dover fare codice per più piattaforme. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Sincronizzazione Mentre una modifica è in corso bisogna evitare che altri thread accedano alla risorsa condivisa • ◦ È necessario introdurre un meccanismo che regoli l’accesso alle zone pericolose Ogni sistema operativo offre strumenti leggermente diversi, sia in termini di strutture dati che di API • ◦ ◦ Win32: CriticalSection, ConditionVariable, oggetti kernel (Mutex, Event, Semaphore, Pipe, Mailslot, ...) Unix: oggetti della libreria Pthreads (mutex,condition), oggetti kernel (semafori, pipe, segnali) Programmazione di Sistema 28 Quando si scrive un programma concorrente bisogna garantirne la correttezza. Non deve dunque capitare mai che un thread lavori su un dato mentre un altro lo sta modificando. Il problema esplode se si lavora su strutture dati (come ad esempio liste linkate) e non esiste se i dati condivisi sono in sola lettura. Correttezza (1) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Occorre fare in modo che non capiti mai che… ◦ …un thread “operi” su un oggetto, alterandone il contenuto, mentre un altro sta già operando sullo stesso oggetto ◦ In particolare, non devono essere visibili stati “transitori” dell’oggetto (dovuti al meccanismo di aggiornamento) • Gli oggetti condivisi mutabili devono godere di questa proprietà ◦ Questi mantengono al proprio interno degli “invarianti” identificati dal programmatore ◦ Perché gli oggetti immutabili non sono soggetti ad interferenza? Programmazione di Sistema 29 Le mutazioni devono dunque essere protette da un flag. Idem per l'accesso in lettura (se leggo mentre qualcosa è mutato leggo cazzate). Correttezza (2) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Il programmatore deve strutturare il proprio codice in modo tale da impedire che gli invarianti siano violati ◦ Si effettuano le mutazioni (cambi di stato) attraverso appositi metodi che garantiscono la validità degli invarianti prima e dopo l’esecuzione e che bloccano i tentativi di accesso concorrente ◦ Si accede allo stato attraverso altri metodi che controllano che non ci sia una mutazione in corso e che impediscono che essa inizi mentre si sta facendo accesso allo stato condiviso Programmazione di Sistema 30 Bisogna dunque capire quali sono i punti critici e per ciascuno trovare gli strumenti adatti per risolvere le criticità. Occhio che se si sbaglia è un macello. Compiti del programmatore I linguaggi di programmazione e i sistemi operativi forniscono solo meccanismi semplici di sincronizzazione • È compito del programmatore riconoscere quando e dove utilizzarli • Un uso sbagliato porta a risultati disastrosi… © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Programmazione di Sistema 31 I problemi nascono in primis dall'atomicità: n++ sembra un'operazione sola, ma in realtà è formata da più operazioni. Altro problema: la visibilità. Aver eseguito una certa operazione non vuol dire che tutti gli altri la possano vedere. Dell'ordinamento poi se n'è già parlato. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Accesso condiviso: le cause del problema • Atomicità • Visibilità ◦ Quali operazioni di memoria hanno effetti indivisibili? ◦ Sotto quali condizioni, la scrittura di una variabile da parte di un thread può essere osservata da una lettura eseguita da un altro thread? • Ordinamento ◦ Sotto quali condizioni, sequenze di operazioni effettuate da un thread sono visibili nello stesso ordine da parte di altri thread? Programmazione di Sistema 32 C e C++ non danno nessuna garanzia quando leggiamo o scriviamo sulla memoria. Se vogliamo che il processore si comporti a modo, dobbiamo chiederlo in modo esplicito. Cosa i linguaggi C e C++ non garantiscono © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Accesso non sincronizzato ad un dato L'ottimizzazione della pipeline in caso di programmazione concorrente può portare a risultati inattesi, se non si inseriscono contromisure opportune. ◦ Se due thread eseguiti in parallelo fanno accesso allo stessa struttura dati, rispettivamente in lettura e scrittura, non c'è nessuna garanzia su quale delle due operazioni sia eseguita per prima • Accesso ad un dato mentre un modifica è in corso ◦ Se un thread legge un dato che un altro thread sta modificando, il valore letto può essere diverso sia dal valore iniziale che da quello finale • Ri-ordinamento delle istruzioni ◦ Fintanto che il comportamento di un singolo thread rimane indistinguibile se osservato dall'esterno, sia il compilatore che la CPU possono invertire l'ordine con cui le singole istruzioni sono eseguite Programmazione di Sistema 33 La parte di sinistra è lecitissima in un programma sequenziale. In un programma non sequenziale no, perchè un altro thread può avere cambiato le carte in tavola. Accesso non sincronizzato int val; //variabili globale std::vector<int> v; //globale Idem a destra. Non è detto che un istante dopo aver controllato !v.empty() v sia ancora non vuoto. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 void f(int v) { ... } void // // if thread1() { ... invoco f con |val| (val >=0) f(val) // se c'è un altro // thread, non è detto che // a questo punto val sia // ancora un numero positivo } else { f(-val); //idem } } void threadFunc() { //... if (!v.empty()) { std::cout<<v.front()<<std:endl; // anche qui, v.front() // potrebbe non esistere più } } In generale, non è lecito operare né il lettura né in scrittura su una qualsiasi struttura datti mentre è in corso un'altra scrittura BIBBIA Fanno eccezione: l'accesso a elementi distinti di uno stesso contenitore e l'uso dei flussi a caratteri std::cin, std::cout, std::cerr Programmazione di Sistema 34 Se due thread leggono o scrivono nella stessa locazione di memoria possono leggere dati A CASO. Questo succede perchè a complicare il tutto ci si mette anche la cache. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Lettura durante una modifica • Se, nello stesso istante, due thread differenti leggono e scrivono una stessa cella di memoria il risultato della lettura è impredicibile ◦ Potrebbe essere restituito il valore precedente alla scrittura ◦ Potrebbe essere restituito il valore scritto ◦ Potrebbe essere restituito un qualsiasi altro valore • Per comprendere il perché occorre entrare in maggior dettaglio su come sia organizzato il sottosistema di memoria Programmazione di Sistema 35 Il sottosistema di memoria © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Tra il processore e la memoria RAM viene normalmente interposto uno o più livelli di memoria cache ◦ Accesso estremamente rapido ◦ Contiene una copia delle locazioni di memoria a cui si è fatto accesso più recentemente • Tre tipi di cache ◦ I(nstruction)-Cache ◦ D(ata)-Cache ◦ TLB-Cache – Translation Lookaside Buffer, mantiene le corrispondenze tra memoria virtuale e fisica Programmazione di Sistema 36 L2 Cache L2 Cache L1 I-Cache T0 L1 D-Cache T1 L1 I-Cache T0 L1 D-Cache T1 L1 I-Cache T0 L1 D-Cache T1 Core 0 T1 Core 1 T0 L1 D-Cache Core 2 L2 Cache L1 I-Cache Core 3 L2 Cache Cache L3 Memoria principale © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Il sottosistema di memoria (2) Intel Core i7-950 Programmazione di Sistema 37 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Il sottosistema di memoria (3) • Le dimensioni delle memorie cache aumentano con il livello • Nel caso dei processori Intel Core i7-9xx ◦ Così come i tempi di latenza ◦ ◦ ◦ ◦ L1-Cache : 32+32 Kbyte, latenza 4 cicli L2-Cache: 256Kbyte, latenza 11 cicli L3-Cache: 8Mbyte, latenza 39 cicli Memoria principale: fino a 24Gbyte, latenza 107 cicli Programmazione di Sistema 38 Cache e sincronizzazione (1) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Per ottimizzare il proprio funzionamento interno, un processore può modificare l’ordine delle operazioni che coinvolgono il sottosistema di memoria ◦ A patto di non violare il risultato di un esecuzione puramente sequenziale Le operazioni di memoria, effettuate da un thread in un sistema multicore possono diventare visibili al resto del sistema in un ordine diverso rispetto a quello in cui sono avvenute Programmazione di Sistema 39 Usando opportunamente le primitive di sincronizzazione, ciò che succede è che internamente vengono chiamate istruzioni dell'Assembler del processore che gestiscono la memoria e garantiscono la lettura/scrittura come ce l'aspettiamo. Siccome questa cosa porta a un x25 sul costo in scrittura, bisogna usarla con parsimonia (solo sui dati condivisi) Cache e sincronizzazione (2) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • In caso di elaborazione concorrente, tali modifiche possono impedire il corretto funzionamento degli altri flussi ◦ I processori includono particolari istruzioni che fungono da “barriera di memoria” e che provocano il corretto riallineamento delle operazioni di lettura e scrittura • I costrutti di sincronizzazione presenti nelle librerie del sistema operativo provvedono ad invocare tali istruzioni ◦ Possono rendere l’esecuzione un ordine di grandezza più lenta (nell’architettura x86 richiedono circa100 cicli) Programmazione di Sistema 40 Cache e sincronizzazione (3) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Nel caso dei processori x86, esistono le seguenti istruzioni assembler ◦ mfence – Full memory fence • Garantisce che tutte le operazioni di lettura e/o scrittura precedenti siano visibili al resto del sistema prima che diventi visibile una qualunque operazione futura ◦ sfence – Store fence • Garantisce che tutte le operazioni di scrittura precedenti siano visibili al resto del sistema prima che diventi visibile una qualunque operazione futura di scrittura ◦ lfence – Load fence • Garantisce che tutte le operazioni di lettura precedenti siano visibili al resto del sistema prima che diventi visibile una qualunque operazione futura di lettura Programmazione di Sistema 41 Concorrenza e Ottimizzazioni del Compilatore (I) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Quando sono presenti istruzioni successive che operano sullo stesso dato ◦ codice ottimizzato => il dato viene posto in un registro per ridurre il numero di operazioni • Se più thread operano contemporaneamente ◦ il valore del dato può cambiare in modo inatteso ◦ le ottimizzazioni introdotte originano codice errato • Si possono disabilitare queste operazioni dichiarando il dato volatile Programmazione di Sistema 42 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Concorrenza e Ottimizzazioni del Compilatore (II) • Data una porzione di codice priva di punti di ingresso ed uscita intermedi, la sequenza di operazioni elementari può essere modificata per ridurre il tempo di esecuzione ◦ L’esecuzione concorrente può diventare incorretta ◦ L’utilizzo della parola chiave 䇾volatile䇿 non è sufficiente a garantire la sicurezza ◦ Nel linguaggio C e C++ manca un modello di memoria concorrente: ogni compilatore può scegliere come comportarsi Programmazione di Sistema volatile bool b=false; //operazioni su dati dato[0]=1; dato[1]=2; b=true; /* segnala agli altri thread che i dati sono validi */ /* il compilatore potrebbe generare questo codice */ volatile bool b=false; b=true; //operazioni su dati dato[0]=1; dato[1]=2; 43 Concorrenza e Ottimizzazioni del Compilatore (III) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Sebbene sia possibile implementare algoritmi concorrenti privi di lock, occorre valutare attentamente quanto si sta progettando ◦ Spesso, infatti, il comportamento dei sistemi multicore risulta contro-intuitivo e dà origine a situazioni inattese • Di conseguenza, è opportuno utilizzare le funzioni di sincronizzazione offerte dalla piattaforma sottostante ◦ Queste includono quanto necessario per garantire la corretta semantica delle operazioni richieste Programmazione di Sistema 44 Sincronizzare rallenta un po' tutto, ma è necessario. Se non lo si fa, introduco malfunzionamenti casuali, impredicibili e gravi. Se si sbaglia a sincronizzare, il programma si blocca. Se si sincronizza troppo il programma diventa un cesso e ci mette una vita. I problemi introdotti © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Mancata sincronizzazione ◦ malfunzionamenti casuali anche gravi • Errata sincronizzazione ◦ blocco • Eccessiva sincronizzazione ◦ scarse prestazioni Programmazione di Sistema 45 Meglio non inventarsi l'acqua calda ma usare pattern noti. Meccanismi di sincronizzazione La complessità di gestione della sincronizzazione spinge verso l’uso di tecniche note ed affidabili (pattern) • È sempre necessaria molta cautela nella realizzazione di programmi multi-thread © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Programmazione di Sistema 46 Programmazione concorrente in C++11 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • A partire dalla versione C++11, il concetto di thread è diventato parte integrale dello standard del linguaggio ◦ Facilitando la scrittura di programmi portabili ◦ Introducendo un livello di astrazione più elevato che facilita il compito del programmatore, permettendogli di concentrarsi sugli aspetti specifici del proprio algoritmo invece che sui dettagli del sistema operativo • Per realizzare programmi concorrenti sono disponibili due approcci ◦ Uno di alto livello, basato su std::async e std::future ◦ Uno di basso livello che richiede l’uso esplicito di thread e costrutti di sincronizzazione • Entrambi richiedono un compilatore aggiornato ◦ VisualStudio a partire dalla versione 2012 ◦ GCC a partire dalla versione 4.7.x (con parte delle funzionalità già presenti in 4.6.y) Programmazione di Sistema 47 Primo concetto particolarmente semplice, seppur con alcune insidie. Vogliamo usare la programmazione concorrente per migliorare le prestazioni. Ci sono infatti compiti indipendenti tra loro che possono essere svolti tra due thread in autonomia, sincronizzandoli alla fine. Cercare, ad esempio, una stringa di testo in un tot. di file è un'operazione poco gravosa dal punto di vista della CPU, ma che coinvolge molto l'I/O. Posso dividere il compito tra i vari thread (ovviamente non posso nemmeno fare vagonate di thread), magari una ventina. Prima di crearne un ventunesimo, vedo se qualcuno ha finito e ne creo un altro in modo da non saturare la memoria. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Esecuzione asincrona e risultati futuri (1) • Spesso, un compito complesso può essere decomposto in una serie di compiti più semplici ◦ i cui risultati potranno essere poi combinati per fornire la funzionalità complessiva • Se i sotto-compiti sono tra loro indipendenti (la computazione di uno non dipende cioè dalla computazione di un altro), è possibile eseguirli in parallelo • La funzione std::async e la classe std::future rendono molto semplice questo tipo di decomposizione ◦ Combinando poi i risultati quando sono disponibili L'operazione si fa in modo molto semplice tramite la funzione std:: async e oggetti istanza della classe std::future. Per poterle usare dobbiamo includere <future> ◦ Entrambe sono definite nel file <future> Programmazione di Sistema LEZIONE 27 Ogni sistema operativo mette a disposizione un meccanismo nativo per creare dei thread. Non può essere altrimenti: solo il sistema operativo ha il pieno controllo dello scheduler e solo lui può modificare le strutture dati per schedulare nuovi thread. A partire dal 2011 si è deciso che la programmazione concorrente aveva assunto una tale rilevanza da includerla nella specifica base del linguaggio: significativo passo avanti nella creazione di librerie da parte degli sviluppatori, riducendo i problemi di portabilità e introducendo alcuni pattern di programmazione concorrente già belli fatti. Possiamo scegliere due approcci diversi con un compilatore C++11: 1) approccio di alto livello. Più semplice e un po' più limitato. Usiamo le astrazioni particolarmente comode e ciao ciao. Gli algoritmi sono clienti delle strutture dati. 2) approccio di basso livello, perchè magari mi serve una politica di controllo più fine. Uso Thread, Mutex e ConditionVariable. Bisogna usare un compilatore aggiornato. 48 Esecuzione asincrona e risultati futuri (2) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • La funzione std::async() prende come parametro un oggetto chiamabile (puntatore a funzione o oggetto che ha definito operator()) che ritorna un tipo generico T e restituisce un oggetto di tipo std::future<T> ◦ Quando viene eseguita, se possibile, inizia l’esecuzione dell’oggetto chiamabile in un thread separato e ritorna immediatamente, senza attenderne il completamento • Quando sarà necessario accedere al risultato prodotto dall’oggetto chiamabile, si potrà invocare il metodo get() dell’oggetto future ritornato ◦ ◦ ◦ ◦ Se l’esecuzione è terminata andando a buon fine, restituisce il risultato Se il thread secondario è terminato con un’eccezione, la lancia Se l’esecuzione è ancora in corso si blocca in attesa che finisca Se l’esecuzione non è ancora iniziata, ne forza l’avvio nel thread corrente e ne restituisce il risultato (o l’eccezione) Programmazione di Sistema 49 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Esecuzione asincrona e risultati futuri (3) #include <future> #include <string> std::string f1(std:string p1, double p2) { ... } std::string f2(int p) { ... } int main() { // si vuole calcolare f1("...", 3.14) + f2(18) //inizia il calcolo di f1 in background, se possibile std::future<std::string> future1 = std::async(f1, "...", 3.14); std::string res2= f2(18); //chiamata sincrona std::string res1 = future1.get(); // attende, se necessario, il valore std::string result = res1+res2; } Programmazione di Sistema 50 Nella versione più semplice async ha un primo parametro callable, seguito da tanti parametri quanti sono quelli del callable. Nella seconda versione, prima del callable ho una costante di enumerazione che specifica il comportamento. Di default la politica è di creare un thread secondario (e se non si può, lanciare un'eccezione), ma è possibile anche fare thread deferred (valuto il thread in maniera pigra, ovvero quando chiamo la get() o la wait() sul future relativo). © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 std::async (1) • La funzione accetta eventuali parametri da passare all'oggetto chiamabile • L'oggetto chiamabile può essere preceduto da una costante che definisce la politica di attivazione ◦ std::async(f1, "...", 3.14); ◦ std::launch::async Più precisamente, la politica di default è async. Se però non riesce la creazione, l'attività è segnata come deferred. • Attiva un thread secondario • Lancia un'eccezione di tipo system_error se il multithreading non è supportato o non ci sono risorse per creare un nuovo thread ◦ std::launch::deferred • Valutazione pigra: l'oggetto chaiamabile sarà valutato solo se e quando qualcuno chiamerà get() o wait() sul future relativo Come si è già detto, non creiamo thread a go-gò. Il programma invece di migliorare fa schifo. Ha senso usare async quando la funzione che stiamo creando è intensive (non necessariamente in termini di CPU, ma anche in termini di tempo). ◦ Se la politica viene omessa, il sistema prova dapprima ad attivare un thread secondario, se non può farlo, segna l'attività come deferred La creazione di un thread secondario ha un costo significativo Se le operazioni da eseguire sono poche (dell'ordine di un migliaio di cicli macchina) conviene eseguirle direttamente Programmazione di Sistema Pro 51 std::async (2) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Di base async è una funzione che prende un primo parametro di tipo Callable (puntatore a funzione o oggetto per il quale è definito operator(), quindi si può mettere anche una lambda expression). I successivi parametri sono i parametri che voglio che la funzione riceva. async prende tutta questa roba e restituisce un oggetto generico future, con parametro pari al tipo ritornato dalla funzione (esempio: std::future<int>). Il significato è che prima o poi qualcuno mi darà un risultato int (che appunto non ho subito): quando non posso più fare a meno di quei risultati, si invoca su future il metodo get. Se il thread secondario ha finito mi viene ritornato il valore che mi occorreva, altrimenti get si blocca e aspetta. E' chiaro che non è il massimo bloccarsi, ma verosimilmente quell'altro thread sta ancora lavorando. Se per qualche motivo la funzione ha lanciato un'eccezione, questa viene catturata. Non appena chiamo get mi viene rilanciata. Se l'esecuzione è ancora in corso, get si blocca fino a quando la funzione non è terminata. E' possibile che il thread non venga creato con async per tutta una serie di motivi: a quel punto il thread che invoca get comincia a fare quel lavoro (così da non saturare ulteriormente le risorse) ---------------------------------------------------------------------------------------------Supponiamo di voler creare un dato combinazione di due dati elementari, con f1 e f2 funzioni "complicate". Inizialmente ho due task diversi e inizialmente un solo thread. Delego a un thread secondario l'esecuzione di f1 e io mi occupo della seconda parte. Meglio così anzichè creare due thread, uno per l'overhead e due perchè tanto non avrei niente di meglio da fare. Fintantochè il valore di ritorno è passabile per copia non ho turbe. Diverso è se i task andassero a scrivere variabili globali o in qualche misura condivise: in quel caso questa cosa qui non funziona più. Quando lavoro con copie è quindi il top: non porto via risorse e non ho scritto pthread_create o cazzi vari. Nell'esempio ho un vettore di 10000 interi a 1 e ne devo fare la somma. Con un ciclo for me la cavo abbastanza facilmente, ma devo fare lo sborone e voglio strafare: dividiamoci il lavoro in due. Il thread secondario somma i primi 5000, il secondo gli altri 5000. 5000 però magari sono ancora tanti e ci si biforca ulteriormente, andando avanti fino a un limite sensato (tipo 1000). Qui vabbè, operazione discutibile. Ma tipo già se devo invertire una matrice 50x50 può avere mooolto senso separarsi il lavoro. template <typename Iter> int parallel_sum(Iter beg, Iter end) { typename Iter::difference_type len = end-beg; if(len < 1000) // pochi elementi, conviene valutazione sincrona return std::accumulate(beg, end, 0); Iter mid = beg + len/2; auto handle = std::async(std::launch::async, parallel_sum<Iter>, mid, end); int sum = parallel_sum(beg, mid); return sum + handle.get(); } int main() { std::vector<int> v(10000, 1); std::cout << "Somma:" << parallel_sum(v.begin(), v.end()) << '\n'; } Programmazione di Sistema 52 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 std::future<T> (1) • Fornisce un meccanismo per accedere in modo sicuro e ordinato al risultato di un'operazione asincrona • Mantiene internamente uno stato condiviso con il blocco di codice responsabile della produzione del risultato ◦ Il tipo T rappresenta il tipo del risultato ritornato ◦ Quando si invoca il metodo get() lo stato condiviso viene rimosso e l'oggetto future entra in uno stato invalido => get() può essere chiamato UNA SOLA VOLTA • Per forzare l'avvio del task e attenderne la terminazione, senza prelevare il risultato, si utilizza il metodo wait() ◦ Può essere chiamato più volte: se il task è già terminato, ritorna immediatamente Programmazione di Sistema 53 Meglio chiamare wait_for(...) che dice "aspetta tot. secondi" (il parametro è un oggetto di tipo duration, quindi posso andare dal nanosecondo al secolo) e dopo dimmi se ha finito o meno. Possibili ritorni: - deferred: non ha finito e non è nemmeno partito - ready: ha finito, se chiami .get ti prendi il risultato - timeout: il tempo è scaduto ma il thread non ha ancora finito std::future<T> (2) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • È possibile attendere per un po' di tempo la completazione del compito, attraverso i metodi wait_for(...) e wait_until(...) ◦ NON forzano l'eventuale avvio, se il task è stato lanciato con la modalità deferred ◦ Richiedono, rispettivamente, come parametro un oggetto di tipo std::chrono::duration oppure std::chrono::time_point ◦ Indicando una durata nulla, permettono di scoprire se il task sia o meno già terminato • ATTENZIONE. Gli oggetti future hanno un loro distruttore. Quando l'oggetto future esce di scope si vede se la computazione è ancora attiva e, nel caso, si aspetta il termine. Se l'oggetto future si sbriciolasse senza aspettare (collezionando il ritorno), ci sarebbe il rischio che la funzione che avviene in un altro thread andasse a dire in giro che è stata liberata. Restituiscono ◦ std::future_status::deferred, se la funzione non è ancora partita ◦ std:: future_status::ready, se il risultato era già pronto o lo è diventato nel tempo di attesa ◦ std:: future_status::timeout, se il tempo è scaduto, senza che il risultato sia diventato pronto ATTENZIONE!!! Quando un oggetto future viene distrutto, se la computazione è ancora attiva, il distruttore ne attende la fine Programmazione di Sistema 54 Esempio. Devo fare un calcolo complicato che posso risolvere in maniera analitica o in maniera euristica. Mi dò una scadenza (supponiamo un minuto) e lancio il computo analitico, mentre nel frattempo calcolo l'approssimazione. Quando ho un'approssimazione lecita vedo se ho ancora tempo. In caso affermativo vedo se l'altro ha finito (wait_until). Se entro il minuto riesco a tornare il risultato esatto torno quello, altrimenti torno l'approssimazione. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 std::future<T> (2) int quickComputation(); //approssima il risultato con un'euristica int accurateComputation(); // trova la soluzione esatta, // richiede abbastanza tempo std::future<int> f; // dichiarato all'esterno perché il suo ciclo di vita // potrebbe estendersi più a lungo della funzione int bestResultInTime() { // definisce il tempo disponibile auto tp = std::chrono::system_clock::now() + std::chrono::minutes(1); // inizia entrambi i procedimenti f = std::async (std::launch::async, accurateComputation); int guess = quickComputation(); // trova il risultato accurato, se disponibile in tempo std::future_status s = f.wait_until(tp); // ritorna il miglior risultato disponibile if (s == std::future_status::ready) { return f.get(); } else { return guess; // accurateComputation() continua... } } Programmazione di Sistema Affinchè il tutto possa funzionare è necessario che il future non sia dentro la funzione (infatti è globale). Se lo dichiarassi locale alla funzione succederebbe che la funzione parte, guarda l'ora e setta l'intervallo di un minuto. Se status ritorna ready è arrivata la soluzione giusta, altrimenti la stima. Se f fosse locale, return guess comporterebbe la distruzione di f (e quindi si aspetta il calcolo analitico). Occhio però dall'altro lato: siccome f è globale, se tutti chiamano la stessa funzione scrivendo sullo stesso oggetto f succedono disastri. 55 In alcune situazioni abbiamo bisogno di conoscere da più parti il risultato di un'elaborazione asincrona. Per questo abbiamo a disposizione shared_future, che è un future che è accessibile da più client contemporaneamente (= permette a tanti di invocare get). std::shared_future<T> (1) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 L'oggetto future mette a disposizione una "busta" per sapere quando l'altro ha finito. Dentro di sè ha una promise: prima o poi il thread mi darà un risultato, e se il dato non è pronto aspetto senza consumare risorse. Occhio che get può essere chiamato una sola volta. A volte vogliamo solo sapere se un thread ha finito, in modo da prendere una decisione di qualche tipo (es.: ho già fatto 20 thread, voglio sapere se uno dei tanti ha finito per prendere una decisione. Posso usare la wait() per fare ciò, ma wait() è bloccante) • Se in più posti occorre poter valutare se un'operazione asincrona sia terminata e quale risultato abbia prodotto, la classe future<T> mette a disposizione il metodo share() Per ottenerlo basta invocare share() su un future. A questo punto il future in quanto tale non va più usato. Lo shared_future è copiabile oltre che movibile (a differenza del future che è solo movibile ma non copiabile). Offre gli stessi metodi di future. Dal punto di vista funzionale restituirà gli stessi valori (o risultato o eccezione). ◦ Restituisce un oggetto di tipo std::shared_future<T> e invalida lo stato dell'oggetto corrente (che non può più essere usato) • std::shared_future<T> è copiabile oltre che movibile • A parte share(), offre gli stessi metodi di future<T> ◦ A differenza di std::future<T> che può solo essere mosso ◦ Se get() viene chiamato più volte, produce sempre lo stesso risultato (a parte l'attesa) Programmazione di Sistema 56 Perchè viene utile? Perchè ogni tanto devo fare una catena di task, che è un flusso di elaborazione complicato. Gli unici punti di contatto tra i vari blocchi sono i dati che passiamo esplicitamente (quelli di Task1 nell'esempio). Questa situazione non la posso gestire con un future semplice perchè tutti e due hanno bisogno del risultato di Task1 (e con un future normale ce l'avrebbe solo il primo che chiama get). Inoltre 2 e 3 magari non hanno bisogno subito di Task1, quindi nel frattempo fanno quello che devono fare e poi prendono i dati. std::shared_future<T> (2) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Questa classe si presta a realizzare catene di elaborazione asincrone dove i risultati delle fasi iniziali sono messi a disposizione delle fasi successive ◦ senza ridurre – a priori – il grado di parallelismo Task2 task1 Task1 task2 Task3 get() task3 get() t Programmazione di Sistema 57 std::shared_future<t> (3) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 int task1(); // prima fase del calcolo std::string task2(std::shared_future<int1>); // seconda fase del calcolo double task3(std::shared_future<int>); // terza fase int main() { std::shared_future<int> f1 = std::async(std::launch:async, task1).share(); std::future<std::string> f2= std::async(task2, f1); std::future<double> f3 = std::async(task3, f1); try { std::string str = f2.get(); double d= f3.get(); } catch (std::exception& e) { //gestisci eventuali eccezioni } 58 E' facile creare concorrenza ad alto livello. Dobbiamo solo avere l'accortezza di pensare che queste funzioni non utilizzino dati globali, ma vedono solo i propri parametri di ingresso e i risultati che producono (che devono essere copiabili e magari anche copiati). Gestire la concorrenza a basso livello © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Si noti che prima di chiamare get() su 2 e 3 entro in un blocco try, perchè potrebbero fallire o perchè magari 1 è fallito. } Programmazione di Sistema • async() e future<T> permettono di creare facilmente composizioni di attività ad alto livello ◦ A patto che i diversi compiti in cui l'algoritmo può essere suddiviso siano sufficientemente poco interconnessi • In altri casi, occorre fare accesso alle funzionalità di basso livello Tutte le volte in cui la concorrenza che vogliamo realizzare non ricade in questi casi dobbiamo sporcarci le mani. C++ 11 mette a disposizione una serie di classi. In particolare la classe Thread che ci permette di manipolare un thread e le classi Mutex e ConditionVariable che ci permettono di definire politiche di attesa (mutex: attendo che non stia succedendo qualcosa, conditionvariable: attendo che succeda qualcosa) ◦ Gestendo esplicitamente la creazione dei thread, la sincronizzazione delle attività, l'accesso a zone di memoria condivise, l'uso delle risorse Di per sè creare un thread è facile: si crea un'istanza della classe Thread passando nel costruttore il riferimento a una funzione o ad un oggetto Callable. Internamente la libreria si occupa di invocare Alter ego del thread correttamente la libreria sottostante (Windows: chiamo beginthreadLa classe std::thread del sistema operativo ex). Nell'esempio la funzione f non ha parametri e torna void (e così dev' essere). Ci può però essere bisogno di comunicare parametri: in • Modella un oggetto che rappresenta un thread del quel caso non si usa la funzione, ma si prepara un object con una sistema operativo serie di parametri e c'è un operator(), che può vedere i parametri dell'oggetto. ◦ Se, come parametro del costruttore, riceve un oggetto di Programmazione di Sistema © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Questo è l'esempio di prima tradotto in codice. 1) Comincio a far partire task1: politica di lancio async (forza il thread a partire) e col future che ti viene ritornato prendine lo share() in modo da avere uno shared_future. 2) Gli shared_future sono usati come parametro in task2 e task3, che vengono lanciati in async. 3) Mi metto in attesa dell'ultimo della catena (in questo caso 2 e 3). 59 tipo Callable (un puntatore a funzione o un oggetto che implementa operator()), crea un nuovo thread all’interno del sistema operativo e ne inizia subito l’esecuzione #include <thread> void f() { std::cout<<"Thread is running"<<std::endl; } int main() { std::thread t(f); //inizia l’esecuzione di t ... //altre operazioni nel thread principale t.join(); // Si blocca fino a che t termina } Programmazione azione azio ne di di Sistema Sistem Sistem stema a 60 Il thread è mandato subito in esecuzione. L'oggetto thread dispone del metodo join: "bloccati fino a che il thread nel sistema operativo non ha finito di fare il suo mestiere". OCCHIO! Non bisogna confondere std::thread con un thread: dire che abbiamo un oggetto thread dice dunque ben poco, questo perchè l'oggetto thread può anche morire, ma il thread nel SO continua ad esserci. L'oggetto thread ci offre alcuni metodi per avere notizie sul thread nel SO: uno tra questi è proprio join (che in Win32 è l'equivalente di waitForSingleObject) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Specificare il compito attraverso un funtore • Se al costruttore di thread si passa un oggetto che implementa operator(), il thread eseguirà la funzione lì specificata ◦ L’oggetto viene passato per VALORE e quindi copiato all’interno dell’oggetto thread ◦ Se, durante l’esecuzione del funtore, il suo stato cambia, l’originale rimane immutato • Se ho bisogno di creare una comunicazione col mio thread posso passare una lambda function a cui posso passare parametri catturati per reference, che saranno shared con il thread. Questo permette di creare un canale di comunicazione, ma c'è il rischio molto grande di pestarsi i piedi a vicenda. Servono dunque meccanismi per evitare impicci. L'uso dei reference ha quindi tante turbe, ma attenzione: bisogna avere ben chiaro il ciclo di vita. Se passiamo un reference a una variabile locale che viene distrutta, l'altro ne mantiene l'indirizzo. Spesso è comodo utilizzare una funzione lambda come parametro della classe thread ◦ Eventuali parametri catturati come reference potranno essere modificati dal thread creato e resi visibili al codice del thread creante ◦ Questo può creare problemi di sincronizzazione (come spiegato in seguito) e di accesso alla memoria (se, nel frattempo, i parametri escono dal proprio scope) Programmazione di Sistema 61 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Diffrenze tra std::thread e std::async (1) • Non c'è scelta sulla politica di attivazione ◦ Creando un oggetto di tipo thread con un parametro chiamabile, la libreria cerca di creare un thread nativo del sistema operativo ◦ Se non ci sono le risorse necessarie, la creazione dell'oggetto std::thread fallisce lanciando un'eccezione di tipo std::system_error • Non c'è un meccanismo standard per accedere al risultato della computazione ◦ L'unica informazione che viene associata al thread è un identificativo univoco, accessibile tramite il metodo get_id() • Se durante la computazione del thread si verifica un'eccezione non ricuperata da un blocco catch, l'intero programma termina 62 Differenze tra std::thread e std::async (2) • © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Che differenza c'è tra creare un thread e invocare async? - Con async possiamo scegliere la politica di invocazione, con thread no. Se il sistema operativo non ha più risorse ci si becca un'eccezione - async ci dà automaticamente un modo per leggere il risultato della computazione. Con thread è un bordello. L'unica cosa che sappiamo facilmente è la fine del thread, per il resto dobbiamo pianificare tutto noi. - Se durante la computazione della funzione passata al thread si verifica un'eccezione che non abbiamo catchato, il SO se ne accorge e killa il programma. Da questo punto di vista async è molto meglio perchè l'eccezione ci viene restituita quando chiamiamo get ◦ Viene chiamato il metodo std::terminate() Programmazione di Sistema Quando viene creato un oggetto std::thread occorre alternativamente ◦ Attendere la terminazione della computazione parallela, invocando il metodo join() ◦ Informare l'ambiente di esecuzione che non si è interessati all'esito della sua computazione, invocando il metodo detach() ◦ Trasferire le informazioni contenute nell'oggetto thread in un altro oggetto, tramite movimento • Se nessuna di queste azioni avviene, e si distrugge l'oggetto thread, l'intero programma termina • Se il thread principale di un programma termina, tutti i thread secondari ancora esistenti terminano di colpo, senza possibilità di effettuare nessuna forma di salvataggio - Tutte le volte che creiamo thread ci prendiamo un impegno: che cosa ce ne vogliamo fare? Attendere che finisca (join) o fottersene altamente (detach)? Se non facciamo nessuna delle due e viene distrutto l'oggetto thread, l'intero programma termina. - Potrei creare una serie di thread e detaccharli (slego il rapporto tra il thread vero e proprio noto allo schedulatore e la sua rappresentazione in C++). Attenzione. Per come è fatta la funzione che chiama main, se abbiamo creato dei thread secondari detacchati e poi il main termina, questi thread vengono terminati (viene chiamato exit_process). ◦ Sempre invocando std::terminate() Programmazione di Sistema 63 Restituire un risultato (1) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Di per sè nel costruttore di thread possiamo passare: - il nome di una funzione che non ha parametri e torna void - un oggetto C++ che dentro di sè ha definito operator() con una signature del tipo nessun parametro in ingresso, void in uscita E' importante che mi renda conto che l'oggetto venga passato per valore (dunque ne è fatta una copia). Se l'oggetto passato al thread cambia di stato, l'oggetto originale è immutato. • Se un thread durante il proprio svolgimento calcola un risultato, come fa a metterlo a disposizione degli altri thread? • Questo introduce un secondo problema: come fanno gli altri thread a sapere che il contenuto della variabile condivisa è stato aggiornato? ◦ La soluzione richiede appoggiarsi ad una variabile condivisa ◦ La soluzione banale di introdurre un'ulteriore variabile (booleana) che indica la validità del dato non è una soluzione ◦ Diventerebbe infatti un'ulteriore variabile condivisa che avrebbe bisogno di un indicatore di validità... ◦ Problema del riordinamento Programmazione di Sistema 64 Per beccare il risultato di un thread ci sono diverse possibilità: - variabile condivisa: mi trovo però in una situazione di disagio, perchè non so quando il risultato è pronto. Mi serve anche una politica di accesso alla variabile: una buona idea è quella di guardare solo se sono sicuro che il thread è morto (t.join()). E' limitante. Se avessi bisogno che il thread comunicasse pian piano, questa strategia non è fattibile. - variabile condivisa: come faccio a sapere che la variabile condivisa ha in un dato istante t un valore valido? Il polling togliamocelo dalla testa perchè consuma tantissime risorse. Creare un flag sposta solo il problema. Occorre basarsi su un costrutto messo a disposizione dal kernel che si fa garante che quella cosa può funzionare. In C++ usiamo un promise, ovvero un impegno che un thread si prende nel renderci disponibile prima o poi un dato di tipo T. Lavorando con un promise quello che succede è che io preparo il promise, dico al thread di metterci le sue cose e nel thread principale ricavo il future, aspettando che dentro ci sia qualcosa di buono. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Restituire un risultato (2) • Per evitare problemi, il modo più semplice di restituire un valore calcolato all'interno di un thread è basato sull'utilizzo di un oggetto di tipo std::promise<T> ◦ Rappresenta l'impegno, da parte del thread, a produrre, prima o poi, un oggetto di tipo T da mettere a disposizione di chi lo vorrà utilizzare... ◦ ...oppure di notificare un'eventuale eccezione che abbia impedito il calcolo dell'oggetto • Dato un oggetto promise, si può conoscere quando la promessa si avvera richiedendo l'oggetto std::future<T> corrispondente ◦ Attraverso il metodo get_future() Programmazione di Sistema 65 Esempio di quanto abbiamo detto. Il thread secondario deve darmi una stringa: gli passo un promise PER REFERENCE (perchè altrimenti i due promise sono slegati). Per passarlo per reference devo usare un'apposita funzione. A un certo punto sfanculo il thread, ma quando ne ho bisogno vado sul promise, mi faccio dare il future e invoco get. Solo quando il thread avrà depositato un dato nel promise, get verrà soddisfatta. Restituire un risultato (3) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 #include <future> void f(std::promise<std::string>& p) { try { //lungo calcolo per determinare il valore da restituire... std::string result = ...; p.set_value(std::move(result)); } catch (...) { p.set_exception(std::current_exception()); } } int main() { std::promise<std::string> p; //creo un thread responsabile dell'elaborazione std::thread t(f,std::ref(p)); //forza p ad essere passata per reference t.detach(); //faccio altro... //quando ho bisogno del risultato std::string result = p.get_future().get(); } Programmazione di Sistema 66 Prima di entrare nel dettaglio della sincronizzazione guardiamo meglio cosa succede se creiamo un oggetto thread e invochiamo detach? Beh, siamo liberi di distruggerlo anche se è ancora in funzione. Vantaggio ma anche fregatura. Perdiamo la coerenza tra SO e oggetto che rappresenta il thread, ma in alcuni casi va benissimo (es.: vogliamo costruire un server di rete. Il processo dunque in un thread secondario apre un socket e aspetta qualcuno che può arrivare presto, tardi o mai. Alla peggio il thread muore. Chissenefrega.) Thread distaccati © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Se si crea un oggetto thread e si invoca il metodo detach(), l'oggetto thread si "stacca" dal flusso di elaborazione corrispondente... ◦ ... e può continuare la propria esecuzione senza, però, offrire più nessun meccanismo specifico per sapere quando termini • Se il thread distaccato fa accesso a variabili globali o statiche, queste potrebbero essere distrutte (perché sta terminando il thread principale) mentre la computazione è ancora in corso ◦ Se il thread principale termina normalmente (main() ritorna), l'intero processo viene terminato, con tutti i thread distaccati eventualmente presenti ◦ Se la terminazione del thread principale avviene per altre cause (si invoca std::exit(...)) questo può portare a errori sulla memoria: la funzione std::quick_exit(...) permette di far terminare un programma senza invocare i distruttori della variabili globali e statiche (la qual cosa può essere un rimedio peggiore del male) Programmazione di Sistema 67 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Se si utilizza un oggetto di tipo promise per restituire un valore, si introduce un livello di sincronizzazione • Potrebbe avere ancora del codice da eseguire (ad esempio, tutti i distruttori degli oggetti finora utilizzati) Per evitare potenziali corse critiche tra la pubblicazione di un risultato nell'oggetto promise e la continuazione degli altri thread in attesa dello stesso, la classe promise offre due metodi ulteriori ◦ set_value_at_thread_exit(T val); ◦ set_exception_at_thread_exit(std::exception_ptr p); Programmazione di Sistema E' dunque bene avere SEMPRE il controllo delle cose: devo garantire che il thread principale sia l'ultimo a morire, aspettando che tutti gli altri abbiano finito con successo. Sarebbe bene usare set_value_at_thread_exit, in modo che prima di morire il thread segni il valore. In questo modo il thread principale ottiene il valore solo in punto di morte del thread. C'è un altro metodo che va utilizzato quando non si può calcolare il risultato a seguito di un'eccezione. ◦ Il thread principale, invocando wait() o get() sul future corrispondente resta bloccato fino a che il thread detached non ha assegnato un valore ◦ Questo, però non significa che il thread detached sia terminato • Il thread principale potrebbe morire non perchè ritorna, ma perchè c'è stata un'invocazione esplicita di exit_thread (o della corrispondente sotto Linux). Questo fa in modo che tutte le variabili globali non vengano distrutte, il che può portare all'incoerenza del sistema. Idem usando le cose della libreria standard. Di fatto, quando creiamo una promise creiamo anche un meccanismo implicito di sincronizzazione. Il thread secondario che salva il valore nella promise rende disponibile il dato a chi conosce la promise solo nel momento in cui questo è veramente disponibile. Thread distaccati (2) • LEZIONE 28 68 Quando viene creato un oggetto di tipo thread, il SO ha l'esigenza di distinguerlo. Questa esigenza è globale, perchè il thread potrebbe appartenere a processi diversi. Per questo motivo l'SO assegna un numero univoco a ogni thread, il • Lo spazio dei nomi std::this_thread offre un insieme cosiddetto TID. Quando creiamo un oggetto thread questo ha un TID associato, che possiamo conoscere col metodo get_id(). Ci può serdi funzioni che permettono di interagire con il thread che sta attualmente governando l'esecuzione vire perchè magari abbiamo bisogno di alcune strane forme di thread del programma intercommunication. ◦ La funzione std::this_thread::get_id() restituisce L'ID è univoco ma non nell'eternità: nel tempo, se il SO resta acceso l'identificativo del thread corrente ◦ La funzione std::this_thread::sleep_for(duration) sospende a lungo, dopo un po' si arriva a 4 miliardi e si riparte da 1. Potrebbel'esecuzione del thread corrente per almeno il tempo ro esistere malaugurate situazioni in cui conosco un thread col nome indicato come parametro vecchio, ma sono molto remote. ◦ La funzione std::this_thread::sleep_until(time_point) Oltre a get_id() abbiamo altri due metodi statici. interrompe l'esecuzione almeno fino al momento indicato - sleep_for: per un periodo di tempo pari all'unità di misura indicata ◦ La funzione yield() offre al sistema operativo la possibilità di rischedulare l'esecuzione dei thread non fare niente (= togli il thread dalla lista degli eseguibili per il temSe ho bisogno di intervenire in tempistiche certe po indicato) devo avere uno schedulatore che garantisce predicibilità - sleep_until: interrompo l'esecuzione almeno fino al momento indicadi Sistema (soloProgrammazione su macchine real-time). Questo solo per processi 69 to fisici, altrimenti sono tollerabili discrepanze. - yield: dì allo schedulatore di rimettermi al fondo della coda. Fatto perchè ci rendiamo conto che abbiamo a che fare con codice CPU Sincronizzazione intensive e lascio entrare altri. ---------------------------------------------------------------------------------------------Il vero problema nel lavorare coi thread è dire: se voglio che due • Per consentire, in sicurezza, l'accesso condiviso ad un dato, C++11 thread che stanno facendo cose insieme si parlino mentre stanno laintroduce gli oggetti di classe std::mutex ◦ Permettono l'accesso controllato a porzioni di codice ad un solo thread alla vorando, devo creare un meccanismo di sincronizzazione. volta Ne esistono due che rispondono a due bisogni differenti: ◦ Mutex: contrazione di Mutual Exclusion ◦ Definito nel file <mutex> - voglio fare una cosa mentre non la sta facendo nessun altro (ho bi• Tutto il codice che fa accesso ad una data informazione condivisa sogno di un mutex). Equivale a chiudersi in bagno mentre si sta cadeve fare riferimento ad uno stesso oggetto mutex gando. Non si protegge la variabile in quanto tale, ma solo il codice ◦ E racchiudere le operazioni tra le chiamate ai metodi lock() e unlock() ◦ Entrambe includono una barriera di memoria che garantisce la visibilità delle che modifica la variabile (quindi con getter e setter). I metodi più operazioni fino a quel punto eseguite importanti sono lock() e unlock(). • Se un thread invoca il metodo lock() di un mutex mentre questo è © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Conoscere la propria identità posseduto da un altro thread, il primo thread si blocca in attesa che l'altro chiami unlock() ◦ Occorre fare in modo che, se si è preso possesso di un mutex tramite lock(), prima o poi lo si rilasci con unlock() Programmazione di Sistema 70 std::mutex (1) std::list<int> l; std::mutex m; m std::mutex() © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 void add(int i){ m.lock(); l std::list<int>() lock() l.push_back(i); m.unlock(); push_back() } lock() unlock() Se due thread in contemporanea cercando entrambi di aggiungere uno dei due arriva alla lock, ci riesce e chiama push_back. L'altro chiama lock() ma a quel punto la chiamata viene bloccata. A un certo punto quando il primo chiama unlock, la lock di prima termina e la "chiave" passa dal thread verde al thread blu. push_back() Programmazione di Sistema Di base mutex non è legato a una variabile particolare: l'associazione è solo nella testa del programmatore. Ciò spalanca le porte alla scrittura di codice potenzialmente molto brutto. Tutte le volte che faccio un accesso dati alla struttura che voglio proteggere: 1) acquisisco il lock 2) faccio quello che devo fare 3) rilascio il lock. Vale sia per la lettura per la scrittura. Un mutex può proteggere anche 3 liste, ma ovviamente ho meno parallelismo. Di per sè non è sbagliato eh. I mutex non sono ricorsivi: se ci siamo chiusi dentro e andiamo da qualche altra parte, ritornando dov'eravamo prima andiamo in deadlock. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 std::mutex (2) • Non c'è corrispondenza diretta tra l'oggetto mutex e la struttura dati che lo contiene • Tutti gli accessi alla struttura vanno protetti acquisendo dapprima il mutex • Un oggetto mutex può proteggere molte strutture diverse • Un mutex non è ricorsivo ◦ La relazione è nella testa del programmatore ◦ Anche le operazioni in sola lettura ◦ Ma riduce il grado di parallelismo complessivo del programma ◦ Se un thread cerca di acquisirlo (tramite lock()) due volte, senza rilasciarlo, il thread si blocca per sempre • OGNI VOLTA CHE SI ACQUISISCE UN MUTEX BISOGNA RILASCIARLO, ANCHE IN CASO DI ECCEZIONE. Ogni volta che si acquisisce un mutex, occorre rilasciarlo ◦ Anche se si verifica un'eccezione Programmazione di Sistema Se voglio gestire variabili con un mutex faccio un oggetto, con la variabile private, un mutex private e due metodi getter e setter (che fanno .lock() e unlock() in momenti ben determinati). Se io chiamo .lock() mentre qualche altro thread è possessore del blocco, il mio thread si blocca senza consumare CPU (il thread è non schedulabile fin quando non è fatto .unlock(), e a quel punto si prende un thread a caso). Se chiamiamo lock abbiamo la responsabilità di chiamare unlock. Occhio che se lancio un'eccezione non ci arrivo alla unlock: sta andando a fuoco tutto ma io sono uscito e la porta è sbarrata. ---------------------------------------------------------------------------------------------Nell'esempio abbiamo due thread che stanno lavorando su una lista. Se tutti e due facessero push senza protezione, le due chiamate avverrebbero in momenti qualsiasi. Aggiungi al fatto che gli oggetti della standard library sono unsafe multithread #boom. Nell'oggetto dunque metto un paio di variabili private: la lista che voglio proteggere e il mutex. Poi creerò un metodo pubblico add(int i) che prima di tutto prende il lock, aggiunge e poi rilascia il lock. 72 La reazione a questa regola potrebbe essere: "Ah, tutte le volte che acquisisco un mutex ci butto un bel try/catch". Bello, molto bello, sei bravo. Però per me è no. In C++ si segue un paradigma diverso: RAII. L'idea è che invece di accedere direttamente all'oggetto mutex, esso venga annegato in un oggetto temporaneo che chiamiamo lock. Questo oggetto temporaneo nel costruttore fa la lock e nel distruttore la unlock. Cosa succede? std::lock_guard<Lockable> (1) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Per semplificare il codice e garantire che un mutex sia sempre rilasciato, viene messa a disposizione la classe generica std::lock_guard<Lockable> ◦ Utilizza il paradigma RAII • Il costruttore invoca il metodo lock() dell'oggetto passato come parametro ◦ Il distruttore invoca unlock() • Non offre nessun altro metodo Programmazione di Sistema 73 Vediamo l'esempio. Creo un oggetto di tipo lock_guard (nello stack, altrimenti non ha senso) e immediatamente il costruttore invoca lock. Se per caso il costruttore è in uso a qualcun altro, non creo lock_guard finchè il mutex non si libera. Se ho un lock_guard il mutex è mio. A quel punto faccio quello che voglio. Quando arrivo alla chiusa graffa, tutti gli oggetti locali vengono distrutti. Tra loro il lock_guard, che nel distruttore chiama la unlock del mutex. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 std::lock_guard<Lockable> (2) template <class T> class shared_list { std::list<T> list; std::mutex m; T& operator=(const shared_list<T>& that); shared_list(const shared_list<T>& that); public: Questo per le eccezioni è top: se list.push_front(t) fallisce per qualche motivo si contrae lo stack e dunque l'oggetto lock_guard verrebbe distrutto. L'eccezione rimane, ma il mutex è rilasciato. int size() { std::lock_guard<std::mutex> l(m); return list.size(); } T front() { std::lock_guard<std::mutex> l(m); return list.front(); } void push_front(T t) { std::lock_guard<std::mutex> l(m); list.push_front(t); } Importante che sia locale. Per poterli costruire devo passare un mutex. E poi boh, una volta costruiti ce li dimentichiamo: fintanto che lock_guard esiste, il mutex è nostro. //ecc. }; Programmazione di Sistema 74 Potrebbero esserci delle situazioni in cui noi vogliamo sapere se il mutex è prendibile o meno (in modo da fare qualcos'altro se proprio non posso prenderlo). Per questo motivo la classe mutex, oltre al metodo lock, offre il metodo try_lock. Mentre lock torna void, try_lock torna un booleano che mi dice se il mutex è mio oppure no. Blocco condizionale (1) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • In alcune situazioni, un programma non intende bloccarsi se non può acquisire un mutex ◦ Ma fare altro nell'attesa che si liberi • La classe mutex mette a disposizione il metodo try_lock() ◦ Restituisce un valore booleano per indicare se è stato possibile acquisirlo o meno • Se l'acquisizione ha avuto successo, il mutex può essere "adottato" da un oggetto lock_guard ◦ Così da garantirne il rilascio al termine dell'utilizzo Programmazione di Sistema 75 E qui segue un esempio. Ho un mutex e dei dati condivisi. Se in una qualche funzione ho bisogno sia di accedere a dati condivisi che fare altre cose: 1) Chiamo try_lock sul mutex. Se mi ritorna false faccio qualcos'altro. Da lì a un po' riprovo. 2) Nel momento in cui try_lock ritorna true, il while esce e il mutex è mio. Creo un lock_guard l sul mutex. adopt_lock: "non acquisire il lock, verifica solo che sia già tuo". Blocco condizionale (2) #include <mutex> © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Potremmo dunque dire che l'oggetto lock_guard non lo costruiamo subito, ma solo se try_lock ha restituito true. Questo perchè vogliamo metterci nella situazione che se posso prendermi il mutex lo prendo e lo sbatto in un lock_guard. std::mutex m; ... void someFunction() { while (m.try_lock() == false) { do_some_work(); } Quando l verrà distrutto, il mutex verrà rilasciato ed altri potranno entrare. std::lock_guard<std::mutex> l(m, std::adopt_lock); // l registra m al proprio interno, senza cercare di acquisirlo ... //quando l viene distrutto, rilascia il possesso del mutex } Programmazione di Sistema 76 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Regolare l'attesa • Altre classi specializzano il comportamento di std::mutex ◦ std::recursive_mutex può essere acquisito più volte consecutivamente dallo stesso thread • Dovrà essere rilasciato altrettante volte, prima di permettere ad un altro thread di prenderne il possesso ◦ std::timed_mutex aggiunge i metodi try_lock_for() e try_lock_until(), per porre un limite al tempo di attesa massimo • Se il tempo indicato scade senza che sia possibile acquisire il lock, restituiscono false, altrimenti ritornano true ◦ std:recursive_timed_mutex unisce i due comportamenti • Il lock può essere acquisito più volte da parte dello stesso thread ed è possibili limitare il tempo massimo di attesa Programmazione di Sistema 77 lock_guard è una specie di "vestito". Non esiste da solo, ma applicato a un mutex (o a un oggetto lockable). Così come lock_guard è una sorta di vestito che wrappa il mutex, esiste un suo simile che si chiama unique_lock. Come lock_guard permette di garantire certe caratteristiche e in più ho la possibilità controllata di liberare temporaneamente un lock sapendo che poi lo riacquisiremo. std::unique_lock<Lockable> © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Estende il comportamento di lock_guard, permettendo al programmatore di rilasciare e riacquisire l'oggetto Lockable durante la propria esistenza in vita ◦ Tramite i metodi lock() e unlock() ◦ Entrambi lanciano un'eccezione di tipo std::system_error se si cerca di rilasciare qualcosa che non si possiede o viceversa • Oltre a mutex, che è l'effettivo meccanismo che fa entrare uno alla volta e oltre a lock_guard che è un "vestito" che cuciamo su un mutex, la libreria standard ci mette a disposizione altri strumenti. - recursive_mutex: variazione di mutex. Se un thread già possiede un mutex e cerca di invocare lock() sullo stesso mutex, non si bloc. ca ma prosegue. Il mutex però si segna che il thread lo sta possedendo due volte, quindi il thread deve chiamare unlock() due volte affinchè altri thread lo possano prendere in carico. - timed_mutex: oltre a lock() e try_lock() ci mette a disposizione delle versioni try_lock_for() ("attendi questa quantità di tempo") e try_lock_until() ("attendi fino a..."). Se nel tempo indicato riesci a darmi il mutex ritorna true, altrimenti false. In questo modo posso fare attese con timeout. Utile quando devo aspettare qualcuno, ma solo per un tempo ragionevole. - recursive_timed_mutex: mette insieme il conteggio degli ingressi all'attesa limitata nel tempo Il costruttore offre numerose politiche di gestione selezionate in base al secondo parametro ◦ adopt_lock verifica che il thread possieda già il Locakble passato come parametro e lo adotta ◦ defer_lock si limita a registrare il riferimento al Lockable, senza cercare di acquisirlo ◦ try_lock prova ad acquisire il Lockable, se disponibile ◦ Un oggetto di tipo std::chrono::duration richiede si provi ad acquisire il Lockable per un tempo massimo indicato ◦ Un oggetto di tipo std::chrono::timepoint richiede di provare ad acquisire il Lockable entro la data fissata Fondamentalmente, uno unique_lock cerca di acquisire il mutex e sta bloccato fin quando il mutex non diventa nostro. Se non facciamo niente di particolare, distruggendo unique_lock il mutex viene rilasciato. Temporaneamente posso far entrare "intrusi". Questo è fattibile perchè unique_lock ha anche due metodi espliciti lock() e unlock() (cedo temporaneamente con unlock e me lo riprendo con lock). Occhio a chiamare questi metodi nei monenti giusti. Mentre lock_guard se è costruito è nostro, unique_lock è Così come lock_guard dà la possibilità di specificare alcune politipiù sofisticato: è legato a un mutex, ma la relazione può che di gestione, così è fattibile con unique_lock. Veditele. Programmazione di Sistema essere più sofisticata. 78 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Lazy evaluation (1) • Ci sono varie situazioni che rendono utile rimandare la creazione ed inizializzazione di strutture complesse all'interno di un programma fino a quando non c'è la certezza del loro utilizzo • In un programma sequenziale, ci si riferisce tipicamente alla struttura in questione attraverso un puntatore inizializzato a NULL ◦ Questa tecnica prende il nome generico di lazy evaluation ◦ Quando occorre accedere alla struttura, si controlla se il puntatore abbia già o meno un valore lecito ◦ Nel caso in cui valga ancora NULL, si crea la struttura e se ne memorizza l'indirizzo all'interno del puntatore • In un programma concorrente questa tecnica non può essere adottata direttamente ◦ Il puntatore è una risorsa condivisa e il suo accesso deve essere protetto da un mutex • Per supportare questo tipo di comportamento, C++11 offre la classe std::once_flag e la funzione std::call_once(...) Programmazione di Sistema 79 Lazy evaluation (2) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • std::once_flag costituisce la struttura di appoggio per la funzione call_once ◦ Registra, in modo thread safe, se è già avvenuta o sia in corso una chiamata a call_once • std::call_once(flag,f,...) esegue la funzione f una sola volta ◦ Se dal flag non risultano chiamate, inizia l'invocazione di f ◦ Se un'altra chiamata è in corso, blocca l'esecuzione in attesa del suo risultato ◦ Se una chiamata è già terminata con successo, ritorna immediatamente #include <mutex> class Singleton { static Singleton *instance; static std::once_flag inited; Singleton() {...} //privato public: static Singleton *getInstance() { std::call_once( inited, []() { instance=new Singleton(); }); return instance; } //altri metodi... }; Programmazione di Sistema 80 Queste cose sono interessanti perchè nella programmazione, spesso e volentieri, è conveniente calcolare informazioni solo se qualcuno ce le chiede. Questa tecnica è nota sotto il nome di lazy evaluation. In un programma standard sequenziale la cosa è molto semplice. Ho un puntatore a NULL e quando qualcuno ha bisogno del dato, controllo cosa c'è dentro: - se il puntatore non è NULL, il dato è pronto - se il puntatore è NULL, faccio partire il calcolo, salvo il risultato e ritorno il valore. In modalità concorrente è un bordello. Io ho un thread che ha bisogno di sapere quanto spazio libero c'è sul disco, il che richiede spazzolarsi il disco. Di base non lo so = NULL. La prima volta che qualcuno me lo chiede faccio il calcolo: quando ho il risultato salvo e metto dentro. Nel frattempo un altro thread lo chiede: ehhh ciao, tutti contano e non combiniamo un cazzo. Questo perchè quel puntatore diventa una risorsa condivisa, che dunque va protetta con un mutex. Si può fare ma è un po' macchinoso. Il C++ offre dunque una soluzione semplificata con la classe once_flag, inizializzata una volta sola: il primo che accede determina computazione, gli altri aspettano --------------------------------------------------------------------------------------------Come si fa? Così. Ho un puntatore che inizialmente vale null e un oggetto di tipo once_flag che vale true (= ha già ricevuto un valore) o false (= non è ancora valido). Mh. Accedo al puntatore tramite un metodo pubblico (getInstance) e sfrutto call_once: se è già inizializzato bene, altrimenti in maniera atomica inizializza il tutto e ritornamelo. Nel frattempo se qualcun altro chiama getInstance viene bloccato. LEZIONE 29 (35:45 > ...) Il caso di mutex è metà del problema. Quando due thread hanno bisogno contemporaneamente di fare accesso alla stessa variabile devono garantire che uno non faccia mentre sta facendo l'altro. Altre volte c'è bisogno di una condizione differente: "io non faccio finchè tu non hai fatto". © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Gestire il risveglio (1) • Spesso capita che un thread debba aspettare uno o più risultati intermedi prodotti da un altro thread ◦ Per motivi di efficienza, l'attesa non deve consumare risorse e deve terminare non appena un dato è disponibile • Mentre la mutua esclusione dice "basta che tu non stai facendo e io posso fare" qui dico "voglio fare dopo che tu hai finito un qualche passo intermedio". Quel thread ci può mettere tanto o poco a calcolare quel risultato intermedio. Naturalmente vogliamo evitare il polling. La coppia di classi promise/future offrono una soluzione limitata del problema ◦ Valida quando occorre notificare la disponibilità di un solo dato • La presenza di dati condivisi richiede come minimo l'utilizzo di un mutex ◦ Per garantire l'assenza di interferenze tra i due thread che devono fare accesso ai dati Programmazione di Sistema 81 Questa è la soluzione basata su polling. Ho una variabile booleana e un mutex. Acquisisco il mutex, guardo se la variabile è libera o meno. Se il dato è pronto lo prendo e ci faccio delle cose e poi rilascio il mutex. In caso contrario rilascio il mutex, dormo un po' e riprendo il mutex. Viene bene usare uno unique_lock. Introduco latenza. Gestire il risveglio (2) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 bool ready; std::mutex readyFlagMutex; • // cicla fino a che ready vale true { std::unique_lock<std::mutex> ul(readyFlagMutex); Una soluzione basata sul polling ha due limiti ◦ Consuma capacità di calcolo e batteria in cicli inutili ◦ Introduce una latenza tra il momento in cui il dato è disponibile e il momento in cui il secondo thread si sblocca while (!ready) { //rilascio il lock per permettere //all'altro thread di prenderlo ul.unlock(); std::this_thread::sleep_for( std::chrono:: milliseconds(100)); ul.lock(); } // uso la risorsa } // rilascia il lock Programmazione di Sistema 82 Per questo motivo, oltre a mutex viene introdotto il costrutto di sincronizzazione condition_variable che permette di dire: "dammi un meccanismo che mi permette di aspettare fin quando è il momento". Aspettare cosa? Eh, questo lo decide il programmatore. Per una condition_variable ci serve uno unique_lock (e per unique_ lock ci serve un mutex). condition_variable non accede dunque al mutex nudo e crudo. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Gestire il risveglio (3) • Per risolvere questo tipo di situazioni, si utilizza la classe std::condition_variable ◦ Modella una primitiva di sincronizzazione che permette l'attesa condizionata di uno o più thread fino a che non si verifica una notifica da parte di un altro thread ◦ Richiede l'uso di un std::unique_lock<std::mutex> per garantire l'accesso in mutua esclusione alle variabili usate per valutare la condizione di attesa • Offre il metodo wait(unique_lock) per bloccare l'esecuzione del thread ◦ Attraverso i metodi notify_one() e notify_all(), un altro thread può informare uno o tutti i thread attualmente in attesa che la condizione attesa si è verificata Programmazione di Sistema 83 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Gestire il risveglio (4) use namespace std; mutex m; condition_variable cv; int dato; void thread1() { ... //calcola un dato { lock_guard<mutex> lg(m); dato= ...; cv.notify_one(); } ... } cv m ul lock() wait() unlock() lock() notify_one() lock() void thread2() { unique_lock<mutex> ul(m); cv.wait(ul); //uso il dato } unlock() Funziona tipo il bussolotto in banca. Sofisticato. Programmazione di Sistema Siccome devo far comunicare due thread mi serve un dato condiviso, quindi come minimo ho bisogno di un mutex. Ma non basta. Dovrei fare polling continuo con attesa 0, ma brucia CPU in maniera superstraordinaria. Ci piacerebbe una soluzione che risponda all' esigenza: "Appena hai finito puoi continuare, non prima e non dopo". Creando un condition_variable non sincronizziamo un bel niente. La sincronizzazione ci viene offerta tramite una coppia di metodi: - wait(), fa sì che il thread corrente fino a quando la condition_variable non è soddisfatta - notify_one() e notify_all(): mandano la notifica di soddisfacimento --------------------------------------------------------------------------------------------Supponiamo di avere un thread t1 che in modo più o meno ciclico produce dei dati, messi a disposizione di un thread t2. Il nostro t1 ha dunque una variabile condivisa (int dato), e siccome è condivisa mi serve un mutex che la protegga (mutex m). Siccome t2 vuole subito il nuovo dato, oltre a creare il mutex metto anche una condition_ variable cv. Inizialmente nessuno ha il mutex. A un certo punto t1 comincia a farsi i fatti suoi: entra in un blocco e creo un lock_guard sul mutex ("adesso voglio che nessuno usi il mutex"), accedo al dato condiviso e invece di chiudere la graffa e basta - liberando il mutex - dico alla condition_variable che ho un nuovo dato (notify_one()). Nel frattempo t2 voleva disperatamente quel dato: ha creato uno unique_ lock sul mutex e poi si è messo ad aspettare con la wait, che verifica se la condition_variable è stata soddisfatta. t2 si mette a dormire fin quando la condition_variable non è notificata. Dico a t2 di svegliarsi e t2 riparte. t2 cerca dunque di riprendersi il lock: quando il lock è liberato (chiusa graffa in t1) la wait ritorna: in quel momento non solo c'è un dato, ma il lock è suo. L'altro se nel frattempo cerca di mettere dati nuovi non può farlo. wait suppone quindi due attese: 1) attendi fino a che qualcuno non chiama notify e 2) dopo, attendi di riprenderti il lock In modo formale, condition_variable è una struttura dati che modella un meccanismo che permette di attendere senza consumare risorse che succeda qualcosa (= qualcuno chiama notify). Questo ha un senso perchè quello che stiamo aspettando deve succedere in un altro thread. Poichè lui, mentre stiamo dormendo, deve permettere ad altri di fare, condition_variable richiede un oggetto di tipo unique_lock (= ho sicurezza di mutua esclusione e l'altro può fare delle cose). Può darsi che io abbia una condition_variable e due diversi thread che fanno la wait lì sopra. Attenzione: è necessario che questi due thread abbiano passato uno unique_lock allo stesso mutex. E qui capiamo la differenza tra notify_one (pesca uno tra quelli in attesa) e notify_all (pescali tutti). Occhio: diventano tutti schedulabili, non è che partono subito. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 std::condition_variable (1) • Permette, ad uno o più thread, di attendere, senza consumare risorse, la ricezione di una notifica • L'attesa richiede un oggetto di tipo std::unique_lock ◦ Proveniente da un altro thread ◦ Internamente, wait esegue in modo atomico le seguenti tre operazioni • Rilascia il lock • Attende la notifica • Riacquisisce il lock • Se più di un thread si trova in attesa di un oggetto di questo tipo, in base alla notifica ricevuta si ha il seguente comportamento ◦ Se la notifica è stata fatta tramite notify_one(), uno solo dei thread in attesa viene svegliato (la scelta è casuale) ◦ Se la notifica è stata fatta tramite notify_all(), tutti i thread in attesa vengono svegliati • La presenza di un unico lock fa si che, se più thread ricevono la notifica, il risveglio sia progressivo ◦ Non appena un thread rilascia il lock, un altro può acquisirlo e proseguire la computazione Programmazione di Sistema 85 Come nel caso del mutex, la relazione che c'è tra l'evento e l'oggetto è nella nostra testa. condition_variable specifica che certe volte può capitare che wait ritorna anche se nessuno ha chiamato notify. Questo vuol dire che dobbiamo passare alla wait, oltre che uno unique_lock, anche una funzione che ha il seguente compito: quando si sveglia acquisisce il suo mutex e va a guardare se il motivo per cui ci siamo addormentati è soddisfatto o meno. Se questa funzione ritorna false, wait rilascia il lock e aspetta nuovamente. Quindi wait non va chiamato a cazzo, ma con una funzione. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 std::condition_variable (2) • La relazione tra l'evento verificatosi e la notifica è solo nella testa del programmatore ◦ Per evitare notifiche spurie, si rende esplicito l'evento verificatosi scrivendolo dentro una variabile condivisa (sotto il controllo del mutex) • Una versione overloaded del metodo wait, accetta come parametro un oggetto chiamabile ◦ Ha il compito di valutare se l'evento verificatosi è proprio quello atteso, restituendo true oppure false ◦ Alla ricezione di una notifica, il metodo wait invoca l'oggetto chiamabile e, se il risultato è falso, si rimette in attesa; altrimenti ritorna al chiamante Programmazione di Sistema 86 Esempio: vogliamo sapere quando un certo dato è pronto. I due thread hanno in comune una queue. Il primo thread genera dei valori e li inserisce in coda (coi suoi tempi) affinchè vengano elaborati. Il secondo thread pesca i dati dalla coda nell'ordine con cui sono arrivati e li consuma. I due thread ovviamente viaggiano a velocità diverse e quindi viene molto bene l'uso delle condition_variable. std::condition_variable (3) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 std::mutex mut; //protegge la coda std::queue<data_chunk> data_queue; //dato condiviso std::condition_variable data_cond; //indica che la coda non è vuota void data_preparation_thread() { while(more_data_to_prepare()) { data_chunk const data=prepare_data(); std::lock_guard<std::mutex> lk(mut); data_queue.push(data); data_cond.notify_one(); } } Il thread 1 quindi finchè ha da fare procura il dato e fa il lock del mutex tramite un lock_guard. Pusho nella lista e notifico. Il thread 2 si prende il lock sul mutex e chiama la wait sulla condition variable, con lock che ho appena creato e con una funzione lambda che dice di non svegliarsi se la data_queue è vuota. Se mi sveglio prendo il dato, faccio la unlock, lo processo e se non è l'ultimo dato da gestire ritorno al while. Se la wait si svegliasse per qualche notifica di cazzo non c'è problema, perchè si guarda se c'è un dato nella coda. void data_processing_thread() { while(true) { std::unique_lock<std::mutex> lk(mut); data_cond.wait(lk, [](){return !data_queue.empty();}); data_chunk data=data_queue.front(); data_queue.pop(); lk.unlock(); process(data); if(is_last_chunk(data)) break; } } Programmazione Programmazio Prog azione ne di di Sistema Sistema Sistem 87 Accanto a wait che aspetta per un tempo indefinito, ci sono anche le versioni wait_for() ("aspetta fino a tanti secondi") e wait_until ("aspetta fino a questo preciso momento") che permettono di fissare timeout sull'attesa. Come faccio poi a capire se sono uscito da wait_for/until perchè è passato il tempo o perchè c'erano dei dati? Se passo una funzione chiamabile me ne accorgo, altrimenti devo guardare il valore di ritorno (TIMEOUT: nessuno mi ha fatto una notify, NO_TIMEOUT: qualcuno probabilmente ha chiamato notify). A volte vorrei notificare una condition_variable come ultima cosa prima di morire come thread: si usa notify_all_at_thread_exit(). Questo permette di garantire che lui sarà completamente distrutto quando gli altri si sveglieranno. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 std::condition_variable (4) • Accanto al metodo wait(...), che attende per un tempo indeterminato, sono presenti anche i metodi wait_for(...) e wait_until(...) ◦ Pongono un limite superiore all'attesa ◦ Se chiamati senza indicare un oggetto chiamabile, restituiscono le costanti std::cv_status::timeout e std::cv_status::no_timeout per indicare l'esito dell'attesa • Le notifiche possono essere effettuate anche tramite il metodo notify_all_at_thread_exit() ◦ Offre l'opportunità al thread che esegue la notifica di completare la propria distruzione prima che i thread in attesa abbiano l'opportunità di osservare il dato condiviso Programmazione di Sistema 88 Di base il nostro sistema operativo lavora a 32/64 bit. Di per sè le CPU, anche multicore, hanno accanto alle operazioni standard un set di operazioni atomiche (garantite fare un ciclo completo di memoria indivisibile e non soggetto a interruzioni di varia natura). Per favorire alcuni casi particolari di sincronizzazione, C++11 offre atomic, oggetto generico che permette di eseguire operazioni atomiche su un dato elementare di tipo T (dove T può essere solo intero, char, ... o anche un puntatore). Posso supportare operazioni di tipo read/modify/write. Un esempio su tutti è l'incremento. Istruzioni di questo tipo sono ovviamente più onerose perchè fanno da memory fence e permettono di creare meccanismi minimali di sincronizzazione. Operazioni atomiche © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Le normali operazioni di accesso in lettura e scrittura ad una cella di memoria non offrono nessuna garanzia sulla visibilità delle operazioni che sono eseguite in parallelo da più thread ◦ I processori supportano alcune istruzioni specializzate per permettere l'accesso atomico ad un singolo valore • La classe std::atomic<T> offre la possibilità di accedere in modo atomico al tipo T ◦ Ovvero garantendo che gli accessi concorrenti alla variabile sono osservabili nell'ordine in cui avvengono ◦ Questo garantisce il meccanismo minimo di sincronizzazione • Le operazioni di lettura e scrittura di questi oggetti contengono al proprio interno istruzioni di memory fence ◦ Che garantiscono che il sottosistema di memoria non mascheri il valore corrente della variabile Programmazione di Sistema 89 Nella classe atomic ci sono fondamentalmente due metodi, load (copiare dalla memoria un dato con un'operazione univoca) e store (salvo nella memoria un dato). La classe std::atomic<T> (1) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Le operazioni su questi oggetti non possono essere riordinate ◦ Rendendoli utili per segnalare condizioni di terminazione ◦ Oppure per generare dipendenze di tipo "happens_before" tra attività differenti std::atomic<boolean> done=false; void task1() { //continua ad elaborare fino a che //non viene detto di smettere while (! done.load() ) { process(); } } void task2() { wait_for_some_condition(); //segnala che il task1 deve finire done.store(true); //... } void main() { auto f1=std::async(task1); auto f2=std::async(task2); } Programmazione di Sistema 90 Inoltre ho anche operazioni read/modify/write come: - fetch_add(val): dato += val, atomico - fetch_sub(val): dato -= val, atomico - operator++(): dato++, atomico - operator--(): dato--, atomico - exchange(val): ti sto passando un dato. Prendilo, sbattilo dentro la memoria e ritornami quello che c'era in memoria. © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 La classe std::atomic<T> (2) • Le operazioni di inizializzazione non sono atomiche • Oltre all'assegnazione ed alla lettura, offrono anche operazioni atomiche di tipo Read/Modify/Write ◦ Quelle di accesso tramite load() e store(T t), sì ◦ ◦ ◦ ◦ ◦ • fetch_add(val) – aggiunge val al valore corrente (+=) fetch_sub(val) – sottrae val dal valore corrente (-=) operator++() – equivalente a fetch_add(1) operator--() – equivalente a fetch_sub(1) exchange(val) – assegna val e ritorna il valore precedente Il template offre alcune specializzazioni per i tipi int e boolean Programmazione di Sistema 91 Ci sono strategie che rendono alcuni problemi più facilmente trattabili di altri. Quando lavoriamo sul file system, leggere da file è un'operazione tendenzialmente lenta. Buono fare tanti thread. Così come è buono farli quando serviamo la rete e gestiamo molte connessioni in parallelo. Strategie per sfruttare la concorrenza © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Alcune classi di problemi si prestano maggiormente alla decomposizione in sotto-attività parzialmente indipendenti ◦ Questo permette di aumentare il livello di parallelismo, sfruttare gli eventuali ulteriori processori disponibili e aumentare le prestazioni totali • Un tipico esempio è costituito da programmi che operano intensamente sul file system ◦ Oppure da programmi che fanno accesso alla rete (sia come client che come server) ◦ O ancora da programmi che svolgono attività molto intense dal punto di vista computazionale Programmazione di Sistema 92 In generale, quando passo a un'elaborazione concorrente devo garantire che il mio programma sia thread-safe (non succeda niente di sbagliato). Non ci devono dunque essere interferenze, e per levarmi dall'impiccio creo dei mutex. Correttezza © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Nel momento in cui si passa da un algoritmo sequenziale ad un algoritmo concorrente occorre garantire che ogni componente software sia thread-safe A volte togliere l'interferenza non basta, perchè vogliamo anche che il programma vada avanti. Possiamo usare le condition_variable, meccanismo efficiente per andare avanti quando ci sono dei dati. ◦ Ovvero che il suo stato non possa essere travisato a seguito di un interferenza tra thread • Nella programmazione ad oggetti, lo stato è normalmente incapsulato negli attributi (privati) ◦ Si accede allo stato attraverso i metodi (pubblici): se un oggetto deve essere thread-safe, occorre adottare le necessarie contromisure • In generale è bene mettere i dati da condividere in un oggetto sotto forma di attributi private. Questo oggetto ha naturalmente anche un mutex e magari una condition_variable. Se non faccio così e debuggo magari funziona tutto per puro culo. In mancanza di queste, il programma potrà anche apparire corretto ◦ Ma di fatto non lo è e, in qualunque momento, potrà dare origine a comportamenti inattesi Programmazione di Sistema 93 In generale un oggetto non è thread-safe per ciò che fa, ma per come viene usato. Strategie per la correttezza © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • • - Se un thread è tutto confinato a operare su uno spazio di memoria limitato è safe. - Se un oggetto è immutabile non dà casini. - Altrimenti l'unica salvezza è l'uso di strategie di sincronizzazione. Un oggetto (funzioni+dati) è thread safe non in virtù di ciò che fa, ma di come viene usato Tre strategie possibili ◦ Confinare l’uso di un oggetto all’interno di uno specifico thread ◦ Rendere gli oggetti immutabili ◦ Adottare tecniche di sincronizzazione • Ogni strategia comporta qualche penalizzazione in termini di complessità e prestazioni. Non tutte le strategie sono sempre percorribili ◦ Inoltre, ognuna comporta una penalizzazione in termini di complessità e prestazioni Programmazione di Sistema 94 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Principi generali sull’uso dei thread • Identificare le aree di potenziale parallelismo • Sincronizzare l’accesso a tutti i dati condivisi mutabili • Evitare di prendere il possesso di una risorsa condivisa mentre se ne possiede già un’altra • Ridurre il tempo in cui si detiene l’accesso esclusivo ad una data risorsa • Studiare attentamente le funzioni invocate all’interno di un blocco sincronizzato Quando dobbiamo usare i thread capiamo se ci sono zone di potenziale parallelismo e progettiamo il codice per poterle utilizzare. Se ci sono dati condivisi mutabili vanno protetti mettendoli all'interno di oggetti. Evitiamo di prendere possesso di una risorsa condivisa mentre ne abbiamo un'altra, perchè rischiamo deadlock a bombazza. Quando abbiamo il lock facciamo il meno possibile. Nei blocchi sincronizzati studiamo bene le funzioni che chiamiamo. Documentare il codice è un dovere civico. ◦ E progettare il codice per poterle sfruttare ◦ Ed utilizzare variabili locali al thread per impedire l’accesso ai dati privati ◦ Può dare origine al blocco del programma ◦ Per non rallentare altri thread ◦ Documentare opportunamente il codice che si produce rispetto all’esecuzione concorrente Programmazione di Sistema 95 Spesso si realizzano applicazioni concorrenti da applicazioni sequenziali che già funzionano. Parallelizzare subito non fa capire se i problemi nascono dall'algoritmo o da errori di parallelizzazione. Realizzare un’applicazione concorrente Spesso si realizza un’applicazione concorrente a partire da una versione seriale funzionante • Realizzare fin dall’inizio un’applicazione concorrente può essere più complesso © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • ◦ è più difficile verificare se gli errori sono dovuti ad un errore nella logica dell’algoritmo o se sono causati da errori introdotti dalla gestione errata dei thread • Per trasformare un’applicazione seriale in una concorrente si devono identificare ◦ le attività indipendenti ◦ i blocchi di dati Programmazione di Sistema 96 In generale bisogna avere tanti thread quante sono le attività parallele, non di più e non di meno. Attività indipendenti © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Le attività indipendenti possono essere eseguite in qualunque ordine ◦ chiamate a funzione, iterazioni ◦ gruppi di istruzioni che possono essere raggruppate in elaborazioni indipendenti • È necessario avere tante attività quanti sono i thread ◦ per evitare lo spreco di risorse ◦ la quantità di elaborazione di ciascuna attività (granularità) deve essere sufficiente a bilanciare l’overhead introdotto dalla gestione dei thread Programmazione di Sistema 97 La granularità dipende da quello che vogliamo fare. Granularità (tutto il resto delle slide è da leggere per conoscenza) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • La granularità può anche essere definita come la quantità di elaborazione che può essere eseguita prima della sincronizzazione decomposizione a grana fine decomposizione a grana grossa Programmazione di Sistema 98 Attività non indipendenti (1) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • • Algoritmi, funzioni o procedure che contengono uno stato Cicli in cui ci sono relazioni di ricorrenza ◦ le informazioni di un’iterazione vengono utilizzate da quella successiva • Operazioni con variabili di induzione ◦ vengono incrementate in ogni iterazione i1 = 4; i2 = 0; for (k = 1; k < N; k++) { B[i1++] = function1(k,q,r); i2 += k; A[i2] = function2(k,r,q); } Programmazione di Sistema 99 Attività non indipendenti (2) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Operazioni di riduzione ◦ trasformazione di un insieme di dati in un valore scalare tramite un’operazione di combinazione • Cicli in cui si verifica una dipendenza fra le iterazioni ◦ i risultati di iterazioni precedenti devono essere utilizzati nell’iterazione corrente sum = 0; big = c[0]; for (i = 0; i < N; i++) { sum += c[i]; big = (c[i] > big ? c[i] : big); // maximum element } Programmazione Programm Prog rammazio azione azio ne di di Sistema Sistem Sistem stema a for (k = 5; k < N; k++) { b[k] = func(k); a[k] = b[k-5] + func1(k); } 100 Identificazione di blocchi di dati © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Se si opera con grandi quantità di dati, è possibile suddividerli in blocchi ◦ ed assegnare a ciascun thread l’elaborazione di uno di essi ◦ nei casi in cui si debbano eseguire una serie di operazioni su tutti gli elementi • se le operazioni sono indipendenti, non è necessario il coordinamento fra i thread • altrimenti, è necessario limitare il numero di elementi 䇾confinanti䇿, per ridurre il numero di interazioni Programmazione di Sistema 101 Regole per il progetto (1) Identificare le attività veramente indipendenti • Implementare la concorrenza al più alto livello possibile © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • ◦ Cercare le porzioni di codice che richiedono maggior tempo di esecuzione • Eseguendole in parallelo si ottiene il massimo aumento delle prestazioni ◦ Successivamente, cercare altre operazioni che possono essere rese parallele • In questo modo, si evita di rendere troppo fine la granularità Programmazione di Sistema 102 Regole per il progetto (2) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Pianificare la scalabilità ◦ Il numero di core dei processori è destinato a continuare a crescere • è necessario tenere conto di questo aumento per assicurare la scalabilità del software ◦ Con l’aumento della capacità computazionale si avrà anche un aumento dei dati da processare • è necessario adottare tecniche di suddivisione dei dati per ottenere soluzioni scalabili Programmazione di Sistema 103 Regole per il progetto (3) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Utilizzare librerie thread-safe ◦ invece di eseguire codice ad-hoc ◦ per evitare possibili conflitti di dati ◦ altrimenti è necessario utilizzare la sincronizzazione per proteggere l’accesso a risorse condivise • Non utilizzare thread se esiste un modello implicito di threading che ha tutte le funzionalità richieste ◦ il threading esplicito permetta al programmatore un controllo fine • ma espone anche a rischi (errori, manutenzione del codice…) ◦ ad esempio, OpenMP si focalizza sulla suddivisione dei dati, per rendere paralleli cicli su grandi insiemi di dati Programmazione di Sistema 104 Regole per il progetto (4) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Non assumere un particolare ordine di esecuzione ◦ l’ordine di esecuzione dei thread è non deterministico e controllato dallo scheduler del sistema operativo • Esso può variare di esecuzione in esecuzione ◦ i conflitti di dato sono un risultato del non determinismo dello scheduling Programmazione di Sistema 105 Regole per il progetto (5) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • La sincronizzazione è un overhead, quindi deve essere utilizzata solo quando necessaria ◦ utilizzare principalmente locazioni di memoria locali ad un thread ◦ l’aggiornamento di celle di memoria condivise richiede sincronizzazione • effettuare gli aggiornamenti poco frequentemente ◦ proteggere le variabili condivise con un oggetto di sincronizzazione • relazione 1:1 • se si accede a più variabili sempre contemporaneamente, utilizzare un unico meccanismo di sincronizzazione Programmazione di Sistema 106 Regole per il progetto (6) © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 • Se un algoritmo seriale ottimizzato non può essere trasformato in modo semplice in uno multithread ◦ è consigliabile utilizzare un algoritmo seriale subottimo e trasformarlo in uno multithread Programmazione di Sistema 107 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Progettare strutture dati concorrenti • Individuare gli invarianti che si vuole siano soddisfatti in ogni momento • Assicurarsi che nessun thread possa osservare uno stato intermedio in cui gli invarianti non sono validi • Evitare le corse critiche intrinseche alle interfacce • Fare attenzione alle eccezioni ◦ Rappresentano le "regole del gioco" ◦ Usando gli opportuni costrutti di sincronizzazione ◦ Offrendo solo metodi che corrispondono ad operazioni complete ◦ Che potrebbero portare a stati in cui gli invarianti non sono soddisfatti • Minimizzare le possibilità di deadlock ◦ Evitando di possedere più lock contemporaneamente Programmazione di Sistema 108 © C.Barberis, M.Badella, G. Malnati, L. Tessitore 2002-14 Confronto tra diverse piattaforme Costrutto POSIX C Win32 BOOST C++11 Thread Tipo pthread_t pthread_create() pthread_detach() pthread_join() Tipo HANDLE CreateThread() WaitForSingleObject() Classe boost::thread Classe std::thread Mutua esclusione Tipo pthread_mutex_t pthread_mutex_lock() pthread_mutex_unlock() CRITICAL_SECTION EnterCriticalSection() LeaveCriticalSection() Classi boost::mutex boost::lock_guard boost::unique_lock Classi std::mutex std::lock_guard std::unique_lock Attesa di una condizione Tipo pthread_cond_t pthread_cond_signal() pthread_cond_wait() CONDITION_VARIABLE WakeConditionVariable() SleepConditionVariableCS() Classe boost::condition_ variable Classe std::condition_ variable Operazioni atomiche N/D InterlockedIncrement() InterlockedDecrement() InterlockedExchange() N/D Template std::atomic<T> Future N/D N/D Template boost::unique_ future<T> boost::shared_ future<T> Template std::unique_ future<T> std::shared_ future<T> ThreadPool N/D QueueUserWorkItem() N/D N/D N/D Metodo interrupt() della classe boost::thread N/D Interruzione pthread_cancel() Programmazione di Sistema 109