Salta al contenuto
Note per Studenti Sincronizzazione - sezione critica, race condition e mutex

Sincronizzazione - sezione critica, race condition e mutex

In questa pagina 6

Processi e thread concorrenti (Processi - stati, PCB, creazione con fork ed execUn processo è un programma in esecuzione (entità attiva) con codice (text), dati globali, heap, stack e lo stato dell'esecuzione; il kernel lo descrive con il PCB (Process Control Block: stato, program counter, registri, informazioni di scheduling, memoria, accounting, file aperti, in Linux la struct task_struct) e passa da un processo all'altro con il context switch (puro overhead); gli stati sono new, ready, running, waiting, terminated, con una ready queue e varie wait queue; in UNIX fork() crea un figlio copia del padre (restituisce 0 al figlio e il pid del figlio al padre), exec() sostituisce il programma del figlio, wait()/waitpid() raccoglie lo stato di uscita: senza di essa un figlio terminato è uno zombie, un figlio il cui padre è morto è un orfano adottato da init/systemd; i processi cooperanti comunicano con memoria condivisa (POSIX shm) o scambio di messaggi (diretto o indiretto, sincrono o asincrono, buffer a capacità zero, limitata, illimitata) e con pipe ordinarie (padre-figlio, unidirezionali) e named pipe (FIFO).Processi - stati, PCB, creazione con fork ed exec →, Thread e concorrenza - modelli, Pthreads e goroutineUn thread è un flusso di esecuzione dentro un processo: ha program counter, registri e stack propri ma condivide codice, dati, heap e file aperti del processo, quindi costa meno di un processo (creazione e commutazione) e comunica più facilmente, ma i dati condivisi producono race condition; concorrenza (più compiti che avanzano, anche su un solo core) non è parallelismo (più compiti eseguiti nello stesso istante, serve più di un core); si distinguono parallelismo sui dati e sui compiti e la legge di Amdahl dà lo speedup massimo 1/(S+(1-S)/N) con S frazione seriale; i thread utente sono gestiti da una libreria, quelli kernel dal sistema, e i modelli many-to-one, one-to-one (Linux, Windows) e many-to-many li collegano; in C si usa Pthreads (pthread_create, pthread_join, pthread_exit), in Go le goroutine (parola chiave go, sync.WaitGroup, canali); questioni aperte: fork/exec con i thread, segnali, cancellazione (asincrona o differita), thread-local storage, contention scope (PCS e SCS).Thread e concorrenza - modelli, Pthreads e goroutine →) possono essere interrotti in qualunque momento, anche a metà di un'operazione. Se condividono dati, un'interruzione nel posto sbagliato lascia i dati incoerenti. Questa nota presenta il problema (race condition e sezione critica), le soluzioni via software (Peterson) e via hardware (test_and_set, compare_and_swap), e il primo strumento da usare in pratica, il mutex. I semafori, i monitor e i problemi classici sono nella nota successiva (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 →); quando si bloccano a vicenda i processi sorge lo stallo (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 →).

Race condition

Definizione (race condition). Si ha una race condition (corsa critica) quando più processi o thread accedono agli stessi dati e il risultato dipende dall'ordine in cui le operazioni si intrecciano (interleaving).

Esempio 1: il produttore-consumatore con un contatore counter degli elementi nel buffer (v. Processi - stati, PCB, creazione con fork ed execUn processo è un programma in esecuzione (entità attiva) con codice (text), dati globali, heap, stack e lo stato dell'esecuzione; il kernel lo descrive con il PCB (Process Control Block: stato, program counter, registri, informazioni di scheduling, memoria, accounting, file aperti, in Linux la struct task_struct) e passa da un processo all'altro con il context switch (puro overhead); gli stati sono new, ready, running, waiting, terminated, con una ready queue e varie wait queue; in UNIX fork() crea un figlio copia del padre (restituisce 0 al figlio e il pid del figlio al padre), exec() sostituisce il programma del figlio, wait()/waitpid() raccoglie lo stato di uscita: senza di essa un figlio terminato è uno zombie, un figlio il cui padre è morto è un orfano adottato da init/systemd; i processi cooperanti comunicano con memoria condivisa (POSIX shm) o scambio di messaggi (diretto o indiretto, sincrono o asincrono, buffer a capacità zero, limitata, illimitata) e con pipe ordinarie (padre-figlio, unidirezionali) e named pipe (FIFO).Processi - stati, PCB, creazione con fork ed exec →). Il produttore esegue counter++, il consumatore counter--. In linguaggio macchina ognuna è un'operazione di lettura, modifica, scrittura su un registro:

