Lezione 12 · 2014-12-03 · Lezione 12 Sincronizzazione Sistemi Operativi (9 CFU), CdL...

Post on 15-Aug-2020

2 views 0 download

Transcript of Lezione 12 · 2014-12-03 · Lezione 12 Sincronizzazione Sistemi Operativi (9 CFU), CdL...

1

Lezione 12SincronizzazioneSistemi Operativi (9 CFU), CdL Informatica, A. A. 2014/2015Dipartimento di Scienze Fisiche, Informatiche e MatematicheUniversità di Modena e Reggio Emiliahttp://weblab.ing.unimo.it/people/andreolini/didattica/sistemi-operativi

2

Quote of the day(Meditate, gente, meditate...)

“Everyone knows that debugging is twice as hard as writing a program in the first place. So if you're as clever as you can be when you write it, how will you ever debug it?”Brian Kernighan (1942-)Accademico, informatico teoricoCoautore di UNIX e di AWKAutore del libro

“The C Programming Language”

3

INTRODUZIONE

4

Una grande limitazione(Processi e thread eseguono indipendentemente, senza coordinazione)

Negli esempi visti finora, processi e thread eseguono indipendentemente senza alcuna coordinazione.I tentativi (penosi) precedenti di coordinazione tramite temporizzazione manuale (sleep()) sono destinati a fallire nel mondo reale.

5

I motivi del fallimento(Della sincronizzazione temporale)

L'intervallo di attesa (il parametro di sleep()):può essere adeguato per una macchina veloce matroppo breve per un'altra più lenta.può essere adeguato a macchina scarica e inadeguatosotto l'imposizione di un carico computazionale piùelevato.

Non c'è speranza di individuare un intervallo buono per tutte le occasioni!

Lasciate perdere la temporizzazione con sleep()!Non è una buona idea!Fidatevi!Veramente!

6

Che cosa serve, allora?(Bella domanda!)

→ Servono meccanismi per coordinare l'esecuzione di processi e thread.In sostanza, un processo o thread attende il verificarsi di una qualche condizione. Solo allora prosegue la sua esecuzione.Se tutti i processi o thread sono in grado di fare questo con criterio, l'esecuzione risulta coordinata.

7

Definizione(Che cosa si intende esattamente per “sincronizzazione”)

La sincronizzazione di processo (o thread) è l'atto di un processo o thread di bloccare la propria esecuzione nell'attesa di un evento.Non appena l'evento si verifica, il processo o thread continua la propria esecuzione.Eventi tipici:

cambio di stato di un altro processo/thread (di solito,la terminazione).il rilascio di una risorsa da parte di un altro thread (siparla di accesso in mutua esclusione).una condizione logica espressa dal programmatore.

8

SINCRONIZZAZIONE SULCAMBIO DI STATO

9

Problema(Un processo/thread padre non sa “aspettare” i propri figli)

Un processo o thread padre deve poter:bloccare la propria esecuzione fino a quando almenouno dei suoi figli non esce.leggere il codice di uscita del processo/thread uscitoper capire se si sono verificati errori.

Esempi tipici:la shell esegue un comando e comunica eventualierrori.un server crea un nuovo processo worker e attendeil completamento della relativa richiesta.

10

La chiamata di sistema wait()(Blocca il processo padre fino a quando almeno un figlio non cambia stato)

La chiamata di sistema wait() blocca il processo invocante fino a quando uno qualunque dei suoi figli non cambia stato.Cambio di stato: è uno degli eventi seguenti.

Un processo figlio esce normalmente o con errore.Un processo figlio esce per via di un segnale.Un processo figlio è stoppato o ripristinato da unsegnale.

In questa introduzione ci si concentra sul primo caso.

11

Parametri in ingresso(Uno; un puntatore ad una variabile intera)

La wait() vuole un unico parametro in ingresso: un puntatore ad una variabile intera.Quando un processo figlio cambia stato, nella variabile puntata sarà codificata una serie di informazioni che spiegano in dettaglio il cambio di stato.

12

Valore di ritorno(Il PID del processo figlio che ha cambiato stato, oppure -1)

Il valore di ritorno di wait() è il PID del processo che ha cambiato stato, se tutto è andato a buon fine.Altrimenti, viene ritornato il valore -1 e la variabile errno contiene il codice esatto dell'errore.

man 2 wait per tutti i dettagli.

13

Il funzionamento di wait()(Che cosa deve scrivere il programmatore)

pid_t pid, pid_changed;int status;

pid = fork();if (pid > 0) {

pid_changed = wait(&status);<lettura di status>

}

Qui il processo padre siblocca in attesa che ilfiglio cambi stato.

14

Alcuni scenari di uso di wait()(wait() tempestiva, tardiva, mai invocata)

Sono ora presentati alcuni esempi classici di uso corretto, improprio e mancato di wait(). In tutti questi esempi:

un processo padre genera un figlio. il processo figlio stampa un messaggio, aspetta alcunisecondi ed esce.

Come si differenziano gli esempi?wait_1.c: il processo padre invoca wait() primadella terminazione del processo figlio.wait_2.c: il processo padre invoca wait() dopola terminazione del processo figlio.wait_3.c: il processo padre non invoca wait().

15

Scenario wait() tempestiva(Il processo padre esegue la wait() prima della terminazione del figlio)

Il processo padre crea unfiglio con la fork().Entrambi i processi eseguono concorrentemente.

padre

figlio

fork() Running

Tabellaprocessi

Running

PID

PID

Sorgente wait_1.c in 12-esempi.tar.bz2.

16

Scenario wait() tempestiva(Il processo padre esegue la wait() prima della terminazione del figlio)

Il processo padre invoca la wait() prima della morte del figlio, tempestivamente.Il processo padre viene bloccato in attesa che il figlio termini la sua esecuzione.

padre

figlio

wait()

Blocked

Tabellaprocessi

Running

17

Scenario wait() tempestiva(Il processo padre esegue la wait() prima della terminazione del figlio)

Ad un certo punto, il processo figlio termina la sua esecuzione.Il kernel imposta lo stato del figlio a TASK_DEAD (uscita).

→ Processo figlio non puòpiù essere schedulato.

padre

figlio

wait()

Running

Tabellaprocessi

ExitingX

18

Scenario wait() tempestiva(Il processo padre esegue la wait() prima della terminazione del figlio)

Il kernel controlla se deve assemblare le informazioni sul cambio di stato e copiarle nella variabile puntata dall'argomento di wait() nel processo padre.Nel caso specifico di wait_1.c non viene copiato niente (il parametro di wait() è NULL).

padre

figlio

wait()

Running

Tabellaprocessi

ExitingXPID,

codiceuscita

19

Scenario wait() tempestiva(Il processo padre esegue la wait() prima della terminazione del figlio)

