Esercizio - Deadlock con semafori e thread (laboratorio 6)
In questa pagina 3
Testo (laboratorio 6 del corso di Sistemi Operativi, Ing. Informatica UniPD). Due programmi C con thread e semafori POSIX che si bloccano in deadlock:
- Task 1 (
deadlock1.c): due thread e due semafori (valore 1). Il thread fawait(sem1), poiwait(sem2); il thread fawait(sem2), poiwait(sem1). Verificare che il programma si blocca e rimuovere il deadlock cambiando l'uso dei semafori. - Task 2 (
deadlock2.c): tre thread e tre semafori che rappresentano risorse: (1 istanza), (2 istanze), (1 istanza). Il grafo di partenza: possiede un'istanza di e richiede ; possiede e un'istanza di e richiede ; possiede e richiede . Verificare il deadlock e rimuoverlo cambiando l'allocazione iniziale, per esempio in modo che i thread terminino nell'ordine . - I Task 3 e 4 (banchiere) sono in Esercizio - Implementare il banchiere e calcolarne la complessità (eserciziario, esercizio 3 e laboratorio 6).
Teoria: Deadlock - condizioni, prevenzione, evitamento e rilevamentoUn deadlock (stallo) è una situazione in cui un insieme di processi attende all'infinito risorse possedute da altri processi dello stesso insieme; può verificarsi solo se valgono insieme quattro condizioni (mutua esclusione, hold and wait, assenza di prelazione, attesa circolare); il grafo di allocazione delle risorse (archi di richiesta P->R e di assegnazione R->P) senza cicli esclude lo stallo, con un ciclo lo implica se ogni risorsa ha una sola istanza, e lo rende solo possibile se ha più istanze; si può prevenire (negare una delle quattro condizioni, per esempio ordinare le risorse per negare l'attesa circolare), evitare (stato sicuro: esiste una sequenza sicura; algoritmo del grafo con archi di rivendicazione per istanza singola, algoritmo del banchiere con Available, Max, Allocation, Need = Max - Allocation e complessità O(m n^2) per istanze multiple), rilevare e ripristinare (grafo wait-for per istanza singola, algoritmo di rilevamento per istanze multiple; terminazione dei processi o prelazione delle risorse con scelta della vittima e rollback), oppure ignorare.Deadlock - condizioni, prevenzione, evitamento e rilevamento →, Semafori, monitor e problemi classici di sincronizzazioneUn semaforo è un intero accessibile solo con due operazioni atomiche, wait (P: aspetta finché S > 0 e decrementa) e signal (V, in POSIX sem_post: incrementa); il semaforo contatore ha valori illimitati, quello binario vale 0 o 1; con valore iniziale 1 realizza la mutua esclusione, con valore iniziale 0 impone un ordine di esecuzione (S1 prima di S2); senza attesa attiva ogni semaforo ha una coda di processi sospesi (sleep e wakeup); i semafori si usano male con facilità (ordine sbagliato, omissioni); il monitor è un tipo di dato astratto in cui un solo processo alla volta è attivo e le variabili condizione x.wait()/x.signal() sospendono e risvegliano (in POSIX mutex più pthread_cond_t); le anomalie di liveness sono deadlock, starvation e inversione di priorità; i problemi classici sono produttore-consumatore a buffer limitato (mutex, vuote = n, piene = 0; l'ordine delle wait è importante), lettori-scrittori (rw_mutex, mutex, read_count; i lettori possono far morire di fame gli scrittori) e filosofi a cena (la soluzione ingenua con un semaforo per bacchetta va in deadlock; si evita limitando a n-1 filosofi, rompendo la simmetria o ordinando le risorse), con soluzioni in C (POSIX) e in Go (canali e sync).Semafori, monitor e problemi classici di sincronizzazione →. Compilazione: gcc deadlockN.c -o deadlockN -lpthread.
Task 1: attesa circolare tra due semafori
Il codice originale:
void* threadA(void* arg) { sem_wait(&sem1); sleep(1); sem_wait(&sem2); ... sem_post(&sem2); sem_post(&sem1); }
void* threadB(void* arg) { sem_wait(&sem2); sleep(1); sem_wait(&sem1); ... sem_post(&sem1); sem_post(&sem2); }Perché si blocca: il sleep(1) dà tempo a entrambi di prendere il primo semaforo. Allora possiede e aspetta , possiede e aspetta : valgono tutte e quattro le condizioni di Coffman (mutua esclusione: semafori binari; hold and wait; nessuna prelazione; attesa circolare ). Il join nel main non ritorna mai.
Correzione (quella del corso). Si nega l'attesa circolare con un ordine totale sulle risorse: entrambi i thread prendono prima e poi . Il codice (con la stessa struttura) è:
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <unistd.h>
static sem_t sem1, sem2;
static void *threadA(void *arg)
{
(void)arg;
sem_wait(&sem1); /* ordine: sempre sem1 prima di sem2 */
printf("[A] prima sezione critica...\n");
sleep(1);
sem_wait(&sem2);
printf("[A] seconda sezione critica...\n");
sleep(1);
sem_post(&sem2);
sem_post(&sem1);
return NULL;
}
static void *threadB(void *arg)
{
(void)arg;
sem_wait(&sem1); /* stesso ordine di A: niente attesa circolare */
printf("[B] prima sezione critica...\n");
sleep(1);
sem_wait(&sem2);
printf("[B] seconda sezione critica...\n");
sleep(1);
sem_post(&sem2);
sem_post(&sem1);
return NULL;
}
int main(void)
{
pthread_t tA, tB;
sem_init(&sem1, 0, 1);
sem_init(&sem2, 0, 1);
pthread_create(&tA, NULL, threadA, NULL);
pthread_create(&tB, NULL, threadB, NULL);
pthread_join(tA, NULL);
pthread_join(tB, NULL);
sem_destroy(&sem1);
sem_destroy(&sem2);
return 0;
}Ora il thread che arriva per secondo si ferma già alla wait(sem1) senza possedere nulla: non c'è hold and wait che chiuda un ciclo. Il programma stampa le sezioni di e poi quelle di (o viceversa) e termina in circa 4 secondi.
Altre soluzioni (nel corso viene citata solo la prima): rilasciare prima di prendere (se la logica lo permette, nega hold and wait); usare sem_trywait sul secondo semaforo e, se fallisce, rilasciare il primo e riprovare (nega l'assenza di prelazione, con rischio di livelock); un unico semaforo che protegge entrambe le sezioni.
Task 2: più istanze, grafo di allocazione
Il programma originale usa tre semafori inizializzati a 1, 2 e 1 come risorse. Ogni thread prende prima le risorse che il grafo dice che possiede, poi (dopo un sleep(1)) chiede la rimanente:
| thread | wait iniziali (possiede) |
richiesta successiva | rilascia |
|---|---|---|---|
| , | |||
| , | , , | ||
| , |
Perché si blocca. Dopo le wait iniziali: (di ), (una istanza a , una a ), (di ). Poi chiede (posseduta da ), chiede (da ), chiede (finite le istanze, in mano a e ): ciclo (e ). Nel grafo con a due istanze è deadlock perché le due istanze di sono possedute da processi del ciclo ( e ).
Il grafo è lo stesso dell'algoritmo del banchiere: Available , e con Max Allocation richiesta: : alloc max ; : alloc max ; : alloc max . Sono esattamente le matrici del programma banker.c del Task 3, che risulta UNSAFE: nessun processo può finire.
Correzione del corso. Si cambia l'allocazione iniziale: prende e all'inizio (poi non chiede più nulla); prende solo all'inizio e dopo il sleep chiede e ; resta com'era (, poi chiede ). Stato iniziale: ha , ha , ha ; Available . Evoluzione:
- non chiede altro: termina e libera e ;
- ora può ottenere un'istanza di (una libera): termina e libera e ;
- ottiene e : termina.
Ordine di terminazione , come richiesto: sequenza sicura . Il codice:
#include <pthread.h>
#include <semaphore.h>
#include <stdio.h>
#include <unistd.h>
static sem_t R1, R2, R3;
static void *T1_run(void *arg)
{
(void)arg;
sem_wait(&R2); /* possiede R2 e R1 fin dall'inizio */
sem_wait(&R1);
printf("T1 running...\n");
sleep(1);
printf("T1 completed.\n");
sem_post(&R2);
sem_post(&R1);
return NULL;
}
static void *T2_run(void *arg)
{
(void)arg;
sem_wait(&R2); /* all'inizio possiede solo R2 */
printf("T2 running...\n");
sleep(1);
sem_wait(&R3); /* poi chiede R3 e R1 */
sem_wait(&R1);
printf("T2 completed.\n");
sem_post(&R3);
sem_post(&R2);
sem_post(&R1);
return NULL;
}
static void *T3_run(void *arg)
{
(void)arg;
sem_wait(&R3); /* come nel programma originale */
printf("T3 running...\n");
sleep(1);
sem_wait(&R2);
printf("T3 completed.\n");
sem_post(&R2);
sem_post(&R3);
return NULL;
}
int main(void)
{
pthread_t T1, T2, T3;
sem_init(&R1, 0, 1);
sem_init(&R2, 0, 2); /* due istanze */
sem_init(&R3, 0, 1);
pthread_create(&T1, NULL, T1_run, NULL);
pthread_create(&T2, NULL, T2_run, NULL);
pthread_create(&T3, NULL, T3_run, NULL);
pthread_join(T1, NULL);
pthread_join(T2, NULL);
pthread_join(T3, NULL);
sem_destroy(&R1);
sem_destroy(&R2);
sem_destroy(&R3);
return 0;
}Verifica esaustiva. I thread non partono in un ordine garantito, quindi la correttezza non può dipendere dalla fortuna. Modellando i tre thread come sequenze di wait e post ed enumerando tutti i possibili intrecci, la versione corretta completa in ognuno dei 601 intrecci possibili senza mai bloccarsi, mentre il programma originale ha intrecci che finiscono in deadlock (ed è quello che succede sempre in pratica, perché lo sleep(1) dà a tutti il tempo di prendere le prime risorse). Per il Task 1 si trova lo stesso: l'originale ha un intreccio bloccante ( prende e prende ), la versione con ordine uguale non ne ha.
I due programmi sono stati compilati con zig cc -Wall -Wextra -Wpedantic per Linux senza avvisi; per il resto la correttezza è stata verificata con il banchiere e con l'enumerazione degli intrecci (i binari Linux non vengono eseguiti qui). Il codice originale del corso ha invece 3 avvisi -Wextra per l'argomento arg inutilizzato nelle funzioni dei thread.
Domande d'esame
Come si rimuove il deadlock del Task 1 senza cambiare il numero di semafori? Quale condizione di Coffman si nega? Traccia. Si impone lo stesso ordine di acquisizione ai due thread ( poi ): si nega l'attesa circolare con un ordine totale delle risorse.
Versione ripasso
- Task 1: :
wait(sem1),wait(sem2); :wait(sem2),wait(sem1);sleep(1)fa prendere a entrambi il primo -> attesa circolare. Fix: stesso ordine per tutti ( poi ): nega l'attesa circolare. Alternative: rilascio anticipato,sem_trywaitcon rilascio, semaforo unico. Teoria: Deadlock - condizioni, prevenzione, evitamento e rilevamentoUn deadlock (stallo) è una situazione in cui un insieme di processi attende all'infinito risorse possedute da altri processi dello stesso insieme; può verificarsi solo se valgono insieme quattro condizioni (mutua esclusione, hold and wait, assenza di prelazione, attesa circolare); il grafo di allocazione delle risorse (archi di richiesta P->R e di assegnazione R->P) senza cicli esclude lo stallo, con un ciclo lo implica se ogni risorsa ha una sola istanza, e lo rende solo possibile se ha più istanze; si può prevenire (negare una delle quattro condizioni, per esempio ordinare le risorse per negare l'attesa circolare), evitare (stato sicuro: esiste una sequenza sicura; algoritmo del grafo con archi di rivendicazione per istanza singola, algoritmo del banchiere con Available, Max, Allocation, Need = Max - Allocation e complessità O(m n^2) per istanze multiple), rilevare e ripristinare (grafo wait-for per istanza singola, algoritmo di rilevamento per istanze multiple; terminazione dei processi o prelazione delle risorse con scelta della vittima e rollback), oppure ignorare.Deadlock - condizioni, prevenzione, evitamento e rilevamento →. - Task 2: (1), (2), (1); ha chiede ; ha chiede ; ha chiede ->
Available=0, UNSAFE (stesse matrici dibanker.c). - Fix del corso: prende subito; solo , poi ; invariato. Ordine di fine (sequenza sicura). Rimandi: Semafori, monitor e problemi classici di sincronizzazioneUn semaforo è un intero accessibile solo con due operazioni atomiche, wait (P: aspetta finché S > 0 e decrementa) e signal (V, in POSIX sem_post: incrementa); il semaforo contatore ha valori illimitati, quello binario vale 0 o 1; con valore iniziale 1 realizza la mutua esclusione, con valore iniziale 0 impone un ordine di esecuzione (S1 prima di S2); senza attesa attiva ogni semaforo ha una coda di processi sospesi (sleep e wakeup); i semafori si usano male con facilità (ordine sbagliato, omissioni); il monitor è un tipo di dato astratto in cui un solo processo alla volta è attivo e le variabili condizione x.wait()/x.signal() sospendono e risvegliano (in POSIX mutex più pthread_cond_t); le anomalie di liveness sono deadlock, starvation e inversione di priorità; i problemi classici sono produttore-consumatore a buffer limitato (mutex, vuote = n, piene = 0; l'ordine delle wait è importante), lettori-scrittori (rw_mutex, mutex, read_count; i lettori possono far morire di fame gli scrittori) e filosofi a cena (la soluzione ingenua con un semaforo per bacchetta va in deadlock; si evita limitando a n-1 filosofi, rompendo la simmetria o ordinando le risorse), con soluzioni in C (POSIX) e in Go (canali e sync).Semafori, monitor e problemi classici di sincronizzazione →.
- Task 1, traccia: :
wait(sem1); :wait(sem2); ilsleep(1)fa passare entrambi il primowait; poi aspettasem2(di ) e aspettasem1(di ): attesa circolare; ipthread_joinnon ritornano mai. Le quattro condizioni di Coffman valgono: mutua esclusione (semafori binari), hold and wait, nessuna prelazione, attesa circolare. - Altre correzioni del Task 1: rilasciare il primo semaforo prima di prendere il secondo (nega hold and wait);
sem_trywaitsul secondo e, se fallisce, rilascio del primo e nuovo tentativo (nega la no preemption, rischio di livelock); un solo semaforo per entrambe le sezioni. - Task 2, stato iniziale:
Available; alloc , , ; max , , : sono le matrici dibanker.c( UNSAFE). Con il fix (banker_new, alloc , , ): SAFE con . - Verifica esaustiva: modellando i thread come sequenze di
wait/postsi enumerano tutti gli intrecci: il fix del Task 2 completa in tutti i 601 casi, l'originale ha intrecci che finiscono in deadlock; Task 1: originale con un intreccio bloccante, fix con nessuno. - Compilare:
gcc ... -lpthread; i fix sono compilati conzig cc -Wall -Wextra -Wpedanticsenza avvisi. - Errori tipici: credere che il
sleepsia la causa (lo è solo dell'interleaving); cambiare solo l'ordine deipost; scordare che ha due istanze.