counter++ :  registro1 = counter           counter-- :  registro2 = counter
             registro1 = registro1 + 1                  registro2 = registro2 - 1
             counter = registro1                        counter = registro2

Con counter = 5 un possibile intreccio è:

t chi istruzione registro counter
0 produttore reg1 = counter reg1 = 5 5
1 produttore reg1 = reg1 + 1 reg1 = 6 5
2 consumatore reg2 = counter reg2 = 5 5
3 consumatore reg2 = reg2 - 1 reg2 = 4 5
4 produttore counter = reg1 6
5 consumatore counter = reg2 4

Un'incrementata e una decrementata dovrebbero lasciare 5, invece resta 4 (con t4 e t5 scambiati si avrebbe 6). Il codice è corretto "in sequenza", ma non sotto concorrenza.

Esempio 2: due processi P0P_0 e P1P_1 chiamano fork; il kernel assegna il pid con la variabile next_available_pid. Senza un meccanismo che impedisca l'accesso contemporaneo, lo stesso pid potrebbe essere dato a due processi.

Una race condition è un errore intermittente: il programma funziona per migliaia di esecuzioni e poi sbaglia. Il laboratorio 3 la fa vedere con due thread che incrementano e decrementano sum (Esercizio - Thread, race condition e parallelismo sui dati (laboratorio 3)).

Il problema della sezione critica

Si considera un sistema di nn processi {P0,…,Pn−1}\{P_0, \dots, P_{n-1}\}; ognuno ha un segmento di codice, la sezione critica (critical section), in cui modifica dati comuni (variabili, tabelle, file). Quando un processo è nella sua sezione critica gli altri non devono essere nelle loro. La struttura di ogni processo:

while (true) {
    entry section        // chiede il permesso di entrare
        sezione critica
    exit section         // segnala che ha finito
    remainder section    // il resto del codice
}

Una soluzione deve soddisfare tre requisiti:

requisito significato
mutua esclusione (mutual exclusion) se PiP_i è nella sua sezione critica, nessun altro è nella propria
progresso (progress) la decisione di chi entra non dipende dai processi nella remainder section e non può essere rimandata all'infinito
attesa limitata (bounded waiting) dopo che un processo ha chiesto di entrare, il numero di volte che gli altri entrano prima di lui è limitato

Primo tentativo: disabilitare gli interrupt

Entry: disabilita gli interrupt; exit: li riabilita. Su un monoprocessore il codice in esecuzione non può essere interrotto. Ma: e se la sezione critica dura un'ora? Un processo può causare starvation agli altri? E con due o più CPU bisogna disabilitare gli interrupt di tutte? Non scala e dà troppo potere ai programmi utente.

Secondo tentativo: alternanza stretta

Due processi condividono int turn, che indica a chi spetta entrare. Si assume che load e store siano atomiche.

/* P0 */                         /* P1 */
while (true) {                   while (true) {
    while (turn == 1) ;              while (turn == 0) ;
    sezione critica                  sezione critica
    turn = 1;                        turn = 0;
    remainder                        remainder
}                                }
  • Mutua esclusione: sì, turn non può valere contemporaneamente 0 e 1.
  • Progresso: no. I due processi devono alternarsi: se P1P_1 è lento (resta nella sua remainder section), P0P_0 ha finito il turno e non può rientrare anche se la sezione critica è libera.
  • Attesa limitata: sì (prima o poi ogni processo entra).

La soluzione di Peterson

Definizione (soluzione di Peterson, due processi). I processi condividono boolean flag[2] e int turn. flag[i] = true significa «PiP_i vuole entrare»; turn dice a chi spetta se entrambi vogliono.

