This is an old revision of the document!
Pentru parcurgerea demo-urilor, folosim arhiva aferentă. Demo-urile rulează pe Linux. Descărcăm arhiva folosind comanda
wget http://elf.cs.pub.ro/so/res/cursuri/curs-09-demo.zip
și apoi decomprimăm arhiva
unzip curs-09-demo.zip
și accesăm directorul rezultat în urma decomprimării
cd curs-09-demo/
Acum putem parcurge secțiunile cu demo-uri de mai jos.
Dorim să urmărim ce se întâmplă în cazul în care avem date partajate într-un mediu multithread. Pentru aceasta accesăm subdirectorul list-excl/
; urmărim conținutul fișierelor thread-list-app.c
și list.c
. Este vorba de o aplicație care lucrează cu liste înlănțuite într-un mediu multithreaded. Vom observa că există riscul ca datele să fie corupte, fiind necesară sincronizare.
Compilăm fișierele folosind make
. Rezultă două fișiere în format executabil: thread-list-app
și thread-list-app-mutex
.
Programul thread-list-app-mutex
folosește intern un mutex pentru sincronizare. Pentru aceasta folosim macro-ul USE_MUTEX
pe care îl definim în fișierul Makefile
.
Ca să urmărim ce se întâmplă cu o aplicație multithreaded cu date partajate, rulăm de mai multe ori programul thread-list-app
, până la obținerea unei erori:
./thread-list-app
thread-list-app.c
macro-urile NUM_THREADS
și NUM_ROUNDS
la alte valori (probabil mai mari). Apoi recompilăm folosind make
și rulăm din nou programul thread-list-app
.
Eroare este cauzată de coruperea pointerilor din lista înlănțuită a programului. Datele sunt accesate concurent, iar în absența sincronizării, vor fi corupte.
Soluția este să folosim un mutex pentru protejarea accesului la listă, lucru realizat în programul thread-list-app-mutex
. Dacă vom rula de mai multe ori programul thread-list-app-mutex
, nu vom obține niciodată eroare:
./thread-list-app-mutex
Avem acces exclusiv la datele partajate deci am rezolvat problema coruperii datelor din cauza accesului concurent neprotejat.
Dorim să investigăm overheadul produs când folosim spinlock-uri și mutex-uri pentru acces exclusiv. Pentru aceasta accesăm subdirectorul spinlock-mutex/
; urmărim conținutul fișierului spinlock-mutex.c
. Acest fișier are implementare didactică în care folosește spinlock-uri sau mutex-uri pentru asigurarea accesului exclusiv.
Compilăm cele două programe folosind make
. Rezultă două fișiere în format executabil: spinlock
și mutex
.
Cele două fișiere au fost generat din același cod sursă (spinlock-mutex.c
), după cum a fost definit sau nu macro-ul USE_SPINLOCK
. Macro-ul îl definim în fișierul Makefile
.
Pentru a contabiliza timpul de rulare rulăm cele două executabile prin comanda time
:
/usr/bin/time ./spinlock /usr/bin/time ./mutex
Observăm că folosirea spinlock-urilor pentru accesul exclusiv la resurse rezultă într-un timp de rulare mai mic decât folosirea mutex-urilor. Aceasta se întâmplă pentru că avem regiune critică mică iar overhead-ul cauzat de operații pe mutex-uri este semnificativ mai mare decât cel cauzat de operații pe spinlock-uri.
Observăm din output-ul comenzii time
că folosirea mutex-urilor înseamnă semnificativ mai multe schimbări de context: o operație de tip lock pe mutex are șanse mari să blocheze thread-ul curent și să invoce planificatorul pentru schimbarea contextului. De asemenea, observăm că programul ce folosește spinlock-uri petrece mai mult timp în user space (User time) și mai puțin în kernel space (System time). Aceasta se întâmplă pentru că implementarea de spinlock-uri este realizată în user space și toate acțiunile (inclusiv partea de busy waiting aferentă spinlock-urilor) au loc în user space.
Dorim să urmărim cum se manifestă o condiție de cursă de tipul TOCTTOU (time of check to time of use). Pentru aceasta accesăm subdirectorul tocttou/
; urmărim conținutul fișierului tocttou.c
. Fișierul simulează o problemă producător consumator în care producătorul produce câte NUM_ITEMS_PRODUCER
elemente, iar consumatorul consumă câte NUM_ITEMS_CONSUMER
elemente; dacă un consumator nu poate consuma elemente, își încheie execuția.
Compilăm programul folosind make
. Rezultă fișierul tocttou
în format executabil.
În cadrul programului tocttou
nu folosim sincronizare și deci este posibil să avem condiții de cursă. Pentru a observa acest lucru rulăm de mai multe ori programul până când obținem o valoare negativă pentru numărul de elemente, lucru imposibil la o rulare obișnuită a programului:
./tocttou Created 50 producers. Each producer creates one item. Created 10 consumers. Each producer removes one item. Num items at the end: -2
Aceasta are loc întrucât avem o perioadă între timpul de verificare, adică linia
if (num_items >= NUM_ITEMS_CONSUMER) {
și timpul de utilizare, adică linia
num_items -= NUM_ITEMS_CONSUMER; /* Consume. */
adică o condiție de cursă de tipul TOCTTOU, în care se poate “infiltra” alt thread. Practic este posibil ca două thread-uri să scadă valoarea variabilei num_items
deși condiția ar fi trebuit să se întâmple doar pentru unul. Dacă avem o valoare inițială NUM_ITEMS_CONSUMER + 2
, atunci există riscul ca mai multe thread-uri să vadă îndeplinită condiția și apoi toate să scadă valoarea variabilei num_items
rezultând într-o valoare negativă.
O astfel de situația poate duce la un comportament nedeterminst al programului, la coruperea datelor, chiar la încheierea cu eroare a programului. Mai mult o astfel de situație poate fi exploatată de un atacator.
Pentru a preveni apariția condițiilor de cursă, trebuie implementată corespunzător sincronizarea accesului. Din păcate, condițiile de cursă pot apărea foarte greu (adică programul să fie greșit dar să meargă aproape tot timpul); acest lucru face nedeterminist comportamentul programului și dificilă investigarea problemei.
Dorim să urmărim cum se manifestă un deadlock. Pentru aceasta accesăm subdirectorul deadlock/
; urmărim conținutul fișierului deadlock.c
. Fișierul are o implementare didactică pentru două tipuri de thread-uri care obțin în ordine diferită două mutex-uri.
Compilăm programul folosind make
. Rezultă fișierul deadlock
în format executabil.
La rularea programului se va genera deadlock:
./deadlock
Dacă nu se întâmplă acest lucru rulăm programul de mai multe ori.
Deadlock-ul are loc pentru că cele două mutex-uri folosite în program (xmutex
și ymutex
) nu sunt achiziționate în aceeași ordine. Unele thread-uri (cele care execută funcția xfirst
) achiziționează mutex-urile în ordinea xmutex
, ymutex
; celelalte thread-uri (cele care execută funcția yfirst
) achiziționează mutex-urile în ordinea ymutex
, xmutex
. În acest fel, la un moment dat un thread va achiziționa mutex-ul xfirst
și imediat după un altul va achiziționa ymutex
. În continuare ambele thread-uri vor aștepta după un mutex deținut de alt thread.
Dorim să urmărim cum se manifestă o problemă de așteptare nedefinită (indefinite wait). Pentru aceasta accesăm subdirectorul indefinite-wait/
; urmărim conținutul fișierului indefinite-wait.c
. Fișierul are o implementare nefucțională pentru problema producători-consumatori, lucru care conduce la așteptare nedefinită.
Compilăm programul folosind make
. Rezultă fișierul indefinite-wait
în format executabil.
Dorim să urmărim cum se comportă un program atunci când diferă dimensiunea regiunii critice folosită pentru acces exclusiv. Pentru aceasta accesăm subdirectorul granularity/
; urmărim conținutul fișierului granularity.c
. Fișierul are o implementare didactică în care mai multe thread-uri accesează o regiune critică.
Compilăm programul folosind make
. Rezultă două fișiere în format executabil: granuarity-fine
și granularity-coarse
. Fișierului executabil granularity-fine
îi corespunde o regiune critică de mică dimensiune, dar accesată des; fișierului executabil granularity-coarse
îi corespunde o regiune critică de dimensiune mai mare, dar accesată rar.
În cadrul codului, folosirea unei granularități fine sau nu este indicată de macro-ul GRANULARITY_TYPE
inițializat în Makefile.
Pentru a vedea impactul tipului de granularitate, rulăm cele două fișiere în format executabil și măsurăm timpul de rulare:
/usr/bin/time ./granularity-fine /usr/bin/time ./granularity-coarse
Observăm că dureaza semnificativ mai mult rularea executabilului cu granularitate fină. Acest lucru se întâmplă pentru că regiunea critică pe care acesta o protejează este mic, iar cea mai mare parte din timp o va consuma în operațiile cu mutex-ul (lock contention). Observăm acest lucru și din numărul mare de schimbări de context (voluntare sau nevoluntare).