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