/* processo P_i, con j = l'altro */
while (true) {
    flag[i] = true;                    // voglio entrare
    turn = j;                          // ma cedo il turno all'altro
    while (flag[j] && turn == j) ;     // aspetto solo se l'altro vuole entrare ED è il suo turno
        sezione critica
    flag[i] = false;                   // ho finito
    remainder
}

Perché funziona:

  1. Mutua esclusione: PiP_i entra solo se flag[j] == false oppure turn == i. Se entrambi fossero dentro, avremmo flag[0] = flag[1] = true, e ognuno sarebbe entrato perché turn è il proprio indice: ma turn non vale 0 e 1 insieme. Chi ha scritto turn per ultimo ha ceduto il turno all'altro, che quindi passa.
  2. Progresso: se PjP_j non vuole entrare (flag[j] == false) PiP_i non aspetta mai.
  3. Attesa limitata: dopo aver sbloccato, PjP_j rimette flag[j] = true e turn = i: lascia passare PiP_i, che aspetta al più un'entrata dell'altro.

Punto debole. Anche con load e store atomiche, la soluzione non è garantita sulle architetture moderne: per migliorare le prestazioni processori e compilatori possono riordinare istruzioni senza dipendenze tra loro. Per un solo thread va bene, per più thread no. Esempio: due thread condividono boolean flag = false; int x = 0;. Il thread 1 esegue while (!flag) ; print x; e il thread 2 x = 100; flag = true;. Ci si aspetta 100, ma se il thread 2 viene riordinato in flag = true; x = 100; si può stampare 0. In Peterson, il riordino di flag[i] = true e turn = j o delle letture può far entrare entrambi nella sezione critica.

Barriere di memoria

I modelli di memoria possono essere fortemente ordinati (strongly ordered: la modifica di un processore è subito visibile agli altri) o debolmente ordinati (weakly ordered: può non esserlo). Una barriera di memoria (memory barrier) è un'istruzione che costringe a completare, e a rendere visibili agli altri processori, tutte le letture e scritture precedenti prima di ogni successiva. Nell'esempio: thread 2 esegue x = 100; memory_barrier(); flag = true; e thread 1 while (!flag) memory_barrier(); print x;, così x viene scritto prima di flag e letto dopo.

In C11 le variabili atomiche con ordinamento sequenziale (memory_order_seq_cst, il default di <stdatomic.h>) mettono le barriere necessarie. Peterson corretto per due thread:

peterson_c11.cc
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>

#define ITER 100000

static atomic_bool flag[2];
static atomic_int turn;
static long contatore;                    /* dato condiviso protetto da Peterson */

static void entra(int i)
{
    int j = 1 - i;
    atomic_store(&flag[i], 1);            /* voglio entrare */
    atomic_store(&turn, j);               /* cedo il turno */
    while (atomic_load(&flag[j]) && atomic_load(&turn) == j)
        ;                                 /* attesa attiva */
}

static void esci(int i)
{
    atomic_store(&flag[i], 0);
}

static void *lavora(void *arg)
{
    int i = (int)(long)arg;               /* 0 oppure 1 */
    for (int k = 0; k < ITER; k++) {
        entra(i);
        contatore += (i == 0) ? 1 : -1;   /* sezione critica */
        esci(i);
    }
    return NULL;
}

int main(void)
{
    pthread_t t0, t1;
    pthread_create(&t0, NULL, lavora, (void *)0L);
    pthread_create(&t1, NULL, lavora, (void *)1L);
    pthread_join(t0, NULL);
    pthread_join(t1, NULL);
    printf("contatore = %ld (atteso 0)\n", contatore);
    return 0;
}

La soluzione di Peterson resta però lenta (attesa attiva) e limitata a due processi: in pratica si usano gli strumenti sotto.

Supporto hardware

Su un monoprocessore si potrebbero disabilitare gli interrupt, ma su un multiprocessore è inefficiente. Molti sistemi offrono istruzioni speciali che leggono e modificano atomicamente (cioè senza interruzione) una parola, o ne scambiano due.

test_and_set: ritorna il vecchio valore di *target e lo imposta a true, in modo atomico. Con lock condiviso inizializzato a false:

