Curs 09 - Sincronizare

Demo-uri

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.

Nevoie de acces exclusiv

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

Dacă nu dă eroare, putem actualiza în fișierul 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.

Consum de timp mutex și spinlock

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.

Race condition (TOCTTOU)

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.

Deadlock

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.

Pentru a evita apariția deadlock-urilor în momentul în care folosim lock-uri/mutex-uri, o condiție importantă este ca lock-urile să fie achiziționate în aceeași ordine.

Așteptare nedefinită

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.

Granularitate regiune critică

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).

În general preferăm granularitate fină pentru regiunile critice (adică regiuni critice de mică dimensiune); dar granularitatea fină poate înseamna operații foarte dese pe mutex (lock contention). De aceea este recomandat ca folosirea granularității fine să fie echilibrată de un număr redus de thread-uri care să dorească să acceseze la un moment dat regiunea critică, pentru a genera cât mai puțină încărcare (contention) pe mutex.

so/cursuri/curs-09.txt · Last modified: 2019/04/15 01:04 by razvan.deaconescu
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0