La wait() termina. Il kernel imposta a il processo padre a TASK_RUNNING (pronto per l'esecuzione).Il kernel invia un segnale SIGCHLD al padre (di solito ignorato).Viene letto il codice di uscita del figlio (non nel caso di wait_1.c).

padre

Running

Tabellaprocessi

PID,codiceuscita

20

Verifica sperimentale(Si esegua wait_1.c e si osservino gli stati dei due processi)

Si compili wait_1.c e si mandi in esecuzione wait_1.Per osservare in diretta lo stato dei due processi:

watch -n 1 'ps faxu | grep [w]ait_1'

Lancia ognisecondo

ciò che segue

Elencocompletoprocessi

Filtra iprocessi

interessati

21

Scenario wait() tardiva(Il processo padre esegue la wait() dopo la terminazione del figlio)

Il processo padre crea unfiglio con la fork().Entrambi i processi eseguono concorrentemente.

padre

figlio

Running

Tabellaprocessi

Running

PID

PID

Sorgente wait_2.c in 12-esempi.tar.bz2.

fork()

22

Scenario wait() tardiva(Il processo padre esegue la wait() dopo la terminazione del figlio)

Ad un certo punto, il processo figlio termina la sua esecuzione.Il kernel imposta lo stato del figlio a TASK_ZOMBIE.

→ Processo figlio morto a tutti gli effetti.

→ Il PCB è ancora presente nella tabella dei processi, ma è usato dal kernel solo per fornire al padre il valore di uscita.

padre

figlio

Running

Tabellaprocessi

ZombieX

23

Scenario wait() tardiva(Il processo padre esegue la wait() dopo la terminazione del figlio)

Il processo padre invoca la wait() dopo la morte del figlio, tardivamente.Il kernel controlla se deve assemblare le informazioni sul cambio di stato e copiarle nella variabile puntata dall'argomento di wait() nel processo padre.Nel caso specifico di wait_2.c non viene copiato niente (il parametro di wait() è NULL).

padre

Running

Tabellaprocessi

Zombie

wait()

figlioXPID,

codiceuscita

24

Scenario wait() tardiva(Il processo padre esegue la wait() dopo la terminazione del figlio)

La wait() termina subito, senza bloccarsi. Il kernel imposta a il processo padre a TASK_RUNNING (pronto per l'esecuzione).Il kernel invia un segnale SIGCHLD al padre (di solito ignorato).Viene letto il codice di uscita del figlio (non nel caso di wait_2.c).

padre

Running

Tabellaprocessi

PID,codiceuscita

25

Verifica sperimentale(Si esegua wait_2.c e si osservino gli stati dei due processi)

Si compili wait_2.c e si mandi in esecuzione wait_2.Per osservare in diretta lo stato dei due processi:

watch -n 1 'ps faxu | grep [w]ait_2'Si osservino:

il cambio di stato del figlio da S a Z.l'azzeramento delle risorse del figlio quando diventauno zombie.

26

Scenario wait() mai invocata(Il processo padre non esegue mai la wait())

Il processo padre crea unfiglio con la fork().Entrambi i processi eseguono concorrentemente.

padre

figlio

Running

Tabellaprocessi

Running

PID

PID

Sorgente wait_3.c in 12-esempi.tar.bz2.

fork()

27

Scenario wait() mai invocata(Il processo padre non esegue mai la wait())

Ad un certo punto, il processo figlio termina la sua esecuzione.Il kernel invia un segnale SIGCHLD al padre (di solito ignorato).Il kernel imposta lo stato del figlio a TASK_ZOMBIE. Finché il padre esegue, il figlio rimane in TASK_ZOMBIE.

padre

figlio

Running

Tabellaprocessi

ZombieX

28

Scenario wait() mai invocata(Il processo padre non esegue mai la wait())

Quando il padre termina l'esecuzione, vengono ripuliti entrambi i PCB.

Tabellaprocessi

29

Verifica sperimentale(Si esegua wait_3.c e si osservino gli stati dei due processi)

Si compili wait_3.c e si mandi in esecuzione wait_3.Per osservare in diretta lo stato dei due processi:

watch -n 1 'ps faxu | grep [w]ait_3'Si osservino:

il cambio di stato del figlio da S a Z.l'azzeramento delle risorse del figlio quando diventauno zombie.

30

Esercizi (5 min.)

1. Si modifichi il sorgente di getpids.c nell'archivio 9-soluzioni.tar.bz2 in modo tale da sincronizzare il processo padre con l'uscita del figlio. Si gestiscano anche eventuali errori.

32

Gestione dello stato(Possibile tramite “comode” macro)

Se l'argomento di wait() è un puntatore valido ad una variabile intera, in tale variabile sarà impacchettata l'informazione relativa al cambio di stato del processo figlio.Lo stato è letto con delle macro che prendono in ingresso la variabile intera.

man 2 waitMacro:

WIFEXITED(status)...WIFCONTINUED(status)

33

Un esempio concreto(Lettura del cambio di stato)

Il sorgente wait_status.c contenuto nell'archivio 12-esempi.tar.bz2 è una variante di wait_1.c in cui è stampato il codice di uscita del processo figlio. A tal scopo, si usa la macro seguente.WIFEXITSTATUS(status): estrae da status il codice di uscita del processo figlio.

34

Esercizi (10 min.)

2. Il sorgente wait_status.c funziona, ma contiene una grossa imprecisione. Si studi la pagina di manuale di wait(), la si individui, la si corregga.

36

Attesa di processi figli specifici(Chiamata di sistema waitpid())

La chiamata di sistema waitpid() è concettualmente analoga a wait() e permette di attendere il cambio di stato di specifici processi figli.È anche possibile specificare le modalità dell'attesa con alcune specifiche opzioni.

man 2 wait per tutti i dettagli.

37

Parametri in ingresso(Ben tre; quali PID attendere, il puntatore allo stato e la modalità operativa)

La waitpid() vuole in ingresso tre parametri.Primo parametro: identifica quale o quali processi figli monitorare.

> 0: PID di uno specifico processo figlio.0: un qualunque processo del gruppo di processi

di cui fa parte il processo invocantewaitpid().

-1: un qualunque processo figlio.< -1: un qualunque processo del gruppo di processi

identificato dal valore assoluto dell'argomento.

38

Parametri in ingresso(Ben tre; quali PID attendere, il puntatore allo stato e la modalità operativa)

La waitpid() vuole in ingresso tre parametri.Secondo parametro: un puntatore ad una variabile intera in cui sarà impacchettata l'informazione relativa al cambio di stato del processo.La gestione è identica a wait().

39

Parametri in ingresso(Ben tre; quali PID attendere, il puntatore allo stato e la modalità operativa)

La waitpid() vuole in ingresso tre parametri.Terzo parametro: una variabile intera rappresentante un vettore di bit contenente le modalità operative. Le modalità sono impostabili tramite OR bit a bit (operatore | in C) del vettore di bit con opportune costanti.

man 2 waitCostanti:

WNOHANG...WCONTINUED

40

Valore di ritorno(Il PID del processo figlio che ha cambiato stato, oppure -1)

Semplificando: il valore di ritorno di waitpid() è il PID del processo che ha cambiato stato, se tutto è andato a buon fine.Altrimenti, viene ritornato il valore -1 e la variabile errno contiene il codice esatto dell'errore.

man 2 wait per tutti i dettagli.

41

Un esempio concreto(Attesa esplicita di più figli)

Il sorgente waitpid_1.c nell'archivio12-esempi.tar.bz2 illustra l'uso basilare di waitpid().Un processo padre crea tre figli e li aspetta esplicitamente. I tre figli dormono per un periodo casuale in [1s, 10s].

42

Esercizi (5 min.)

3. Si modifichi l'esempio wait_1.c in modo tale da usare waitpid() al posto di wait().

44

La funzione di libreria pthread_join()(L'analogo di waitpid() per i thread)

La funzione di libreria pthread_join() è l'analogo di waitpid() per i thread.Essa sospende l'esecuzione del thread invocante fino a che un determinato thread termina.

45

Parametri in ingresso(Due; la pthread_t del thread da attendere ed un puntatore doppio)

La pthread_join() vuole due parametri.Primo parametro: la variabile pthread_t che identifica il thread da attendere.Secondo parametro: un void** usato per memorizzare il valore di ritorno del thread.Può essere impostato a NULL se non si è interessati al valore di ritorno del thread.

46

Perché void**?(L'analogia con i processi e wait() può aiutare)

Processi.Un processo ritorna un codice di uscita intero.wait() memorizza il valore di ritorno in una variabile intera, tramite un suo puntatore int *.→Thread.Un thread ritorna un void *.pthread_join() memorizza il valore di ritorno in una variabile void *, tramite un suo puntatore void **.

47

Valore di ritorno(Un intero)

Il valore di ritorno di pthread_join() è un intero.

= 0: tutto OK.!= 0: un errore (man pthread_join per tutti gli

errori possibili).

48

Un esempio concreto(Attesa esplicita di più thread)

Il sorgente pthread_join.c nell'archivio12-esempi.tar.bz2 illustra l'uso basilare di pthread_join().Un processo padre crea due thread che:

stampano una stringa.ritornano la lunghezza della stringa stampata.

Il processo padre aspetta i thread e stampa il loro valore di ritorno.

49

Esercizi (10 min.)

4. Si modifichi il sorgente di hello_world.c nell'archivio 11-esempi.tar.bz2 in modo tale da sincronizzare il processo padre con i thread generati. Si gestiscano anche eventuali errori.

51

Intervallo(http://www.youtube.com/watch?v=_ACoU8fRyEw)

52

Un esempio più realistico(Multi-threaded, sincronizzato, scalabile)

Dopo aver imparato tutti i rudimenti necessari, prima di passare alla seconda parte della lezione si vuole cogliere al volo l'occasione per presentare un esempio più realistico di applicazione multi-threaded.L'obiettivo principale è quello di mostrare la scalabilità di tale applicazione rispetto ad una equivalente basata su singolo processo.

53

Somma dei primi n interi(Un problema risolvibile serialmente e parallelamente)

Si vuole calcolare la somma dei primi 2N-1 interi.

Si vuole confrontare il tempo di esecuzione di una soluzione seriale (un processo) e di una parallela (più thread).Di quanto è più veloce la versione parallela rispetto a quella seriale?

S=∑i=0

2N−1

i

54

Le due versioni(somma_seriale.c, somma_thread.c)

Il sorgente somma_seriale.c nell'archivio 11-esempi.tar.bz2 calcola la somma dei primi 2N-1 numeri serialmente.Argomenti del programma: il numero N.Il sorgente somma_thread.c nell'archivio 11-esempi.tar.bz2 calcola la somma dei primi 2N-1 numeri con più thread.Argomenti del programma: il numero di thread, il numero N.

55

Single process vs. multi-threaded(Uno schema)

Single process Multi-threaded

main()

12

2NΣ

main()

12

2N

threadΣthreadΣthreadΣthreadΣ

range

Σ

Σ

Σ

56

Confronto dei tempi(Versione seriale vs. versione parallela con un thread)

Qual è il “costo fisso” (sempre presente) legato all'uso dei thread?Si può provare a stimarlo calcolando la somma dei primi 231-1 interi con la versione seriale e la versione parallela con un solo thread.

/usr/bin/time ./somma_seriale 31/usr/bin/time ./somma_thread 1 31

57

Una prima indicazione(Non si notano grosse differenze)

Sulla macchina del docente (Intel I7 a 2.60GHz con Turbo Boost) si ottengono i seguenti risultati.

Sembra non sussistere alcuna differenza fra la versione seriale e quella parallela con un thread.

Programma user system elapsedsomma_seriale 5.350s 0.000s 5.350ssomma_thread 5.350s 0.000s 5.360s

58

Un esperimento più rigoroso(Prove ripetute; rimozione di funzioni lente)

Un approccio più rigoroso richiederebbe la valutazione statistica dei tempi di risposta dei due programmi.

Si eseguono 30 prove indipendenti per ciascun test.Si forniscono min, max, average, standard deviation.

Inoltre, bisognerebbe evitare ogni rallentamento del file system (che ridurrebbe sensibilmente la scalabilità).

Si scalda la cache con un test preliminare.Non si distrugge la cache dopo ogni test.Si eliminano le printf() nei due programmi.

Conduzione del test: script scalability_test.sh contenuto nell'archivio 12-esempi.tar.bz2.

59

Il risultato del confronto(Single process vs. single thread)

Sulla macchina del docente si ottengono i seguenti risultati (elapsed time).

Le due implementazioni sono praticamente identiche dal punto di vista delle prestazioni.

Programma minelapsed

avgelapsed

maxelapsed

stddevelapsed

somma_seriale 5.310s 5.315s 5.350s 0.008ssomma_thread 5.310s 5.327s 5.350s 0.007s

60

Confronto dei tempi(Versione parallela con un thread vs. versione parallela con due thread)

Di quanto cala il tempo di esecuzione se si usano due thread al posto di uno?Si può provare a stimarlo calcolando la somma dei primi 231-1 interi con la versione parallela con uno e con due thread.

/usr/bin/time ./somma_thread 1 31/usr/bin/time ./somma_thread 2 31

61

Il risultato del confronto(Un thread vs. due thread)

Sulla macchina del docente si ottengono i seguenti risultati (elapsed time).

→ Il tempo di esecuzione si è letteralmente dimezzato!

Programma minelapsed

avgelapsed

maxelapsed

stddevelapsed

somma_thread(un thread)

5.310s 5.327s 5.350s 0.007s

somma_thread(due thread)

2.734s 2.743s 2.760s 0.006s

62

Scalabilità(Analisi dei tempi di esecuzione al variare del numero di thread)

Come scalano le prestazioni al variare del numero di thread? In altre parole, fino a quale numero di thread i tempi di esecuzione continuano a dimezzarsi?Si può provare a stimarlo calcolando la somma dei primi 231-1 interi con la versione parallela con 1, 2, 4, 8, ... thread.

/usr/bin/time ./somma_thread 1 31/usr/bin/time ./somma_thread 2 31/usr/bin/time ./somma_thread 4 31...

63

Il risultato del confronto(Scalabilità per 1, 2, 4, 8, 16 thread)

Sulla macchina del docente si ottengono i seguenti risultati (elapsed time).

Programma minelapsed

avgelapsed

maxelapsed

stddevelapsed

somma_thread (1 thread) 5.310s 5.327s 5.350s 0.007s

somma_thread (2 thread) 2.734s 2.743s 2.760s 0.006ssomma_thread (4 thread) 1.390s 1.402s 1.450s 0.012s

somma_thread (8 thread) 0.950s 0.969s 1.000s 0.014ssomma_thread (16 thread)

0.940s 0.962s 1.000s 0.015s

64

Un grafico(Vale sempre più di e6.907755279 parole)

Tempo mediodi esecuzione (s)

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16Numerodi thread

1

2

3

4

5

Fino ad otto threadi tempi di esecuzionediminuiscono, poichéogni thread eseguedistintamente su unaCPU diversa.

A partire da nove threadle prestazioni rimangonole stesse, poiché i threadsi contendono l'uso delleCPU.

65

SINCRONIZZAZIONE BASATASULLA MUTUA ESCLUSIONE

66

Problema(Due o più thread vogliono accedere ad una variabile in maniera sicura)

Scenario: due o più thread accedono ad una variabile in lettura e/o scrittura.Problema: se l'accesso non viene arbitrato dal SO, l'ordine di schedulazione decide l'ordine di accesso alla variabile.

→ Il programma si comporta in maniera non deterministica.

67

Un esempio concreto(Il programma shared_counter.c)

Il programma di esempio shared_counter.c vede un processo padre generare due thread.I due thread effettuano le seguenti operazioni in maniera concorrente:

Incremento di un contatore condiviso per 1M volte.Decremento di un contatore condiviso per 1M volte.

Il valore iniziale del contatore condiviso è 0. Al termine dell'esecuzione ci si aspetterebbe di vedere di nuovo il valore 0 nel contatore, giusto?

68

Prova di esecuzione(Sob... Sigh.)

Si mandi in esecuzione shared_counter (anche tante volte, per capire cosa sta succedendo):while true; do ./shared_counter; doneIl programma sembra stampare valori casuali. Il contatore non vale mai 0!

69

Orrore!(Threads gone crazy)

70

Alcune domande legittime e pertinenti(Provocate dallo smarrimento)

Perché il programma shared_counter non funziona come atteso?Perché stampa numeri casuali?Che cosa è successo esattamente?Come si può prevenire questo problema?

71

La radice di tutti i mali(shared_counter è una variabile globale condivisa fra thread)

La variabile shared_counter è condivisa tra i due thread.Pertanto, i due thread possono accedervi in lettura o scrittura simultaneamente.

72

Incremento e decremento(Visti in codice macchina Si intuisce il problema)→

Si esamini, a puro titolo di esempio, la variabile shared_counter.Che microcodice esegue la CPU in seguito a shared_counter++ e shared_counter--?

shared_counter++è tradotta inregister1 = shared_counter;register1 = register1 + 1;shared_counter = register1;

shared_counter--è tradotta inregister2 = shared_counter;register2 = register2 - 1;shared_counter = register2;

73

Alcune sfortunate circostanze(Che rendono possibile il malfunzionamento)

registro1 e registro2 possono essere lo stesso registro.

Ad esempio, un registro accumulatore (EAX su x86).Il SO esegue uno scheduler della CPU con prelazione, che è in grado di interrompere potenzialmente ad ogni istante tracce di processi e thread.

Obiettivo: eseguire tracce di task in manierainterlacciata e concorrente.

74

Le conseguenze disastrose(Su sistemi single-processor)

il codice che esegue shared_counter++ può interrompersi “a metà” (ad es., arriva una interruzione).Lo scheduler dei processi decide di riesumare la tracciache esegue shared_counter--.Lo statement shared_counter-- opera su un registro il cui valore è inconsistente con il risultato dell'operazione counter++ (che non è ancora terminata).

→ Non determinismo sul valore di shared_counter.OCCHIO: ciò si può verificare sempre, anche quandoregister1 e register2 sono diversi.

75

Le conseguenze disastrose(Su sistemi multi-processor)

Le tracce possono eseguire su più processori fisiciconcorrentemente.L'ordine di accesso al registro ne determina il valorefinale.Il risultato delle operazioni su shared_counter dipende dall'ordine di schedulazione di thread e processi.

→ Non determinismo sul valore di shared_counter.

76

Un ordine di esecuzione corretto(Uso di due registri separati)

register1 = shared_counterregister1 = register1 + 1shared_counter = register1

register2 = shared_counterregister2 = register2 - 1shared_counter = register2

t0t1t2t3t4t5

Lo scheduler della CPUdecide di rischedulare unaltro processo.

Questa sequenza di istruzioni macchinaproduce il risultato corretto.

5 5

566 6

6 6

65

5 5

Traccia Traccia

77

Un ordine di esecuzione non corretto(Uso di due registri separati)

register1 = shared_counterregister1 = register1 + 1

shared_counter = register2

t0t1t2t3t4t5

Lo scheduler della CPUdecide di rischedulare unaltro processo.

Questa sequenza di istruzioni macchinaNON produce il risultato corretto.

5 5

56

register2 = shared_counterregister2 = register2 - 1

Traccia Traccia

shared_counter = register1

5 5

4 5

6 6

4 4

78

Corsa critica(Traccia di codice la cui esecuzione dipende dalla schedulazione)

È stato appena mostrato che l'ordine di schedulazione di processi e thread impatta pesantemente sul risultato delle operazioni su una variabile condivisa.Quando il valore finale di una elaborazione su una variabile condivisa dipende dall'ordine di esecuzione delle istruzioni che la modificano, si è in presenza di una corsa critica (race condition, race).

79

Volendo usare un'immagine...(… questa calzerebbe a pennello!)

80

Sezione critica(La porzione di codice che accede ai dati condivisi)

Siano dati n task (processi o thread) T0, T1, ..., Tn-1:eseguiti concorrentemente.cooperanti tramite dati condivisi.

Ciascun Tj ha una porzione di codice che accede ai dati condivisi.

Tale porzione prende il nome di sezione critica.La sezione critica deve essere eseguita da al più unprocesso alla volta.

81

Corsa critica: approcci al problema(Accesso in mutua esclusione, esecuzione atomica)

Per risolvere questo problema, è necessario che solo un processo alla volta acceda a shared_counter. Esistono due approcci (radicalmente diversi).Accesso in mutua esclusione.

Si bloccano tutti i tentativi di accesso ashared_counter se quest'ultima è già in uso.

Esecuzione atomica.Si fa in modo che il codice macchina relativo ashared_counter++ o a shared_counter-- siaeseguito integralmente oppure oppure per nulla.

82

Corsa critica: approcci al problema(Accesso in mutua esclusione, esecuzione atomica)

In questa presentazione, ci si occuperà inizialmente dell'accesso in mutua esclusione.Successivamente sarà introdotto il meccanismo di esecuzione atomica.Infine, si cercherà di combinare i due meccanismi per fornire una soluzione più generale al problema.

83

Protezione delle sezioni critiche(Il codice è suddiviso in diverse parti)

La sincronizzazione degli accessi in mutua esclusione avviene attraverso un protocollo di sincronizzazione caratterizzato dalle seguenti fasi.

Sezione di ingresso: il task chiede il permesso dientrare nella sezione critica.Sezione critica: accesso ai dati condivisi.Sezione di uscita: rilascio dell'uso della sezionecritica.Sezione non critica: codice la cui esecuzione nonrichiede un meccanismo di mutua esclusione.

84

Protezione delle sezioni critiche(Un accesso in mutua esclusione è effettuato in questo modo)

Il protocollo di sincronizzazione è implementato attraverso le seguenti istruzionido {

<sezione di ingresso><sezione critica><sezione di uscita><sezione non critica>

} while (TRUE);Il ciclo do {} while(TRUE); è puramente “accademico” (ciò che conta sono gli statement all'interno del blocco).

85

Alcune osservazioni(Caveat emptor!)

Ciò che si protegge è il dato condiviso.Il dato condiviso si protegge facendo eseguire la sezione critica che lo modifica da un solo processo.Non si “protegge il codice”! Il codice è un'area dati “read-only” che non sarà mai modificata.Non ha alcun senso dare accesso mutuamente esclusivo ad una porzione di codice che non accede a dati condivisi.

86

Requisiti di una soluzione al problema(Mutua esclusione)

Ogni soluzione al problema della sezione critica deve soddisfare tre requisiti.Mutua esclusione: se Tj è in sezione critica, Ti non può essere in sezione critica (i ≠ j).Si garantisce che la struttura non sia letta e scritta simultaneamente.

Origine del non determinismo.

87

Requisiti di una soluzione al problema(Progresso)

Ogni soluzione al problema della sezione critica deve soddisfare tre requisiti.Progresso: se nessun task esegue la sezione critica, e se alcuni task vogliono eseguire la sezione critica, allora solo i task che non eseguono le sezioni non critiche possono decidere l'ammissione in sezione critica.

88

“Guardi, guardi, lo vede il dito?”(“Lo vede che stuzzica, e prematura anche?”)

89

“Progresso”?(Eh?)

La condizione di progresso mira ad evitare che un task appena uscito da una sezione critica se la riprenoti immediatamente.

→ Monopolizzando, di fatto, il processore. → Gli altri task non possono avanzare. → Starvation degli altri processi!

90

L'effetto del “non progresso”(Un esempio con pseudocodice)

do {

<sezione di ingresso> <sezione critica> <sezione di uscita> <sezione non critica>} while (TRUE);

do {

<sezione di ingresso> <sezione critica> <sezione di uscita> <sezione non critica>} while (TRUE);

T0

T1

T0 vuole entrare in sezione critica ed attende l'uscita diT1.

T1 si riassegna la risorsa subito dopo il rilascio. Con elevata probabilità, T1 monopolizza la risorsa stessa.

91

Requisiti di una soluzione al problema(Attesa limitata)

Ogni soluzione al problema della sezione critica deve soddisfare tre requisiti.Attesa limitata: se un task è in attesa di ingresso nella sezione critica, altri task possono eseguire la sezione critica al più k volte (k finito).

Si evitano le situazioni di stallo.Si limita la unfairness (assegnazione non equa dellarisorsa) fra task.In caso contrario, potrebbe insorgere un fenomeno distarvation.

92

ALGORITMO DI PETERSON

93

A cosa serve(Ad accedere in mutua esclusione ad una variabile condivisa)

Soluzione software per garantire accesso in mutua esclusione ad una sezione critica.Permette a 2 task di accedere ad una risorsa senza conflitti.La risorsa in questione è una porzione di memoria condivisa.Può essere esteso al caso di n task (non interessa come, in questa introduzione di base).

94

Funzionamento(Complicato)

Due task T0 e T1 si alternano nella loro esecuzione, condividendo due informazioni.

Una variabile intera turn, contenente l'indice deltask abilitato ad entrare in sezione critica.Un array di boolean flag[2] che, per ciascuntask, vale TRUE se tale task è pronto perentrare in sezione critica.

95

Pseudocodice dell'algoritmo(Con le varie sezioni evidenziate)

La soluzione di Peterson è rappresentata dall'algoritmo seguente (processo Ti).do {

flag[i] = TRUE;turn = 1-i;while (flag[1-i] && turn==1-i);<sezione critica>flag[i] = FALSE;

} while (TRUE);

Sezione diingresso

Sezionedi uscita

96

Il significato degli statement(Riga per riga)

do {flag[i] = TRUE;turn = 1-i;while (flag[1-i] && turn==1-i);<sezione critica>flag[i] = FALSE;

} while (TRUE);Ti si dichiara pronto adentrare in sezione critica.

97

Il significato degli statement(Riga per riga)

do {flag[i] = TRUE;turn = 1-i;while (flag[1-i] && turn==1-i);<sezione critica>flag[i] = FALSE;

} while (TRUE);Ti lascia passare avantiT1-i.

98

Il significato degli statement(Riga per riga)

do {flag[i] = TRUE;turn = 1-i;while (flag[1-i] && turn==1-i);<sezione critica>flag[i] = FALSE;

} while (TRUE);

Ti attende di poter entrarein sezione critica poiché vi è giàentrato T1-i. T1-i rimane nellasezione critica fintantoché sonovalide tutte e due le condizionisottolineate. Non appena se neinvalida una, Ti entra.

99

Il significato degli statement(Riga per riga)

do {flag[i] = TRUE;turn = 1-i;while (flag[1-i] && turn==1-i);<sezione critica>flag[i] = FALSE;

} while (TRUE);Se flag[1-i] = FALSE T1-iha appena eseguito questostatement (sezione di uscita).

→ T1-i ha appena rilasciato unarisorsa, che è ora libera.

100

Il significato degli statement(Riga per riga)

do {flag[i] = TRUE;turn = 1-i;while (flag[1-i] && turn==1-i);<sezione critica>flag[i] = FALSE;

} while (TRUE);

Se turn=i T1-i ha appenaeseguito questo statement(sezione di ingresso).

→ T1-i lascia passare Ti pergarantirgli il progresso.

101

Il significato degli statement(Riga per riga)

do {flag[i] = TRUE;turn = 1-i;while (flag[1-i] && turn==1-i);<sezione critica>flag[i] = FALSE;

} while (TRUE);Ti entra in sezione critica.

102

Il significato degli statement(Riga per riga)

do {flag[i] = TRUE;turn = 1-i;while (flag[1-i] && turn==1-i);<sezione critica>flag[i] = FALSE;

} while (TRUE);Ti esce dalla sezione critica.Si segnala tale evento a T1-iimpostando flag[i] = FALSE.

103

Sono verificati i requisiti?(Mutua esclusione)

Requisito 1: mutua esclusione.Ti entra nella sua sezione critica solo se fallisce ilwhile, ossia flag[1-i]==FALSE o turn==i.Se T0 e T1 fossero in sezione critica nello stessoistante, si avrebbe simultaneamente:

flag[0]==TRUE, flag[1]==TRUEPertanto, il while fallisce su turn==1-i.Nello specifico, in T0: turn=0, mentre in T1: turn=1.Tuttavia turn è condivisa e non può assumere duevalori distinti contemporaneamente assurdo.→

104

Sono verificati i requisiti?(Progresso)

Requisito 2: progresso.Si supponga che T0 e T1 non stiano usando la risorsa.T0 è in procinto di usarla, T1 l'ha appena usata.C'è progresso se l'algoritmo sceglie T0.

105

Sono verificati i requisiti?(Progresso)

Requisito 2: progresso.T1 è appena uscito dalla sezione critica ed impostaflag[1] == FALSE.T1 ricomincia il ciclo do {} while();, dichiara T0come prossimo task avente diritto ad usufruire dellarisorsa (turn=0) e ne aspetta il rilascio.Prima o poi, T0 raggiunge il while() dellasezione di ingresso, che sicuramente fallisce suturn==0; pertanto, T0 entra in sezione critica.T0 e T1 si alternano nell'uso della risorsa Progresso.→

106

Sono verificati i requisiti?(Attesa limitata)

Requisito 3: attesa limitata.La dimostrazione precedente (requisito 2) ha mostratocome T1 passi il testimone a T0 dopo aver utilizzato larisorsa condivisa.Analogamente si può mostrare come T0 passi il testimonea T1 dopo aver utilizzato la risorsa condivisa.In altre parole, T0 e T1 si alternano nell'esecuzione dellasezione critica.Se un task è in attesa di entrare in sezione critica,l'altro task può eseguire la sezione critica al più k=1volte.

107

Vantaggi e svantaggi(Indipendente dall'hw, ma soggetta alle bizze del compilatore)

Vantaggi.Non richiede hardware di supporto per poterfunzionare.Generalizzabile a n processi.

Svantaggi.Se la CPU effettua il reordering delle istruzioni,flag[i] e turn rischiano di essere impostati conuna temporizzazione sballata.Se il compilatore ottimizza i cicli, rischia di trasformareil while() in un while(TRUE), perché per lui lacondizione non è mai falsa.

108

Un esempio concreto(Ottimizzazione del compilatore)

Il programma di esempio optimized_away.c in 12-esempi.tar.bz2 illustra un esempio di ottimizzazione letale in un contesto multi-threaded.Un programma definisce una variabile a 0 e poi controlla per sempre che il suo valore sia diverso da 255.

109

Compilazione senza ottimizzazioni(È presente il controllo della condizione)

Se si assembla il programma senza ottimizzazioni e lo si legge, si nota la condizione.gcc -O0 -S optimized_away optimized_away.cvim optimized_away.s....L2:

cmpl $255, -4(%rbp)jne .L2

110

Compilazione con ottimizzazioni(Non è più presente il controllo della condizione)

Se si assembla il programma con ottimizzazioni e lo si legge, la condizione è sparita.gcc -O3 -S optimized_away optimized_away.cvim optimized_away.s....L2:

jmp .L2

111

Supporto hardware(Istruzioni atomiche)

Nei moderni SO, le sezioni di ingresso e di uscita sono implementate tramite un modello universale: il lock (serratura, lucchetto).Si protegge una sezione critica che accede a dati condivisi con

una variabile intera (lock) che indica se la risorsa èlibera oppure occupata.un meccanismo che controlla lo stato della variabileintera e permette l'accesso esclusivo.

112

Modello di locking/unlocking(Molto più semplice rispetto all'algoritmo di Peterson)

Il meccanismo generale di protezione delle sezioni critiche tramite lock si presenta nel formato seguente.do {

<acquisizione lock><sezione critica><rilascio lock><sezione non critica>

} while (TRUE);

113

Supporto hardware al locking(Esecuzione atomica)

Molte architetture offrono particolari istruzioni che permettono di leggere e scrivere il contenuto della memoria in modalità atomica.

Esecuzione atomica: esecuzione non interrompibile(tutto o niente).La tipica operazione atomica è un controllo di unavariabile con contestuale modifica.

Le operazioni di locking sono spesso implementate tramite istruzioni atomiche.

114

Istruzione TestAndSet(Imposta il valore di una variabile e ritorna il valore precedente)

L'istruzione macchina TestAndSet effettua le seguenti operazioni atomicamente.

Ritorna il vecchio valore di una variabile.Imposta la variabile al valore TRUE (1).

boolean TestAndSet (boolean *obiettivo) {boolean valore = *obiettivo;*obiettivo = TRUE;return valore;

}

115

Locking/Unlocking con TestAndSet(Molto semplice)

Si usa una variabile intera blocco (inizializzata a FALSE) per contenere l'informazione “risorsa libera o occupata”.

blocco=FALSE: la risorsa è libera.blocco=TRUE: la risorsa è occupata.

Il protocollo di sincronizzazione è il seguente.do {

while (TestAndSet(&blocco));<sezione critica>blocco = FALSE;

} while (TRUE);

116

TestAndSet: perché funziona?(Nelle slide seguenti viene mostrato)

In teoria, si potrebbero dimostrare i tre requisiti mutua esclusione, progresso e attesa limitata.

Viene lasciato come utile esercizio.Nelle slide seguenti, viene mostrata una sequenza temporale di eventi che spiega il funzionamento del protocollo.

117

TestAndSet: perché funziona?(Andamento temporale della variabile blocco)

Quando il primo task esce dalla sezione critica, rilascia il lock ponendo blocco=FALSE.Il secondo task (che sta tentando di ottenere il lock) esegue, in uno degli istanti successivi, l'n-ma iterazione di:while(TestAndSet(&blocco));

blocco=TRUE e viene ritornato il valore precedente,che ora è FALSE.Il ciclo while() fallisce ed il secondo processo entrain sezione critica.

118

TestAndSet: la sequenza temporale(Esplicitata)

blocco = FALSE;while (TestAndSet(&blocco));<sezione critica>

while (TestAndSet(&blocco));

t0t1t2t3

t4t5

Imposta blocco = TRUE eritorna FALSE. Entra subitoin sezione critica.

F

T0 (CPU #0) T1 (CPU #1)

Cicla sul while() fino a quandoT0 non imposta blocco=FALSE.

T

T

blocco = FALSE;F

<sezione critica>

Imposta blocco = TRUEe ritorna TRUE.

TestAndSet() impostablocco = TRUE e ritornaFALSE.

119

TestAndSet: la sequenza temporale(Grafico di blocco in funzione del tempo)blocco

t

TRUE

FALSE

blocco=FALSE

T0 acquisisceil lock

T0 rilasciail lock

T1 acquisisceil lock

T1 prova adacquisire illock

...

t4 t5t1

120

Istruzioni hw e mutua esclusione(Non è soddisfatto il requisito di attesa limitata.)

TestAndSet non soddisfa, in generale, il criterio di attesa limitata.TestAndSet va integrata con un supporto (algoritmo) per permettere l'attesa limitata.

121

Limitazioni degli approcci visti finora(Serve un meccanismo generale di locking/unlocking)

L'implementazione delle sezioni di ingresso e di uscita può essere complicata, ed è in generale molto scomoda da inserire direttamente in una applicazione.Il compilatore può ottimizzare il codice, di fatto producendo eseguibili che non funzionano in ambiente multi-threaded.L'attesa di una risorsa può non essere limitata.

→ Serve un meccanismo di locking/unlocking più generale in grado di risolvere questi problemi.

122

Semaforo(Un po' di storia)

Costrutto di sincronizzazione ideato da Edsger W. Dijkstra nel 1965 presso l'Eindhoven University of Technology.Manoscritto originale: EWD123.

http://www.cs.utexas.edu/users/EWD/ewd01xx/EWD123.PDF

123

Semaforo(Che cosa è)

Il semaforo è una variabile intera S che conta il numero di istanze di una risorsa condivisa ancora disponibili agli utenti.La variabile S viene acceduta solo tramite tre funzioni.

init(S): inizializza S ad un valore > 0.wait(S): sezione di accesso (originariamentechiamata P(), dall'olandese proberen, ossia “testare”).signal(S): sezione di uscita (originariamentechiamata V(), dall'olandese verhogen, ossia“incrementare”).

124

Locking/Unlocking con i semafori(Incredibilmente semplice)

Il protocollo di sincronizzazione con i semafori è il seguente.init(S);do {

wait(S);<sezione critica>signal(S);

} while (TRUE);

125

Implementazione di wait() e signal()(Aggiornano il contatore)

Lo scheletro implementativo delle funzioni wait() e signal() è il seguente.wait(S) {

while(S <= 0) {<attesa>

}S--;

}

signal(S) {S++;

}

126

Semafori contatore e binari(Binario mutua esclusione; contatore sincronizzazione)→ →

Semafori contatore.S è inizializzata ad un valore > 1.Rappresenta una risorsa con molteplici istanze.

Semafori binari.S è inizializzata ad 1 (caso comune).La risorsa è presente in una istanza; o la si usa, oppurenon la si usa (mutua esclusione).Viene anche detto mutex (MUTual Exclusion).

127

Attesa attiva(La CPU continua a controllare il cambio di valore di S)

L'implementazione ora vista è ad attesa attiva, ossia l'attesa implementata nella wait() è di tipo attivo (busy waiting).

Un ciclo while che esegue fino a quando non se nevanifica la condizione.Dal punto di vista funzionale, somiglia in maniera sinistraai meccanismi di locking con istruzioni hardware.

Un semaforo attivo (o un lock implementato con istruzioni hardware) prende il nome di spinlock.

La CPU continua a ciclare (spin) sul while, bloccando(lock) l'accesso.

Esiste un modello di semaforo “passivo”?

128

Semafori attivi e passivi(Attesa passiva il processo è sospeso)→

Nell'implementazione ad attesa passiva, l'attesa implementata nella wait() è di tipo passivo.il processo viene messo in attesa (sleep) che si verifichi l'evento che consente l'accesso alla sezione critica.Quando tale evento si verifica, il processo è risvegliato ed inserito in coda di pronto.

129

Quando si usa lo spinlock?(Attese brevi, tipicamente per strutture dati in memoria)

Ideale per attese brevi, dell'ordine dei ns (accesso in mutua esclusione a strutture dati in memoria).

Bloccare e ripristinare il processo introdurrebbe unritardo di alcuni secondi (1000 volte più lento).

Disastroso per attese lunghe.L'attesa “brucia” il processore, surriscaldandolo eimpedendo ad altri processi di avanzare (tragico per lalatenza).

130

Quando si usa il semaforo passivo?(Attese lunghe, tipicamente per operazioni su dispositivi)

Ideale per attese lunghe, dai ms in su.Si blocca un processo che altrimenti impallerebbe lamacchina.

Disastroso per attese brevi.La sospensione ed il ripristino del processointroducono un ritardo inaccettabile.

131

Semaforo passivo: definizione(Una variabile più una coda di processi)

Nel caso di attesa passiva, il SO implementa il semaforo con una struttura dati contenente:

la singola variabile intera S, che conta il numero di slotassociati alla risorsa.una lista di task che attendono lo sblocco dellarisorsa.

typedef struct {int valore;struct task *p;

} semaforo;

Puntatore alla lista dei task in attesa di entrare nella

sezione critica.

132

Semaforo passivo: wait()(Accoda un processo se la risorsa è occupata)

La funzione wait(S) va estesa con un meccanismo di accodamento dei processi in caso di blocco.

void wait(semaforo S) {S.valore--;if (S.valore < 0) {

<accoda processo a S.p>block();

}}

Mette il processoin stato di attesa.

133

Semaforo passivo: signal()(Sveglia un processo se la risorsa è libera)

La funzione signal(S) va estesa con dei meccanismi per il prelievo di un processo dalla coda di attesa del semaforo e per l'inserimento del processo nella coda di pronto.void signal(semaforo S) {

S.valore++;if (S.valore <= 0) {

<estrai un processo P da S.p>wakeup(P);

}}

Mette il processoin stato di pronto.

134

Dettagli implementativi(Da non trascurare)

Le funzioni interne block() e wakeup() devono essere fornite dal kernel.Il criterio di estrazione usato nella wakeup() è, generalmente, FIFO, ma può non essere il solo.L'esecuzione di wait() e signal() è atomica.

wait() e signal() sono considerate come vere eproprie sezioni critiche.

135

Criteri di estrazione LIFO e FIFO(Di solito si adotta il criterio FIFO)

In generale, si evita il criterio LIFO (stack) nella gestione della coda dei processi in attesa della sezione critica.Il criterio LIFO favorisce gli ultimi processi e non i primi.

→ Attesa indefinita dei processi più vecchi(starvation).Si adotta il criterio FIFO (coda).

136

Mutex in GNU/Linux(pthread_mutex_t)

In questa introduzione si presentano i mutex per i thread.La libreria Pthreads definisce il tipo di dato opaco pthread_mutex_t per rappresentare un semaforo binario.Per definire un mutex è necessario dichiarare una variabile di tipo pthread_mutex_t.

pthread_mutex_t my_mutex;

137

Attributi di un mutex(pthread_mutexattr_t)

Proprio come per i thread, anche nei mutex è possibile configurare particolari proprietà tramite il tipo di dato opaco pthread_mutexattr_t.

Mutex normale, mutex ricorsivo, controllo degli erroriper ogni operazione, …

In questa introduzione si considerano solo mutex normali; pertanto, non è necessario impostare altre proprietà.

138

Inizializzazione di un mutex(Funzione di libreria pthread_mutex_init())

La funzione di libreria che inizializza i mutex si chiama pthread_mutex_init().Parametri in ingresso.

Il puntatore ad una struttura di tipopthread_mutex_t.Il puntatore ad una struttura dal nomepthreads_mutexattr_t rappresentante gliattributi del mutex (oppure NULL per quelli didefault).

Valore di ritorno: sempre 0.

139

Accesso in mutua esclusione(Funzione di libreria pthread_mutex_lock())

La funzione di libreria pthread_mutex_lock() ottiene accesso in mutua esclusione alla risorsa protetta da un pthread_mutex_t.Parametri in ingresso.

Il puntatore ad una struttura di tipo pthread_mutex_t.Valore di ritorno.

In caso di successo: 0.In caso di errore: un codice di errore (un numero positivo).

Per tutti i dettagli: man 3 pthread_mutex_lock.

140

Rilascio della risorsa(Funzione di libreria pthread_mutex_unlock())

La funzione di libreria pthread_mutex_unlock() rilascia il lock su una risorsa condivisa protetta da un pthread_mutex_t.Parametri in ingresso.

Il puntatore ad una struttura di tipo pthread_mutex_t.Valore di ritorno.

In caso di successo: 0.In caso di errore: un codice di errore (un numero positivo).

141

Distruzione di un mutex(Funzione di libreria pthread_mutex_destroy())

La funzione di libreria pthread_mutex_destroy() distrugge un mutex precedentemente inizializzato con la pthread_mutex_init().Parametri in ingresso.

Il puntatore ad una struttura di tipo pthread_mutex_t.Valore di ritorno.

In caso di successo: 0.In caso di errore: un codice di errore (un numero positivo).

142

Un esempio concreto(Il programma pthread_mutex)

Il sorgente pthread_mutex.c contenuto nell'archivio 12-esempi.tar.bz2 illustra l'uso basilare di un mutex.Un processo padre crea due thread che incrementano una variabile condivisa in maniera mutuamente esclusiva.

143

Esercizi (10 min.)

5. Si modifichi il sorgente di shared_counter.c in modo tale da accedere il contatore in mutua esclusione. Si gestiscano anche eventuali errori.

145

Esercizi (5 min.)

6. Si valuti l'impatto del mutex sui tempi di esecuzione del programma. Cosa si deduce?

147

Esercizi (15 min.)

7. L'attesa di pthread_mutex_lock() è attiva o passiva?

150

SINCRONIZZAZIONE BASATASU CONDIZIONE LOGICA

151

Una limitazione dei mutex(Il mutex non è in grado di segnalare una specifica condizione logica)

Negli esempi visti finora, un mutex è in grado di segnalare un solo tipo di condizione: il rilascio di una risorsa.Un mutex (o semaforo contatore) non è in grado di segnalare una generica condizione ad altri task.

→ Serve un meccanismo più generale di attesa e segnalazione di eventi (associati a condizioni logiche).

152

Un esempio concreto(Il problema produttore-consumatore)

Il problema produttore-consumatore illustra molto bene le limitazioni dei mutex negli scenari di sincronizzazione più complessi.Un processo padre crea N thread produttori ed M thread consumatori (N, M ≥1).Il thread produttore produce elementi e li inserisce in un array; si blocca se l'array è pieno.Il thread consumatore estrae elementi dall'array e li consuma; si blocca se l'array è vuoto.L'array è condiviso tra produttori e consumatori.

153

Produzione e consumo(Possono essere banali; non sono loro il vero scopo dell'esercizio)

Produttore e consumatore possono essere funzioni banali.Produttore: generazione continua di un intero.Consumatore: stampa continua di un intero.Il focus dell'esercizio non è sulle funzioni di produzione e di consumo.Il focus dell'esercizio è sulle tecniche di sincronizzazione fra thread.

154

Gestione dell'array(Due indici alla prima posizione vuota e piena; un contatore di elementi)

counter: conta ilnumero di elementinell'array

in: punta allaprima posizione nonlibera dell'array

out: punta allaprima posizionelibera dell'array

Array circolare di N posizioni.Array vuoto: counter = 0Array pieno: counter = N

155

Pseudocodice(Molto pseudo e poco codice)

Produttorevoid producer(void) {

item p;

while(TRUE) {

p = produce_item();

lock(lock);

while(counter == N)

/* do nothing */;

buffer[in] = p;

in = (in + 1) % N;

counter++;

unlock(lock);

}

}

Consumatorevoid consumer(void) {

item c;

while(TRUE) {

while(counter == 0)

/* do nothing */;

lock(lock);

c = buffer[out];

out = (out + 1) % N;

counter--;

consume_item(c);

unlock(lock);

}

}

Lock lock;

156

Una possibile implementazione(Che usa solo i mutex: producer_consumer_mutex.c)

Il sorgente producer_consumer_mutex.c in 12-esempi.tar.bz2 è una possibile soluzione al problema produttore-consumatore.Tale soluzione usa:

un mutex per l'accesso in mutua esclusione aicontatori.un'attesa attiva di riempimento o svuotamento array.

Si esegua l'applicazione:./producer_consumer_mutex

157

Una osservazione(Non c'è stretta alternanza fra produttore e consumatore)

Osservando l'output del programma, si nota come un produttore inserisca diversi elementi nell'array prima di vederseli tutti consumati dal consumatore.In altre parole, non sembra esserci stretta alternanza fra produttore e consumatore.Ovvero, l'attesa di un thread non è, in generale, limitata al minimo indispensabile.Perché?

158

Due spiegazioni plausibili(Aggravio di sincronizzazione delle cache CPU, timeslice di scheduling alta)

1. La variabile counter è condivisa. Ad ogni accesso le cache delle CPU devono controllarne la coerenza del valore e, nel caso, sincronizzarlo. → counter può essere molto lenta da accedere

(ad es. nel ciclo while()).

159

Due spiegazioni plausibili(Aggravio di sincronizzazione delle cache CPU, timeslice di scheduling alta)

2. Lo scheduler dei processi assegna una fetta temporale di esecuzione non indifferente (fino a 20ms) ad ogni thread. Se un altro thread sta leggendo il valore di counter, può rimanere stallato per via della sincronizzazione delle cache L1 e L2. Il primo thread ha pertanto mano libera per un bel po' di cicli.

160

Cosa serve, allora?(Bisogna ridurre l'attesa dei thread)

Occorre un meccanismo per forzare una più stretta alternanza fra thread.Un semplice mutex non è in grado di garantirci questo; lui ci può garantire solo mutua esclusione.

161

Un'idea(Segnalare gli eventi “si è svuotato un elemento” e “si è riempito un elemento”)

Se si potessero rappresentare i due eventi:“si è liberato un elemento dell'array”“si è riempito un elemento dell'array”

tramite due semafori saremmo a cavallo. Perché?

162

Flowchart del produttore(Attende un elemento vuoto, produce, inserisce, segnala un elemento pieno)

Il produttore potrebbe operare così.Attende l'evento “elemento array vuoto”.Produce un elemento.Inserisce in mutua esclusione l'elementonell'array.Segnala l'evento “elemento array pieno”.

163

Flowchart del consumatore(Attende un elemento pieno, estrae, consuma, segnala un elemento vuoto)

Il consumatore potrebbe operare così.Attende l'evento “elemento array pieno”.Estrae in mutua esclusione l'elementodall'array.Consuma l'elemento.Segnala l'evento “elemento array vuoto”.

164

Semafori contatore(La salvezza!)

I semafori contatore rappresentano l'estensione dei mutex al caso di risorse con n istanze disponibili (n>1).Essi possono essere usati per segnalare condizioni del tipo:

“un array è vuoto” un task può produrre dati ed→inserirli.“un array è pieno” un task blocca la produzione di→dati in attesa dello svuotamento dell'array.

165

Semafori contatore in GNU/Linux(Due tipologie: SysV e POSIX)

La libreria Pthreads non offre alcun supporto per i semafori contatore.Bisogna ricorrere ai semafori contatore forniti dalla libreria del C a processi e thread.Esistono due categorie di semafori contatore:

POSIX.SysV.

In questa introduzione si useranno i semafori di tipo POSIX, poiché molto più semplici da usare.La semantica d'uso è pressoché identica a quelladei mutex.

166

Semafori POSIX(Possono essere di due tipi: con nome e senza nome)

I semafori POSIX sono di due tipi: con nome (named) e senza nome (unnamed).Un semaforo con nome è associato ad una stringa ed è facilmente visibile tra processi e thread distinti.Un semaforo senza nome non ha associata alcuna stringa. È facilmente visibile solo fra thread di uno stesso processo.In questa introduzione si useranno semafori POSIX senza nome.

167

Semaforo POSIX senza nome(pthread_mutex_t)

La libreria del C definisce il tipo di dato opaco sem_t per rappresentare un semaforo.

Definito in <semaphore.h>.Per definire un semaforo è necessario dichiarare una variabile di tipo sem_t.

sem_t my_sem;

168

Inizializzazione di un semaforo(Funzione di libreria sem_init())

La funzione di libreria che inizializza i semafori si chiama sem_init().Parametri in ingresso.

Il puntatore ad una struttura di tipo sem_t.Un flag che indica se il semaforo è condiviso fra thread (0)o processi (!=0).Il valore iniziale del semaforo (>1 per un semaforocontatore).

Valore di ritorno.In caso di successo: 0.In caso di errore: -1: (il codice di errore è in errno).

169

Accesso in mutua esclusione(Funzione di libreria sem_wait())

La funzione di libreria sem_wait() implementa la logica di attesa dei semafori.

Decrementa il contatore del semaforo.Se contatore > 0, ritorna senza bloccarsi.Se contatore = 0, si blocca in attesa di contatore > 0.

Parametri in ingresso.Il puntatore ad una struttura di tipo sem_t.

Valore di ritorno.In caso di successo: 0.In caso di errore: -1: (il codice di errore è in errno).

Per tutti i dettagli: man 3 sem_wait.

170

Rilascio della risorsa(Funzione di libreria sem_post())

La funzione di libreria sem_post() implementa la logica di sblocco dei semafori.

Incrementa il contatore del semaforo.Se contatore > 0, si risveglia un task in attesa sul semaforo.

Parametri in ingresso.Il puntatore ad una struttura di tipo sem_t.

Valore di ritorno.In caso di successo: 0.In caso di errore: -1: (il codice di errore è in errno).

Per tutti i dettagli: man 3 sem_post.

171

Distruzione di un semaforo(Funzione di libreria sem_destroy())

La funzione di libreria sem_destroy() distrugge un semaforo precedentemente inizializzato con la sem_init().Parametri in ingresso.

Il puntatore ad una struttura di tipo sem_t.Valore di ritorno.

In caso di successo: 0.In caso di errore: -1: (il codice di errore è in errno).

Per tutti i dettagli: man 3 sem_post.

172

Integrazione dei semafori contatore(Nel programma che risolve il problema produttore-consumatore)

Si definiscono due nuovi semafori contatore:full (conta le istanze della risorsa “elementi pieninell'array”).empty (conta le istanze della risorsa “elementi liberinell'array”).

Si modificano le procedure di lock/unlock in modo tale da:

garantire mutua esclusione negli accessi alle variabilicondivise.garantire alternanza fra produttori e consumatori.

173

Lock e unlock(Sono implementati così)

Produttorevoid producer(void) {

...

while(TRUE) {

...

wait(empty);

lock(lock);

...

unlock(lock);

signal(full);

}

}

Consumatorevoid consumer(void) {

...

while(TRUE) {

...

wait(full);

lock(lock);

...

unlock(lock);

signal(empty);

}

}

Lock lock;Sem empty;Sem full;

174

Lock e unlock(Sono implementati così)

Produttorevoid producer(void) {

...

while(TRUE) {

...

wait(empty);

lock(lock);

...

unlock(lock);

signal(full);

}

}

Consumatorevoid consumer(void) {

...

while(TRUE) {

...

wait(full);

lock(lock);

...

unlock(lock);

signal(empty);

}

}

Lock lock;Sem empty;Sem full;

Attende un elementovuoto nell'array.Sostituisce l'attesa attivadel ciclo while(); conuna attesa passiva piùefficiente per la CPU.

175

Lock e unlock(Sono implementati così)

Produttorevoid producer(void) {

...

while(TRUE) {

...

wait(empty);

lock(lock);

...

unlock(lock);

signal(full);

}

}

Consumatorevoid consumer(void) {

...

while(TRUE) {

...

wait(full);

lock(lock);

...

unlock(lock);

signal(empty);

}

}

Lock lock;Sem empty;Sem full;

Accede in mutuaesclusione allevariabili condivise.

176

Lock e unlock(Sono implementati così)

Produttorevoid producer(void) {

...

while(TRUE) {

...

wait(empty);

lock(lock);

...

unlock(lock);

signal(full);

}

}

Consumatorevoid consumer(void) {

...

while(TRUE) {

...

wait(full);

lock(lock);

...

unlock(lock);

signal(empty);

}

}

Lock lock;Sem empty;Sem full;

Rilascia il locksulle variabilicondivise.

177

Lock e unlock(Sono implementati così)

Produttorevoid producer(void) {

...

while(TRUE) {

...

wait(empty);

lock(lock);

...

unlock(lock);

signal(full);

}

}

Consumatorevoid consumer(void) {

...

while(TRUE) {

...

wait(full);

lock(lock);

...

unlock(lock);

signal(empty);

}

}

Lock lock;Sem empty;Sem full;

Segnala il riempimentodi un elemento nell'array.

178

Lock e unlock(Sono implementati così)

Produttorevoid producer(void) {

...

while(TRUE) {

...

wait(empty);

lock(lock);

...

unlock(lock);

signal(full);

}

}

Consumatorevoid consumer(void) {

...

while(TRUE) {

...

wait(full);

lock(lock);

...

unlock(lock);

signal(empty);

}

}

Lock lock;Sem empty;Sem full;

Attende un elementopieno nell'array.Sostituisce l'attesa attivadel ciclo while(); conuna attesa passiva piùefficiente per la CPU.

179

Lock e unlock(Sono implementati così)

Produttorevoid producer(void) {

...

while(TRUE) {

...

wait(empty);

lock(lock);

...

unlock(lock);

signal(full);

}

}

Consumatorevoid consumer(void) {

...

while(TRUE) {

...

wait(full);

lock(lock);

...

unlock(lock);

signal(empty);

}

}

Lock lock;Sem empty;Sem full;

Accede in mutuaesclusione allevariabili condivise.

180

Lock e unlock(Sono implementati così)

Produttorevoid producer(void) {

...

while(TRUE) {

...

wait(empty);

lock(lock);

...

unlock(lock);

signal(full);

}

}

Consumatorevoid consumer(void) {

...

while(TRUE) {

...

wait(full);

lock(lock);

...

unlock(lock);

signal(empty);

}

}

Lock lock;Sem empty;Sem full;

Rilascia il locksulle variabilicondivise.

181

Lock e unlock(Sono implementati così)

Produttorevoid producer(void) {

...

while(TRUE) {

...

wait(empty);

lock(lock);

...

unlock(lock);

signal(full);

}

}

Consumatorevoid consumer(void) {

...

while(TRUE) {

...

wait(full);

lock(lock);

...

unlock(lock);

signal(empty);

}

}

Lock lock;Sem empty;Sem full;

Segnala lo svuotamentodi un elemento nell'array.

182

Lock e unlock(Sono implementati così)

Produttorevoid producer(void) {

...

while(TRUE) {

...

wait(empty);

lock(lock);

...

unlock(lock);

signal(full);

}

}

Consumatorevoid consumer(void) {

...

while(TRUE) {

...

wait(full);

lock(lock);

...

unlock(lock);

signal(empty);

}

}

Lock lock;Sem empty;Sem full;

E ricomincial'alternanza.

183

Esercizi (15 min.)8. Si implementi il protocollo di lock/unlock

appena proposto nel file sorgente producer_consumer_mutex.c. Si rinomini tale file in producer_consumer_sem.c. Si compili ed esegua il file. L'alternanza è migliore?

OCCHIO: si imposti la dimensione dell'array ad un valore più piccolo del numero di produttori. Altrimenti un produttore può sempre entrare e non vedete molta alternanza!

187

CENNI IMPLEMENTATIVI

188

Fast Userspace Mutex(https://lwn.net/Articles/360699/)

GNU/Linux fornisce un meccanismo univoco per l'implementazione di primitive di locking, noto con il nome di FUTEX (Fast Userspace muTEX).Un futex è una coda di attesa del kernel associata ad una variabile intera di una applicazione. Su tale coda sono definite due operazioni.

FUTEX_WAIT(addr, val): controlla se il valoresalvato in addr è val e blocca il task invocante seaddr==val.

FUTEX_WAKE(addr, val): sveglia val thread in seguito ad un cambio del valore salvato in addr.

189

Il valore della variabile intera(https://lwn.net/Articles/360699/)

I valori assunti dalla variabile intera hanno la semantica seguente.*addr = 0: risorsa non prenotata (lock libero).*addr = 1: risorsa prenotata (lock preso).

190

La chiamata di sistema futex()(Gestisce il futex)

La chiamata di sistema futex() gestisce un futex:

permette l'invocazione di FUTEX_WAIT().permette l'invocazione di FUTEX_WAKE().

futex() non ha un wrapper GLIBC, poiché è una chiamata di sistema concepita per essere di supporto a funzioni di libreria.$LINUX/kernel/futex.c

191

L'associazione lock futex↔(Avviene automaticamente in seguito alla FUTEX_WAIT())

L'associazione tra un lock ed il corrispettivo futex avviene automaticamente tramite l'invocazione della funzionalità FUTEX_WAIT().Nello specifico, il kernel crea dinamicamente la coda quando un task si blocca per la prima volta sull'indirizzo specificato.La coda viene distrutta quando tutti i task bloccati sull'indirizzo sono stati risvegliati con FUTEX_WAKE().

192

Locking/unlocking tramite futex(Si entra nel kernel solo se c'è contesa)

Le primitive di locking di GNU/Linux entrano in kernel mode (invocando futex()) solo se c'è contesa.Se la risorsa è libera, non si entra in kernel mode; è la libreria del C a far acquisire direttamente il lock al task.

→ Guadagno prestazionale non trascurabile.

193

Un esempio: pthread_mutex_lock()(Che cosa fa, a grandissime linee)

La funzione pthread_mutex_lock() è definita nel file seguente:$GLIBC/nptl/pthread_mutex_lock.cEssa implementa la sezione d'ingresso di un mutex per uso con i pthread.

In tutte le varianti possibili ed immaginabili: mutexricorsivi, adattativi, robusti, con controllo degli errori,con elisione dei lock, etc. etc. etc.

Nel caso di mutex normali, la funzione di libreria pthread_mutex_lock() invoca la macro LLL_MUTEX_LOCK(mutex).

194

LLL_MUTEX_LOCK()(Che cosa è veramente?)

LLL_MUTEX_LOCK() è un alias della funzione assembly lll_lock(), definita in:$GLIBC/nptl/sysdeps/unix/sysv/linux/x86_64/lowlevellock.hEssa invoca la giusta funzione di lavoro:

__lll_lock_wait_private(): per futexprivati ad un processo (disponibili solo per ithread, ma molto veloci).__lll_lock_wait(): per futex condivisibili fraprocessi (disponibili sempre, ma molto più lenti).

Consideriamo __lll_lock_wait().

195

Richiesta del lock(Un semplice swap di variabili)

lll_lock_wait() è una funzione assembly definita nel file seguente:$GLIBC/nptl/sysdeps/unix/sysv/linux/x86_64/lowlevellock.SEssa scambia il valore del lock attuale con un uno.

Intel x86_64: istruzione assembly xchgl.Il valore di ritorno è il valore precedentemente assunto dal lock.

Ricordate: 0 lock libero, 1 lock preso.→ →

196

Ottenimento del lock(Diretto se libero o tramite il kernel se occupato)

Se il valore precedente del lock era zero, il lock era libero. In questo caso non si entra nel kernel.Se il valore precedente del lock era diverso da zero (ad es., uno), il lock era già stato preso.In questo caso si invoca futex() con la richiesta di FUTEX_WAIT.

197

Esercizi (10 min.)

9. Si tracci l'esecuzione di shared_counter (la versione corretta con i mutex) nel modo più dettagliato possibile. Si individuino le funzioni di libreria e le chiamate di sistema coinvolte nelle procedure di lock/unlock.