do {
    while (test_and_set(&lock)) ;     // finché il vecchio valore è true, qualcuno è dentro
        sezione critica
    lock = false;
    remainder
} while (true);

Il primo processo trova false (e imposta true), entra; gli altri leggono true e aspettano. Soddisfa la mutua esclusione ma non l'attesa limitata: un processo veloce può riprendere sempre il lock e far soffrire gli altri (starvation).

compare_and_swap (CAS): ritorna il valore originale di *value e lo sostituisce con new_value solo se *value == expected:

int compare_and_swap(int *value, int expected, int new_value) {
    int temp = *value;
    if (*value == expected) *value = new_value;
    return temp;
}
// lock condiviso, inizializzato a 0
while (compare_and_swap(&lock, 0, 1) != 0) ;     // entra quando riesce a passare da 0 a 1
    sezione critica
lock = 0;

Stesso limite sull'attesa limitata, ma è la base di strumenti più flessibili: le variabili atomiche (aggiornamenti non interrompibili su interi e booleani) e i semafori. Per esempio increment(&sequence) si può realizzare con un ciclo di CAS: leggere il valore, calcolare il nuovo, riprovare finché CAS riesce (se *v cambia prima, ripete). Non risolvono tutti i problemi (per esempio quando una variabile è controllata da più processi).

Un lock realizzato con il vero test_and_set del C11 (atomic_flag):

spinlock_tas.cc
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>

#define ITER 200000

static atomic_flag lock = ATOMIC_FLAG_INIT;
static long contatore;

static void acquire(void)
{
    while (atomic_flag_test_and_set(&lock))   /* imposta a true e ritorna il vecchio valore */
        ;                                     /* spinlock: attesa attiva */
}

static void release(void)
{
    atomic_flag_clear(&lock);
}

static void *lavora(void *arg)
{
    (void)arg;
    for (int k = 0; k < ITER; k++) {
        acquire();
        contatore++;
        release();
    }
    return NULL;
}

int main(void)
{
    pthread_t a, b;
    pthread_create(&a, NULL, lavora, NULL);
    pthread_create(&b, NULL, lavora, NULL);
    pthread_join(a, NULL);
    pthread_join(b, NULL);
    printf("contatore = %ld (atteso %d)\n", contatore, 2 * ITER);
    return 0;
}

Mutex lock

Le soluzioni precedenti sono complicate e inaccessibili al programmatore applicativo. Il sistema operativo offre quindi il mutex lock (mutual exclusion lock): un booleano che dice se il lock è disponibile; si protegge la sezione critica con acquire() prima e release() dopo. Le due operazioni devono essere atomiche (di solito realizzate con compare_and_swap).

Definizione (spinlock). Un mutex che aspetta con un ciclo (busy waiting, attesa attiva) è detto spinlock: il thread resta in esecuzione senza fare lavoro utile. Pregio: leggero e adatto quando l'attesa prevista è molto breve (niente cambio di contesto). Difetto: spreca CPU se l'attesa è lunga.

I mutex di sistema (come pthread_mutex_t) invece sospendono il thread che non ottiene il lock, mettendolo in una coda d'attesa. In POSIX:

funzione uso
pthread_mutex_init(&m, NULL) inizializza (o PTHREAD_MUTEX_INITIALIZER)
pthread_mutex_lock(&m) blocca; se è già bloccato da un altro thread sospende il chiamante
pthread_mutex_trylock(&m) come lock ma non blocca: se è occupato ritorna subito EBUSY
pthread_mutex_unlock(&m) sblocca (deve essere chiamata dal thread proprietario)
pthread_mutex_destroy(&m) libera le risorse (il mutex deve essere sbloccato)
pthread_spinlock_t alternativa con attesa attiva (spreca CPU)

Il mutex ha due stati, libero o posseduto da un thread: non può mai essere posseduto da due thread insieme. Attenzione: per default un thread che chiama pthread_mutex_lock due volte sullo stesso mutex senza sbloccare lo blocca per sempre (deadlock con se stesso). Su Linux il mutex bloccato da un thread può essere sbloccato da un altro, ma non è lo standard POSIX e il comportamento è indefinito.

