Come abbiamo evidenziato la scorsa lezione per poter coordinare thread che condividono memoria sono necessari dei meccanismi di sincronizzazione che assicurino un’esecuzione “ordinata” dei thread in modo da preservare la coerenza dei dati.
La Sezione Critica è la parte di codice in cui i processi accedono a dati condivisi.
Consideriamo un generico thread T in cui sia presente una sezione critica:
thread T {
....
< ingresso >
< sezione critica >
< uscita >
....
}
vogliamo progettare il codice di ingresso e uscita dalla sezione critica in modo da garantire mutua esclusione ovvero che un solo un thread alla volta possa accedere alla sezione critica
Questa proprietà deve valere senza fare assunzioni sulla velocità relativa dei processi (temporizzazione), a prescindere quindi da come vengono schedulati.
Soluzioni software
Proviamo a realizzare la sezione critica con soluzioni puramente in software: senza l’ausilio di meccanismi hardware e chiamate a sistema.
Tentativo 1: lock
Utilizziamo una variabile booleana globale lock
inizializzata a false:
thread T {
....
while(lock) {}
lock = true;
< sezione critica >
lock = false;
....
}
Descrizione: il codice di ingresso attende ciclando “a vuoto” finché il lock è true. Quando il lock viene rilasciato esce dal while
e acquisisce il lock, ponendolo a true. Alla fine della sezione critica il lock viene settato a false e quindi rilasciato.
Problema: se due thread entrano contemporaneamente in sezione critica potrebbe accadere che superino il while
entrambi, in quanto leggono il valore false prima che l’altro thread abbia settato lock
a true. In tale caso è come se entrambi acquisissero il lock e la mutua esclusione non è garantita: ci sono 2 thread in sezione critica.
Thread T0 Thread T1
while(lock) {} while(lock) {}
# Entrambi i thread superano il ciclo while!
lock = true; lock = true;
< sezione critica > < sezione critica >
lock = false; lock = false;
Tentativo 2: turno
Utilizziamo una variabile booleana globale turno
inizializzata a 1. I thread eseguono codice che dipende dal proprio ID. Vediamo questa soluzione con 2 thread:
thread T0 { thread T1 {
.... ....
while(turno != 0) {} while(turno != 1) {}
< sezione critica > < sezione critica >
turno = 1; turno = 0;
.... ....
} }
Descrizione: il codice di ingresso attende ciclando “a vuoto” finché non è il proprio turno. Alla fine della sezione critica il turno viene dato all’altro thread.
Mutua esclusione: la mutua esclusione è garantita dal fatto che turno
non può valere contemporaneamente 0 e 1, di conseguenza uno dei due thread rimarrà in attesa sul ciclo while
.
Problema: Anche se la soluzione garantisce mutua esclusione esistono esecuzioni problematiche: supponiamo che T0
voglia entrare 2 volte di seguito nella sezione critica mentre T1
sta eseguendo altro codice. T0
esce dalla prima sezione critica e assegna il turno a T1
, a questo punto si bloccherà sul while
in attesa che T1
vada in sezione critica e ceda a sua volta il turno. Il problema è che non sappiamo se e quando T1
entrerà in sezione critica (potrebbe anche non andarci mai!).
Manca quindi una seconda proprietà fondamentale che denominiamo progresso: se nessun thread è in sezione critica, un thread che chiede di accedere deve poterlo fare immediatamente.
Tentativo 3: pronto
Vediamo questa soluzione con 2 thread utilizzando un array di booleani globale pronto[2]
inizializzati a false
. Anche in questo caso i thread eseguono codice che dipende dal proprio ID.
thread T0 { thread T1 {
.... ....
pronto[0] = true; pronto[1] = true;
while(pronto[1]) {} while(pronto[0]) {}
< sezione critica > < sezione critica >
pronto[0] = false; pronto[1] = false;
.... ....
} }
Descrizione: quando il thread i-esimo vuole entrare pone a true
la variabile pronto[i]
per segnalare all’altro thread la sua volontà di accedere. Successivamente cicla a vuoto se anche l’altro thread è pronto. Quando esce dalla sezione critica, il thread pone a false pronto[i]
per indicare che non ha più bisogno della sezione critica.
Mutua esclusione: la mutua esclusione è garantita dal fatto che se un thread è in sezione critica la sua variabile pronto è settata a true
e l’altro thread si bloccherà sul ciclo while
.
Problema: Anche se la soluzione garantisce mutua esclusione esistono esecuzioni problematiche: supponiamo che T0
e T1
vogliano entrare entrambi in sezione critica. Potrebbe accadere che settino simultaneamente pronto[i]=true
e poi si blocchino entrambi sul proprio ciclo while
. Notare che questa situazione di attesa è irrisolvibile e quindi inaccettabile.
Lo stallo di T0
e T1
:
Thread T0 Thread T1
pronto[0] = true; pronto[1] = true;
# entrabe le variabili sono settate a true
while(pronto[1]) {} while(pronto[0]) {}
# i thread sono in stallo!
NOTA: Se invertiamo il while
e l’assegnamento pronto[i]=true
potrebbe accadere che entrambi i thread superino il ciclo while
rompendo la mutua esclusione.
Soluzione: L’algoritmo di Peterson
Nel 1981, Gary L. Peterson propose una soluzione al problema della soluzione critica per 2 thread che di fatto combina 2 dei tentativi che abbiamo descritto sopra: pronto
e turno
.
Il difetto di pronto
è che esiste un caso in cui i thread vanno in stallo. Per evitare questa situazione irrisolvibile utilizziamo una turnazione, ma solo nel caso problematico: quando entrambi i thread sono pronti. Se uno solo è pronto, tale thread può tranquillamente accedere alla sezione critica.
thread T0 { thread T1 {
.... ....
pronto[0] = true; pronto[1] = true;
turno=1; turno=0;
while(pronto[1] && turno != 0) {} while(pronto[0] && turno != 1) {}
< sezione critica > < sezione critica >
pronto[0] = false; pronto[1] = false;
.... ....
} }
Descrizione: quando il thread i-esimo vuole entrare pone a true
la variabile pronto[i]
per segnalare all’altro thread la sua volontà di accedere. Successivamente cede il turno
all’altro thread e cicla a vuoto se l’altro thread è pronto e se non è il proprio turno. Quando esce dalla sezione critica, il thread pone a false pronto[i]
per indicare che non ha più bisogno della sezione critica.
Mutua esclusione: Per convincerci che viene garantita mutua esclusione dobbiamo considerare due casi
T0
supera il propriowhile
quandoT1
è prima diturno=0
. In questo caso quandoT1
eseguiràturno=0
si bloccherà nel ciclowhile
in quanto avremo chepronto[0]
ètrue
eturno
è0
cioè diverso da1
;T0
supera il proprio while quandoT1
è dopoturno=0
. PoichéT0
sta superando ilwhile
deve essereturno==0
. Quindi avremo cheT1
ancora una volta si blocca nel ciclowhile
ed attende.
Progresso: Se nessuno è in sezione critica pronto[i]
vale false
. Chiunque voglia entrare ci riuscirà in quanto la prima condizione nel while
sarà falsa.
Assenza di stallo: Supponiamo che entrambi i thread siano bloccati nel while
. In questo caso avremmo che turno!=0
e turno!=1
ma questo non è possibile in quanto turno
viene assegnata unicamente ai valori 0
e 1
quindi uno dei due thread uscirà necessariamente dal ciclo while.
NOTA: L’algoritmo di Peterson funziona per 2 thread ma esistono anche soluzioni corrette che funzionano con un numero arbitrario di thread.
Conclusioni (soluzioni software)
È possibile risolvere il problema della sezione critica puramente in software ma tali soluzioni presentano alcune problematiche importanti:
- I cicli while “a vuoto” (busy waiting) consumano inutilmente tempo di CPU. Su macchine con un solo core questo problema è particolarmente rilevante. Su più core diventa più accettabile in quanto i vari core dovrebbero comunque attendere che il thread in sezione critica esca;
- Le soluzioni richiedono l’uso di diverse variabili globali e un sequenza precisa di esecuzione delle istruzioni. I compilatori e i processori attuali rimaneggiano e riordinano le istruzioni in modo da migliorare la performance. L’utilizzo di soluzioni software di sincronizzazione richiede che tali ottimizzazioni siano disabilitate per evitare di compromettere la correttezza del codice di ingresso e uscita dalla sezione critica.
Soluzioni hardware
Vediamo quali meccanismi hardware possono essere d’aiuto per realizzare la sezione critica.
Disabilitazione interruzioni
Questa tecnica veniva utilizzata nei sistemi operativi ed è tuttora usata nei sistemi embedded. Si tratta di disabilitare le interruzioni in modo da “monopolizzare” la CPU per il tempo necessario ad eseguire il codice critico. Su sistemi a singolo core questo permette di bloccare temporaneamente l’esecuzione parallela di thread e processi garantendo in modo ovvio la mutua esclusione.
Tuttavia, questa tecnica non scala su sistemi multi-core in cui disabilitare le interruzioni non elimina il parallelismo vero tra core. Inoltre non è utilizzabile per programmi utente in quanto troppo rischiosa: un errore di programmazione bloccherebbe l’intero sistema.
Istruzioni speciali
Esistono istruzioni macchina particolari che possono essere usate per semplificare le soluzioni software al problema della sezione critica e non presentano i difetti della disabilitazione delle interruzioni discussa poco fa. Un esempio classico è test_and_set
. Questa istruzione permette di testare un valore ed assegnarlo in modo indivisibile. Per capirne il funzionamento la definiamo in pseudo-C come segue:
boolean test_and_set(boolean *x) {
boolean ret = *x;
*x = true;
return ret;
}
test_and_set
pone a true
il valore della variabile x
ritornando il valore booleano prima dell’assegnamento. Ci permette quindi di testare il valore di x
e assegnare x
a true in modo indivisibile. Possiamo usarla per risolvere il problema del primo tentativo di soluzione software con lock
, in cui due thread potevano acquisire il lock
simultaneamente:
thread T {
....
while(test_and_set(&lock)) {}
< sezione critica >
lock = false;
....
}
Se lock
è false
, test_and_set
ritorna false
ma contemporaneamente acquisisce il lock
ponendolo a true
, in modo indivisibile. Il thread quindi entra in sezione critica. Se invece lock
è a true
, test_and_set
ritorna true
e lascia inalterato il valore. Di conseguenza il thread rimane dentro il while
.
Questa soluzione scala su un numero arbitrario di thread e necessita di un’unica variabile booleana. L’unica accortezza è che l’assegnamento di lock
non venga riordinato rispetto alle istruzioni in sezione critica dal compilatore o dal processore stesso. Questa soluzione prende il nome di spin-lock e viene usata nei sistemi operativi per realizzare la sezione critica sulle proprie strutture dati.
Nei processori Intel esiste un’istruzione atomica che permette di scambiare il valore di due variabili in modo atomico (XCHG
). In pseudo C:
XCHG(boolean *x, *y) {
boolean tmp = *x;
*x = *y;
*y = tmp;
}
Esercizio
Provare a realizzare la sezione critica usando XCHG
.