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
.
Eroarea 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 generate 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ție 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.
Pentru a observa comportamentul programului îl rulăm:
./indefinite-wait
Observăm că programul se blochează, deci fie avem deadlock fie unele thread-uri așteaptă fără a fi trezite/notificate. Având un singur mutex folosit corezpunzător, problema nu poate fi deadlock deci este vorba de o așteptare nedefinită.
La o investigație atentă observăm că atât thread-urile producător cât și cele consumator vor aștepta la variabilele condiție aferente (buffer_empty_cond
și buffer_full_cond
) fără a fi trezite. Nu există apeluri ale funcției pthread_cond_signal necesare pentru trezirea thread-urilor.
Rezolvăm această problemă prin adăugarea apelurilor necesare în cadrul funcțiilor produce
și consume
rezultând forma actualizată:
static void *produce(void *arg) { size_t idx = (size_t) arg; pthread_mutex_lock(&pc_mutex); if (pc_buffer.size >= MAX_ITEMS) pthread_cond_wait(&buffer_full_cond, &pc_mutex); pc_buffer.storage[pc_buffer.size] = ITEM; pc_buffer.size++; pthread_cond_signal(&buffer_empty_cond); printf("Producer %zu created item.\n", idx); pthread_mutex_unlock(&pc_mutex); return NULL; } static void *consume(void *arg) { size_t idx = (size_t) arg; pthread_mutex_lock(&pc_mutex); if (pc_buffer.size == 0) pthread_cond_wait(&buffer_empty_cond, &pc_mutex); pc_buffer.storage[pc_buffer.size] = NO_ITEM; pc_buffer.size--; pthread_cond_signal(&buffer_full_cond); printf("Consumer %zu removed item.\n", idx); pthread_mutex_unlock(&pc_mutex); return NULL; }
În cele de mai sus, înainte de operația de unlock pe mutex am apelat funcția pthread_cond_signal. Cu ajutorul acestei funcții, thread-ul producător ce tocmai a produs un element trezește un thread consumator care acum are ce consuma; în mod similar thread-ul consumator care a consumat un element trezește un thread producător care acum are loc unde să producă un element.
După actualizarea programului, recompilăm folosind make
și apoi îl rulăm. Acum programul va funcționa corespunzător fără problema așteptării nedefinite.
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).