Il programma del laboratorio 3 corretto con un mutex: due thread incrementano e decrementano la stessa variabile sum.

somma_mutex.cc
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

static long sum;                                   /* dato condiviso */
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

static void *incrementa(void *param)
{
    long upper = atol(param);
    for (long i = 1; i <= upper; i++) {
        pthread_mutex_lock(&lock);                 /* entry section */
        sum++;                                     /* sezione critica */
        pthread_mutex_unlock(&lock);               /* exit section */
    }
    return NULL;
}

static void *decrementa(void *param)
{
    long upper = atol(param);
    for (long i = 1; i <= upper; i++) {
        pthread_mutex_lock(&lock);
        sum--;
        pthread_mutex_unlock(&lock);
    }
    return NULL;
}

int main(int argc, char *argv[])
{
    if (argc != 2) {
        fprintf(stderr, "uso: %s N\n", argv[0]);
        return 1;
    }
    pthread_t t1, t2;
    pthread_create(&t1, NULL, incrementa, argv[1]);
    pthread_create(&t2, NULL, decrementa, argv[1]);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("sum = %ld\n", sum);                    /* sempre 0 */
    return 0;
}

Il risultato è sempre 0; senza mutex, per NN grande, sum è un valore diverso a ogni esecuzione (con -O0: l'ottimizzatore potrebbe altrimenti fondere le NN iterazioni e nascondere il problema). Si osserva anche che il mutex è molto più semplice e veloce della soluzione di Peterson con usleep.

Esercizi collegati

Domande d'esame

1. Elencare i tre requisiti della sezione critica e dire quali sono violati dall'alternanza stretta con la variabile turn. Traccia. Mutua esclusione, progresso, attesa limitata. L'alternanza stretta garantisce la mutua esclusione e l'attesa limitata, ma viola il progresso: un processo veloce non può rientrare se l'altro, lento, non ha ancora cambiato turn, anche con la sezione critica libera.

2. Perché la soluzione di Peterson può fallire su un processore moderno e come si ripara? Traccia. Compilatore e processore possono riordinare le istruzioni senza dipendenze (per esempio flag[i] = true e turn = j), facendo entrare insieme i due processi. Si ripara con barriere di memoria o variabili atomiche con ordinamento sequenziale.

3. Differenza tra spinlock e mutex con sospensione: quando si preferisce l'uno o l'altro? Traccia. Lo spinlock attende in un ciclo (busy waiting): nessun cambio di contesto, conviene per attese molto brevi su multiprocessore. Il mutex sospende il thread in una coda: non spreca CPU, conviene per attese lunghe o con un solo processore.

Versione ripasso

c
while (atomic_flag_test_and_set(&lock)) ;   /* acquire: spinlock */
contatore++;                                /* sezione critica */
atomic_flag_clear(&lock);                   /* release */
  • Mutex POSIX: pthread_mutex_init/lock/trylock/unlock/destroy; trylock non blocca (EBUSY); unlock va fatta dal thread proprietario; lock doppio dello stesso thread →\to blocco per sempre (mutex non ricorsivo). Spinlock (pthread_spinlock_t, attesa attiva): conviene per attese brevissime su multiprocessore, perché evita il cambio di contesto; il mutex di sistema sospende il thread.
  • Verifica pratica: sum++/sum-- con due thread: per NN piccolo spesso 0, per NN grande valori diversi a ogni esecuzione; con mutex sempre 0; compilare con -O0 per vedere l'errore (l'ottimizzatore può fondere il ciclo).
  • Requisiti, in una riga: mutua esclusione = al più uno dentro; progresso = chi decide non dipende dai processi nella remainder section e non rimanda per sempre; attesa limitata = un tetto alle entrate degli altri dopo la richiesta. Disabilitare gli interrupt: non scala (multiprocessori, sezioni lunghe, starvation).
  • Errori tipici: credere che counter++ sia atomico; Peterson senza barriere; pthread_mutex_lock due volte; unlock senza lock; dimenticare join; proteggere la scrittura ma non la lettura; mutex diversi per lo stesso dato.

Esercizi su questo argomento

Teoria collegata