Sincronizare thread-uri
Pentru sincronizarea firelor de execuție avem la dispoziție următoarele mecanisme:
mutex
semafoare
variabile de condiție
bariere.
Mutex
Mutex-urile sunt obiecte de sincronizare utilizate pentru a asigura accesul exclusiv la o secțiune de cod în care se accesează date partajate între două sau mai multe fire de execuție. Un mutex are două stări posibile: ocupat și liber. Un mutex poate fi ocupat de un singur fir de execuție la un moment dat. Atunci când un mutex este ocupat de un fir de execuție, el nu mai poate fi ocupat de niciun altul. În acest caz, o cerere de ocupare venită din partea unui alt fir, în general va bloca firul până în momentul în care mutex-ul devine liber.
Inițializarea/distrugerea unui mutex
Un mutex poate fi inițializat/distrus în mai multe moduri:
folosind o macrodefiniție
inițializat cu atribute implicite
inițializare cu atribute explicite
NB: Mutex-ul trebuie să fie liber pentru a putea fi distrus. În caz contrar funcția va întoarce codul de eroare EBUSY. Întoarcerea valorii 0 semnifică succesul apelului.
Tipuri de mutex-uri
Folosind atributele de initializare se pot crea mutex-uri cu proprietăti speciale:
activarea moștenirii de prioritate (priority inharitance) pentru a preveni inversiunea de prioritate (priority invesion). Există trei protocoale de moștenire a prioritătii:
PTHREAD_PRIO_NONE nu se moștenește prioritatea când deținem mutex-ul creat cu acest atribut
PTHREAD_PRIO_INHERIT dacă deținem un mutex creat cu acest atribut și dacă există fire de execuție blocate pe acel mutex se moștenește prioritatea firului de execuție cu cea mai mare prioritate
PTHREAD_PRIO_PROTECT dacă firul de execuție curent deține unul sau mai multe mutex-uri, acesta va executa la maximul priorităților specificată pentru toți mutecșii deținuți.
modul de comportare la preluări recursive ale mutex-ului
PTHREAD_MUTEX_NORMAL nu se fac verificări, preluarea recursivă duce la deadlock
PTHREAD_MUTEX_ERRORCHECK se fac verificări, preluarea recursivă duce la întoarcerea unei erori
PTHREAD_MUTEX_RECURSIVE mutex-urile pot fi preluate recursiv din același thread, și trebuie eliberate de același număr de ori.
Ocuparea/eliberearea unui mutex
Funcțiile de ocupare blocantă/eliberare a unui mutex:
Dacă mutex-ul este liber în momentul apelului, acesta va fi ocupat de firul apelant și funcția va întoarce imediat. Dacă mutex-ul este ocupat de un alt fir, apelul va bloca până la eliberarea mutex-ului. Dacă mutexul este deja ocupat de firul de execuție curent (lock recursiv), comportamentul funcției este dictat de tipul mutex-ului:
Tip mutex | Lock recursiv | Unlock |
---|---|---|
PTHREAD_MUTEX_NORMAL | deadlock | eliberează mutexul |
PTHREAD_MUTEX_ERRORCHECK | returnează eroare | eliberează mutexul |
PTHREAD_MUTEX_RECURSIVE | incrementează contorul de ocupări | decrementează contorul de ocupări (la zero eliberează mutexul) |
PTHREAD_MUTEX_DEFAULT | deadlock | eliberează mutexul |
Nu este garantată o ordine FIFO de ocupare a unui mutex. Oricare din firele aflate în așteptare la deblocarea unui mutex pot să-l acapareze.
Încercarea neblocantă de ocupare a unui mutex
Pentru a încerca ocuparea unui mutex fără a aștepta eliberarea acestuia în cazul în care este deja ocupat, se va apela funcția:
Exemplu de utilizare a mutex-urilor
Un exemplu de utilizare a unui mutex pentru a serializa accesul la variabilă globală global_counter:
Exemplu: Detectarea race-urilor la modificarea variabilelor partajate de mai multe fire de executie.
Să se rezolve această problemă folosind două mutex-uri, astfel ca rezultatul obținut să fi cel așteptat.
Exercițiu: Să se creeze un mutex normal, un thread în care se cheamă funcția pthread_mutex_lock() de două ori. Se va bloca programul, deadlock. Pentru rezolvarea problemei se va crea mutex-ul recursiv. (Este un exemplu didactic. În caz real, apelurile succesive de pthread_mutex_lock pe același mutex se face în fișiere diferite sau la multe rânduri distanță)
Exercițiu: Să se implementeze un deadlock intre două thread-uri folosind doi mutex m1 și m2. Să se rezolve deadlock-ul folosind blocare conditionată cu pthread_mutex_trylock().
THREAD1
THREAD2
Rezolvare THREAD2:
Alegeți varianta corectă în urma rulării testului. Desenați schema logică a aplicației:
Semafoare
Semafoarele sunt obiecte de sincronizare ce reprezintă o generalizare a mutexurilor prin aceea că salvează numărul de operații de eliberare (incrementare) efectuate asupra lor. Practic, un semafor reprezintă un întreg care se incrementează/decrementează atomic. Valoarea unui semafor nu poate scădea sub 0. Dacă semaforul are valoarea 0, operația de decrementare se va bloca până când valoarea semaforului devine strict pozitivă. Mutexurile pot fi privite, așadar, ca niște semafoare binare. Operațiile care pot fi efectuate asupra semafoarelor POSIX sunt:
Operații pe semafoare
Semafoarele POSIX au fost prezentate în cadrul laboratorului de comunicare inter-proces.
Un exemplu de utilizare semafoare în limbajul Java este prezentat in video-ul următor: Coordinating Threads via Java Semaphore
În aplicație se creeaza două semafoare și două thread-uri care se vor executa în ordine ping-pong, ping-pong, ...
Variabile de condiție
Variabilele condiție pun la dispoziție un sistem de notificare pentru fire de execuție, permițându-i unui fir să se blocheze în așteptarea unui semnal din partea unui alt fir. Folosirea corectă a variabilelor condiție presupune un protocol cooperativ între firele de execuție.
Mutexurile (mutual exclusion locks) și semafoarele permit blocarea altor fire de execuție. Variabilele de condiție se folosesc pentru a bloca firul curent de execuție până la îndeplinirea unei condiții. Variabilele condiție sunt obiecte de sincronizare care-i permit unui fir de executie să-i suspende executia până când o condiție (predicat logic) devine adevărată. Când un fir de execuție determină că predicatul a devenit adevărat, va semnala variabila condiție, deblocând astfel unul sau toate firele de execuție blocate la acea variabilă condiție (în funcție de cum se dorește).
O variabilă condiție trebuie întotdeauna folosită împreună cu un mutex pentru evitarea race-ului care se produce când un fir se pregătește să aștepte la variabila condiție în urma evaluării predicatului logic, iar alt fir semnalizează variabila condiție chiar înainte ca primul fir să se blocheze, pierzându-se astfel semnalul. Așadar, operațiile de semnalizare, testare a condiției logice și blocare la variabila condiție trebuie efectuate având ocupat mutex-ul asociat variabilei condiție. Condiția logică este testată sub protecția mutex-ului, iar dacă nu este îndeplinită, firul apelant se blochează la variabila condiție, eliberând atomic mutex-ul. În momentul deblocării, un fir de execuție va încerca să ocupe mutex-ul asociat variabilei condiție. De asemenea, testarea predicatului logic trebuie făcută într-o buclă, pentru că dacă sunt eliberate mai multe fire deodată, doar unul va reuși să ocupe mutex-ul asociat condiției. Restul vor aștepta ca acesta să-l elibereze, însă este posibil ca firul care a ocupat mutexul să schimbe valoarea predicatului logic pe durata deținerii mutex-ului. Din acest motiv celelalte fire trebuie să testeze din nou predicatul pentru că altfel i-ar începe execuția presupunând predicatul adevărat, când el este, de fapt, fals.
Inițializarea/distrugerea unei variabile de condiție
Ca și la mutex-uri:
dacă parametrul attr este nul se folosesc atribute implicite
trebuie să nu existe nici un fir de execuție în așteptare pe variabila de condiție atunci când aceasta este distrusă, altfel se întoarce EBUSY.
Blocarea la o variabilă condiție
Pentru a-i suspenda execuția și a aștepta la o variabilă condiție, un fir de execuție va apela:
Firul de execuție apelant trebuie să fi ocupat deja mutexul asociat, în momentul apelului. Funcția pthread_cond_wait va elibera mutexul și se va bloca, așteptând ca variabila condiție să fie semnalizată de un alt fir de execuție. Cele două operații sunt efectuate atomic. În momentul în care variabila condiție este semnalizată, se va încerca ocuparea mutex-ului asociat, și după ocuparea acestuia, apelul funcției va întoarce. Observați că firul de execuție apelant poate fi suspendat, după deblocare, în așteptarea ocupării mutex-ului asociat, timp în care predicatul logic, adevărat în momentul deblocării firului, poate fi modificat de alte fire. De aceea, apelul pthread_cond_wait trebuie efectuat într-o buclă în care se testează valoarea de adevăr a predicatului logic asociat variabilei condiție, pentru a asigura o serializare corectă a firelor de execuție. Un alt argument pentru testarea în buclă a predicatului logic este acela că un apel pthread_cond_wait poate fi întrerupt de un semnal asincron (vezi laboratorul de semnale), înainte ca predicatul logic să devină adevărat. Dacă firele de execuție care așteptau la variabila condiție nu ar testa din nou predicatul logic, i-ar continua execuția presupunând greșit că acesta e adevărat.
Blocarea la o variabilă condiție cu timeout
Pentru a-i suspenda execuția și a aștepta la o variabilă condiție, nu mai târziu de un moment specificat de timp, un fir de execuție va apela:
Funcția se comportă la fel ca pthread_cond_wait, cu exceptia faptului că dacă variabila condiție nu este semnalizată mai devreme de abstime, firul apelant este deblocat, și după ocuparea mutex-ului asociat, funcția se întoarce cu eroarea ETIMEDOUT. Parametrul abstime este absolut și reprezintă numărul de secunde trecute de la 1 ianuarie 1970, ora 00:00.
Deblocarea unui singur fir blocat la o variabilă condiție Pentru a debloca un singur fir de execuție blocat la o variabilă condiție se va semnaliza variabila condiție astfel:
Dacă la variabila condiție nu așteaptă niciun fir de executie, apelul funcției nu are efect și semnalizarea se va pierde. Dacă la variabila condiție așteaptă mai multe fire de execuție, va fi deblocat doar unul dintre acestea. Alegerea firului care va fi deblocat este făcută de planificatorul de fire de execuție. Nu se poate presupune că firele care așteaptă vor fi deblocate în ordinea în care i-au început așteptarea. Firul de execuție apelant trebuie să dețină mutexul asociat variabilei condiție în momentul apelului acestei funcții.
Exemplu: mutex-test.c
Deci, după semnalarea variabilei de condiție nu se garantează execuția thread-ului care așteaptă la variabilă. Deblocarea tuturor firelor blocate la o variabilă condiție Pentru a debloca toate firele de execuție blocate la o variabilă condiție, se semnalizează variabila condiție astfel:
Dacă la variabila condiție nu așteaptă niciun fir de execuție, apelul funcției nu are efect și semnalizarea se va pierde. Dacă la variabila condiție așteaptă fire de executie, toate acestea vor fi deblocate, dar vor concura pentru ocuparea mutex-ului asociat variabilei condiție. Firul de execuție apelant trebuie să dețină mutex-ul asociat variabilei condiție în momentul apelului acestei funcții.
Exemplu de utilizare a variabilelor de condiție
În următorul program se utilizează o barieră pentru a sincroniza firele de execuție ale programului. Bariera este implementată cu ajutorului unei variabile de condiție.
Din execuția programului se observă:
ordinea în care sunt planificate firele de execuție nu este identică cu cea a creării lor
ordinea în care sunt trezite firele de execuție ce așteaptă la o variabilă de condiție nu este identică cu ordinea în care acestea au intrat în așteptare.
Bariere
Standardul POSIX definește și un set de funcții și structuri de date de lucru cu bariere. Aceste funcții sunt disponibile dacă se definește macro-ul _XOPEN_SOURCE la o valoare >= 600. Cu bariere POSIX, programul de mai sus poate fi simplificat:
Inițializarea/distrugearea unei bariere
Așteptarea la o barieră
Dacă bariera a fost creată cu count=N, primele N-1 fire de execuție care apelează pthread_barrier_wait se blochează. Când sosește ultimul (al N-lea), va debloca toate cele N-1 fire de execuție. Funcția pthread_barrier_wait întoarce trei valori:
EINVAL în cazul în care bariera nu este inițializată (singura eroare definită)
PTHREAD_BARRIER_SERIAL_THREAD în caz de succes, un singur fir de execuție va întoarce valoarea aceasta nu e specificat care este acel fir de execuție (nu e obligatoriu să fie ultimul ajuns la barieră)
0 valoare întoarsă în caz de succes de celelalte N-1 fire de execuție.