This is an old revision of the document!


Laborator 8 - Thread-uri Linux

Materiale ajutătoare

Nice to read

  • TLPI - Chapter 29, Threads: Introduction
  • TLPI - Chapter 30, Threads: Thread Synchronization
  • TLPI - Chapter 31, Threads: Thread Safety and Per-Thread Storage

Prezentare teoretică

În laboratoarele anterioare a fost prezentat conceptul de proces, acesta fiind unitatea elementară de alocare a resurselor utilizatorilor. În cadrul acestui laborator este prezentat conceptul de fir de execuție (sau thread), acesta fiind unitatea elementară de planificare într-un sistem. Ca și procesele, firele de execuție reprezintă un mecanism prin care un calculator poate sǎ ruleze mai multe task-uri simultan.

Un fir de execuție există în cadrul unui proces, și reprezintă o unitate de execuție mai fină decât acesta. În momentul în care un proces este creat, în cadrul lui există un singur fir de execuție, care execută programul secvențial. Acest fir poate la rândul lui sǎ creeze alte fire de execuție; aceste fire vor rula porțiuni ale binarului asociat cu procesul curent, posibil aceleași cu firul inițial (care le-a creat).

Diferențe dintre fire de execuție și procese

  • procesele nu partajează resurse între ele (decât dacă programatorul folosește un mecanism special pentru asta - shared memory spre exemplu), pe când firele de execuție partajează în mod implicit majoritatea resurselor unui proces. Modificarea unei astfel de resurse dintr-un fir este vizibilă instantaneu și din celelalte fire:
    • segmentele de memorie precum .heap, .data și .bss (deci și variabilele stocate în ele)
    • descriptorii de fișiere (așadar, închiderea unui fișier este vizibilă imediat pentru toate firele de execuție), indiferent de tipul fișierului:
      • sockeți
      • fișiere normale
      • pipe-uri
      • fișiere ce reprezintă dispozitive hardware (de ex. /dev/sda1).
  • fiecare fir are un context de execuție propriu, format din:
    • stivă
    • set de registre (deci și un contor de program - registrul (E)IP)

Procesele sunt folosite de SO pentru a grupa și aloca resurse, iar firele de execuție pentru a planifica execuția de cod care accesează (în mod partajat) aceste resurse.

Avantajele firelor de execuție

Deoarece toate firele de execuție ale unui proces folosesc spațiul de adrese al procesului de care aparțin, folosirea lor are o serie de avantaje:

  • crearea/distrugerea unui fir de execuție durează mai puțin decât crearea/distrugerea unui proces
  • durata context switch-ului între firele de execuție aceluiași proces este foarte mică, întrucât nu e necesar să se “comute” și spațiul de adrese (pentru mai multe informații, căutați „TLB flush”)
  • comunicarea între firele de execuție are un overhead mai mic (realizată prin modificarea unor zone de memorie din spațiul comun de adrese)

Firele de execuție se pot dovedi utile în multe situații, de exemplu, pentru a îmbunătăți timpul de răspuns al aplicațiilor cu interfețe grafice (GUI), unde prelucrările CPU-intensive se fac de obicei într-un fir de execuție diferit de cel care afișează interfața.

De asemenea, ele simplifică structura unui program și conduc la utilizarea unui număr mai mic de resurse (pentru că nu mai este nevoie de diversele forme de IPC pentru a comunica).

Tipuri de fire de execuție

Din punctul de vedere al implementării, există 3 categorii de fire de execuție:

  • Kernel Level Threads (KLT)
  • User Level Threads (ULT)
  • Fire de execuție hibride

Detalii despre categoriile de fire de execuţie

Detalii despre categoriile de fire de execuţie

Kernel Level Threads

Managementul și planificarea firelor de execuție sunt realizate în kernel; programele creează/distrug fire de execuție prin apeluri de sistem. Kernel-ul menține informații de context, atât pentru procese, cât și pentru firele de execuție din cadrul proceselor, iar planificarea execuției se face la nivel de fir.

Avantaje :

  • dacă avem mai multe procesoare putem lansa în execuție simultană mai multe fire de execuție ale aceluiași proces;
  • blocarea unui fir nu înseamnă blocarea întregului proces;
  • putem scrie cod în kernel care să se bazeze pe fire de execuție.

Dezavantaje :

  • comutarea contextului este efectuată de kernel (cu o viteză de comutare mai mică):
    • se trece dintr-un fir de execuție în kernel
    • kernelul întoarce controlul unui alt fir de execuție.

User Level Threads

Kernel-ul nu este conștient de existența firelor de execuție, iar managementul acestora este realizat de procesul în care ele există (implementarea managementului firelor de execuție este realizată de obicei în biblioteci). Schimbarea contextului nu necesită intervenția kernel-ului, iar algoritmul de planificare depinde de aplicație.

Avantaje :

  • schimbarea de context nu implică kernelul ⇒ comutare rapidă
  • planificarea poate fi aleasă de aplicație; aplicația poate folosi acea planificare care favorizează creșterea performanțelor
  • firele de execuție pot rula pe orice SO, inclusiv pe SO-uri care nu suportă fire de execuție la nivel kernel (au nevoie doar de biblioteca care implementează firele de execuție la nivel utilizator).

Dezavantaje :

  • kernel-ul nu știe de fire de execuție ⇒ dacă un fir de execuție face un apel blocant toate firele de execuție planificate de aplicație vor fi blocate. Acest lucru poate fi un impediment întrucât majoritatea apelurilor de sistem sunt blocante. O soluție este utilizarea unor variante non-blocante pentru apelurile de sistem.
  • nu se pot utiliza la maximum resursele hardware: kernelul planifică firele de execuție de care știe, câte unul pe fiecare procesor. Kernelul nu este conștient de existența firelor de execuție user-level ⇒ el va vedea un singur fir de execuție ⇒ va planifica procesul respectiv pe maximum un procesor, chiar dacă aplicația ar avea mai multe fire de execuție planificabile în același timp.

Fire de execuție hibride

Aceste fire încearcă să combine avantajele firelor de execuție user-level cu cele ale firelor de execuție kernel-level. O modalitate de a face acest lucru este de a utiliza fire kernel-level pe care să fie multiplexate fire user-level. KLT sunt unitățile elementare care pot fi distribuite pe procesoare. De regulă, crearea firelor de execuție se face în user space și tot aici se face aproape toată planificarea și sincronizarea. Kernel-ul știe doar de KLT-urile pe care sunt multiplexate ULT, și doar pe acestea le planifică. Programatorul poate schimba eventual numărul de KLT alocate unui proces.

Modulul threading

În ceea ce privește firele de execuție, POSIX nu specifică dacă acestea trebuie implementate în user-space sau kernel-space. Linux le implementează în kernel-space, dar nu diferențiază firele de execuție de procese decât prin faptul că firele de execuție partajează spațiul de adresă (atât firele de execuție, cât și procesele, sunt un caz particular de “task”). Pentru folosirea firelor de execuție în Python trebuie să includem modulul threading.

Crearea firelor de execuție

Modulul threading expune clasa Thread. Astfel, un fir de execuție este creat prin instantierea clasei:

import threading
threading.Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)
  • group - se foloseste la extinderea clasei Thread; se recomanda pastrarea valoarei None;
  • target - functia care va fi apelata la rularea threadului;
  • name - numele threadului; daca acesta nu este specificat, se va genera un nume de formaThread-N, unde e un numar;
  • args - lista de parametrii care va fi pasata functiei target; parametrii vor fi pasati in ordinea in care functia ii primeste;
  • kwargs - dictionar care va stoca parametrii pasati functiei target, sub forma numele parametru-valoare;
  • daemon - specifică dacă threadul este de tip daemon; dacă valoarea este None, proprietatea va fi moștenită de la threadul curent.

Noul fir creat poate fi lansat prin apelarea functiei start(). Acesta va executa codul specificat de funcția target căreia i se vor pasa argumentele din args sau kwargs.

Pentru a determina firului de execuție curent se poate folosi funcția current_thread:

import threading
threading.current_thread()

Așteptarea firelor de execuție

Firele de execuție se așteaptă folosind funcția join:

t = threading.Thread()
t.join(timeout=None)

Odata ce un thread a facut join pe un altul, acesta se va bloca pana threadul pe care s-a facut join isi va incheia executia. Daca threadul pe care s-a facut join va arunca o exceptie de tipul excepthook va fi aruncata in threadul care a facut join.

Este recomandat ca programul principal să apeleze întotdeauna funcția join pentru threadurile pe care le generează.

Fire de execuție de tip daemon

Un thread de tip daemon are drept scop procesarea unor operații fară a impacta firul de execuție principal.

Proprietatea principală a acestor threaduri este că programul principal își va încheia execuția dacă rămân doar threaduri de acest tip.

Deși este o operație legală, se recomandă să nu folosiți funcția join pe un thread de tip daemon.

==== Terminarea firelor de execuție ====

Un fir de execuție își încheie execuția în mod automat, la sfârșitul codului firului de execuție.

==== Interacțiunea cu firele de execuție ====

Pentru a schimba informații între programul principal si alte threaduri, vom folosi modulul queue. Folosind acest modul, putem implementa o coadă în care vom plasa mesajele din partea firelor de execuție.

Modululul suportă crearea a 6 tipuri diferite de cozi:

  • Queue - coadă de tip FIFO, primul element inserat în coadă va fi primul extras;
  • LifoQueue - ultimul element inserat în coadă va fi primul extras;
  • PriorityQueue - Fiecare element are o prioritate, pe baza căreia se alege care element va fi cel extras;
  • SimpleQueue - coadă de tip FIFO cu funcționalități limitate;
  • Empty - se aruncă o excepție dacă se încearcă extragerea unui element când coada e goală;
  • Full - se aruncă o excepție dacă se încearcă adăugarea unui element când coada e plină.

În continuare vom discuta doar despre primele 3 tipuri de cozi, la restul aplicându-se alte operații. Pentru mai multe detalii despre restul claselor din modulul queue, vă recomandăm citirea documentației.

Pentru a crea o coadă, trebuie să instanțiem una din clasele expuse de modulul queue:

import queue
 
fifo_queue = queue.Queue()
lifo_queue = queue.LifoQueue()
priority_queue = queue.PriorityQueue()

Funcțiile put și put_nowait adaugă elemente în coadă.

q.put (item, block=True, timeout=None)
q.put_nowait (item)

Funcțiile get și get_nowait extrag elemente în coadă.

q.get (block=True, timeout=None)
q.get_nowait ()

Dacă coada este goală la apelarea uneia din cele două funcții, o excepție de tipul Empty este generată.

===== Exemplu ===== Mai jos vom crea un program care generează un thread ce primește valori de la programul principal și răspunde la aceastea.

TODO - primeste hello sau goodbye si raspunde cu SDE sau class. ===== Așteptarea cozilor =====

Similar cu așteptarea firelor de execuție, putem să apelăm funcția join pe o coadă, pentru a opri execuția pogramului până toate elementele din coadă au fost procesate.

Funcția join trebuie apelată în paralel cu funcția task_done. Fiecare thread va apela această funcție pentru a semnala terminarea procesării.

==== Thread Specific Data (TSD) ====

Uneori este util ca o variabilă să fie specifică unui fir de execuție (invizibilă pentru celelalte fire). Linux permite memorarea de perechi (cheie, valoare) într-o zonă special desemnată din stiva fiecărui fir de execuție al procesului curent. Cheia are același rol pe care îl are numele unei variabile: desemnează locația de memorie la care se află valoarea.

Fiecare fir de execuție va avea propria copie a unei “variabile” corespunzătoare unei chei k, pe care o poate modifica, fără ca acest lucru să fie observat de celelalte fire, sau să necesite sincronizare. De aceea, TSD este folosită uneori pentru a optimiza operațiile care necesită multă sincronizare între fire de execuție: fiecare fir calculează informația specifică, și există un singur pas de sincronizare la sfârșit, necesar pentru reunirea rezultatelor tuturor firelor de execuție.

Cheile sunt de tipul pthread_key_t, iar valorile asociate cu ele, de tipul generic void * (pointeri către locația de pe stivă unde este memorată variabila respectivă). Descriem în continuare operațiile disponibile cu variabilele din TSD:

=== Crearea și ștergerea unei variabile ===

O variabilă se creează folosind pthread_key_create:

int pthread_key_create(pthread_key_t *key, void (*destr_function) (void *));

Al doilea parametru reprezintă o funcție de cleanup. Acesta poate avea una din valorile:

  • NULL și este ignorat
  • pointer către o funcție de cleanup care se execută la terminarea firului de execuție

Pentru ștergerea unei variabile se apelează pthread_key_delete:

int pthread_key_delete(pthread_key_t key);

Funcția nu apelează funcția de cleanup asociată variabilei.

=== Modificarea și citirea unei variabile ===

După crearea cheii, fiecare fir de execuție poate modifica propria copie a variabilei asociate folosind funcția pthread_setspecific:

int pthread_setspecific(pthread_key_t key, const void *pointer);

Pentru a determina valoarea unei variabile de tip TSD se folosește funcția pthread_getspecific:

void* pthread_getspecific(pthread_key_t key);

==== Funcții pentru cleanup ====

Funcțiile de cleanup asociate TSD-urilor pot fi foarte utile pentru a asigura faptul că resursele sunt eliberate atunci când un fir se termină singur sau este terminat de către un alt fir. Uneori poate fi util să se poată specifica astfel de funcții fără a crea neapărat un TSD. Pentru acest scop există funcțiile de cleanup.

Detalii despre funcţii de cleanup

Detalii despre funcţii de cleanup

O astfel de funcție de cleanup este o funcție care este apelată când un fir de execuție se termină. Ea primește un singur parametru de tipul void * care este specificat la înregistrarea funcției.

O funcție de cleanup este folosită pentru a elibera o resursă numai în cazul în care un fir de execuție apelează pthread_exit sau este terminat de un alt fir folosind pthread_cancel. În circumstanțe normale, atunci când un fir nu se termină în mod forțat, resursa trebuie eliberată explicit, iar funcția de cleanup nu trebuie să fie apelată.

Pentru a înregistra o astfel de funcție de cleanup se folosește :

void pthread_cleanup_push(void (*routine) (void *), void *arg);

Aceasta funcție primește ca parametri un pointer la funcția care este înregistrată și valoarea argumentului care va fi transmis acesteia. Funcția routine va fi apelată cu argumentul arg atunci când firul este terminat forțat. Daca sunt înregistrate mai multe funcții de cleanup, ele vor fi apelate în ordine LIFO (cea mai recent instalată va fi prima apelată).

Pentru fiecare apel pthread_cleanup_push trebuie să existe și apelul corespunzător pthread_cleanup_pop care deînregistrează o funcție de cleanup:

void pthread_cleanup_pop(int execute);

Această funcție va deînregistra cea mai recent instalată funcție de cleanup, și dacă parametrul execute este nenul o va și executa.

Atentie! Un apel pthread_cleanup_push trebuie să aibă un apel corespunzător pthread_cleanup_pop în aceeași funcție și la același nivel de imbricare.

Un mic exemplu de folosire a funcțiilor de cleanup :

th_cleanup.c
void *alocare_buffer(int size)
{
	return malloc(size);
}
 
void dealocare_buffer(void *buffer)
{
	free(buffer);
}
 
/* functia apelata de un fir de execuție */
 
void functie()
{
	void *buffer = alocare_buffer(512);
 
	/* înregistrarea funcției de cleanup */
	pthread_cleanup_push(dealocare_buffer, buffer);
 
	/* aici au loc prelucrari, și se poate apela pthread_exit
        sau firul poate fi terminat de un alt fir */
 
	/* deînregistrarea functiei de cleanup și execuția ei 
        (parametrul dat este nenul) */
 
        pthread_cleanup_pop(1);
}

==== Atributele unui fir de execuție ====

Atributele reprezintă o modalitate de specificare a unui comportament diferit de comportamentul implicit. Atunci când un fir de execuție este creat cu pthread_create se pot specifica atributele pentru respectivul fir de execuție. Atributele implicite sunt suficiente pentru marea majoritate a aplicațiilor. Cu ajutorul unui atribut se pot schimba:

  • starea: unificabil sau detașabil
  • politica de alocare a procesorului pentru firul de execuție respectiv (round robin, FIFO, sau system default)
  • prioritatea (cele cu prioritate mai mare vor fi planificate, în medie, mai des)
  • dimensiunea și adresa de start a stivei

Mai multe detalii puteți găsi în secțiunea suplimentară dedicată. ==== Cedarea procesorului ====

Un fir de execuție cedează dreptul de execuție unui alt fir, în urma unuia din următoarele evenimente:

  • efectuează un apel blocant (cerere de I/O, sincronizare cu un alt fir de execuție) și kernel-ul decide că este rentabil să facă un context switch
  • i-a expirat cuanta de timp alocată de către kernel
  • cedează voluntar dreptul, folosind funcția sched_yield:
    int sched_yield(void);

Dacă există alte procese interesate de procesor, unul dintre procese va acapara procesorul, iar dacă nu există niciun alt proces în așteptare pentru procesor, firul curent își continuă execuția. ==== Alte operații ==== Dacă dorim să fim siguri că un cod de inițializare se execută o singură dată putem folosi funcția:

pthread_once_t once_control = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *once_control, void (*init_routine) (void));

Scopul funcției pthread_once este de a asigura că o bucată de cod (de obicei folosită pentru inițializări) se execută o singură dată. Argumentul once_control este un pointer la o variabilă inițializată cu PTHREAD_ONCE_INIT. Prima oară când această funcție este apelată ea va apela funcția init_routine și va schimba valoarea variabilei once_control pentru a ține minte că inițializarea a avut loc. Următoarele apeluri ale acestei funcții cu același once_control nu vor face nimic.

Funcția pthread_once întoarce 0 în caz de succes sau cod de eroare în caz de eșec.

Pentru a determina dacă doi identificatori se referă la același fir de execuție se poate folosi:

int pthread_equal(pthread_t thread1, pthread_t thread2);

Pentru aflarea/modificarea priorităților sunt disponibile următoarele apeluri:

int pthread_setschedparam(pthread_t target_thread, int policy, const struct sched_param *param);
int pthread_getschedparam(pthread_t target_thread, int *policy, struct sched_param *param);

==== Compilare ====

La compilare trebuie specificată și biblioteca libpthread (deci se va folosi argumentul -lpthread).

Nu legați un program single-threaded cu această bibliotecă. Anumite apeluri din bibliotecile standard pot avea implementări mai ineficiente sau mai greu de depanat când se utilizează această bibliotecă.

==== Exemplu ====

În continuare, este prezentat un exemplu simplu în care sunt create 2 fire de execuție, fiecare afișând un caracter de un anumit număr de ori pe ecran.

exemplu.c
#include <pthread.h>
#include <stdio.h>
 
/* parameter structure for every thread */
struct parameter {
	char character; /* printed character */
	int number;     /* how many times */
};
 
/* the function performed by every thread */
void* print_character(void *params)
{
	struct parameter *p = (struct parameter *) params;
	int i;
 
	for (i = 0; i < p->number; i++)
		printf("%c", p->character);
	printf("\n");
 
	return NULL;
}
 
int main()
{
	pthread_t fir1, fir2;
	struct parameter fir1_args, fir2_args;
 
	/* create one thread that will print 'x' 11 times */
	fir1_args.character = 'x';
	fir1_args.number = 11;
	if (pthread_create(&fir1, NULL, &print_character, &fir1_args)) {
		perror("pthread_create");
		exit(1);
	}
 
	/* create one thread that will print 'y' 13 times */
	fir2_args.character = 'y';
	fir2_args.number = 13;
	if (pthread_create(&fir2, NULL, &print_character, &fir2_args)) {
		perror("pthread_create");
		exit(1);
	}
 
	/* wait for completion */
	if (pthread_join(fir1, NULL))
		perror("pthread_join");
	if (pthread_join(fir2, NULL))
		perror("pthread_join");
 
	return 0;
}

Comanda utilizată pentru a compila acest exemplu va fi:

gcc -o exemplu exemplu.c -lpthread

====== Exerciţii de laborator ======

===== Exercițiul 0 - Joc interactiv (2p) =====

  • Detalii desfășurare joc.

===== Linux (9p) =====

Pentru rezolvarea laboratorului, va rugam sa clonati repository-ul. daca il aveti deja, va rugam sa rulati git pull.

Pentru a vă ajuta la implementarea exercițiilor din laborator, în directorul utils din arhivă există un fișier utils.h cu funcții utile.

Pentru a instala paginile de manual pentru 'pthreads'

sudo apt-get install manpages-posix manpages-posix-dev

==== Exercițiul 1 - Thread Stack (2p) ====

Intrați în directorul 1-th_stack și inspectați sursa, apoi compilați și rulați programul. Urmăriți cu pmap sau folosind procfs cum se modifică spațiul de adresă al programului:

watch -d pmap $(pidof th_stack)
watch -d cat /proc/$(pidof th_stack)/maps

Zonele de memorie cu dimensiunea de 8MB (8192KB) care se creează după fiecare apel pthread_create reprezintă noile stive alocate de către biblioteca libpthread pentru fiecare thread în parte. Observați că, în plus, se mai mapează de fiecare dată o pagină (4KB) cu protecția ---p (PROT_NONE, private - vizibil în procfs) care are rolul de "pagină de gardă".

Motivul pentru care nu se termină programul este prezența unui ​while(1)​ în funcția thread-urilor. Folosiți Ctrl+C pentru a termina programul. ==== Exercițiul 2 - Fire de execuție vs Procese (2p) ====

Intrați în directorul 2-th_vs_proc și inspectați sursele. Ambele programe simulează un server care creează fire de execuție/procese. Compilați și rulați pe rând ambele programe.

În timp ce rulează, afișați, într-o altă consolă, câte fire de execuție/procese sunt create în ambele situații folosind comanda ps -L -C <nume_program>.

ps -L -C threads
ps -L -C processes

Verificați ce se întâmplă dacă la un moment dat un fir de execuție moare (sau un proces, în funcție de ce executabil testați). Testați utilizând funcția do_bad_task la fiecare al 4-lea fir de execuție/process. ==== Exercițiul 3 - Thread safety (2p) ====

Datorită faptului că mașina virtuală spook are un singur core virtual, exercițiul următor trebuie realizat pe mașina fizică pentru a permite mai multor thread-uri să ruleze în același moment de timp.

Intrați în directorul 3-safety și inspectați sursa malloc.c. Funcțiile thread_function și main NU sunt thread-safe relativ la variabilele global_storage și function_global_storage (revedeți semnificația lui thread safety). Există o condiție de cursă între cele două thread-uri create la incrementarea variabilei function_global_storage, declarată în funcția thread_function, și o altă condiție de cursă între toate thread-urile procesului la incrementarea variabilei globale global_storage.

Un utilitar foarte folositor este helgrind, care poate detecta automat aceste condiții de cursă. Îl putem folosi în cazul nostru așa:

valgrind --tool=helgrind ./mutex

* ne vom concentra pentru rezolvarea TODO1 pe executabilui mutex

TODO 1: Pentru a rezolva aceste doua conditii de cursa apelati functia increase_numbers intr-un mod thread_safe cu ajutor API-ului pus la dispozitie de critical.h

În fișierul malloc.c se creează NUM_THREADS thread-uri care alocă memorie în 1000 runde. Sunt șanse mari ca thread-urile să execute apeluri malloc concurente. După ce a-ti rezolvat TODO1, compilati și rulati de mai multe ori. Observăm că programul rulează cu succes. Pentru a face verificări suplimentare, rulăm din nou helgrind:

valgrind --tool=helgrind ./mutex

Observăm că nici helgrind nu raportează vreo eroare, lucru care conduce la faptul că funcția malloc ar fi thread-safe. (chiar daca acesta nu este protejat de API-ul pus la dispozitie) Pentru a putea fi siguri trebuie să consultăm paginile de manual și codul sursă.

Este important de știut că anumite funcții sunt thread-safe iar altele nu. Găsiți o listă cu funcțiile care nu sunt thread-safe în pagina de manual pthreads(7), în secțiunea Thread-safe functions.

Funcția malloc din implementarea GLIBC este thread-safe, lucru indicat în pagina de manual malloc(3) (al treilea paragraf din secțiunea NOTES) și vizibil în codul sursă prin prezența câmpului mutex în structura malloc_state.

TODO 2: Implementati un spinlock folosindu-va de operatii atomice. Operatiile atomice existente in standardul GCC le gasiti la __atomic functions

In fisierul critical.c trebuie sa completati in dreptul comentarilor asociate TODO 2, avand la dispozitie hint-uri.

Testati si rulati de mai multe ori, pentru a verifica consistenta variabilei globale: global_storage, executabilul ./spin.

==== Exercițiul 4 - Blocked (2p) ====

Inspectați fișierul blocked.c din directorul 4-blocked, compilați și executați binarul (repetați până detectați blocarea programului). Programul creează două fire de execuție care caută un număr magic, fiecare în intervalul propriu (nu este neapărat necesar ca numărul să fie găsit). Fiecare fir de execuție, pentru fiecare valoare din intervalul propriu, verifică dacă este valoarea căutată:

  • dacă da, marchează un câmp found pentru a înștiința și celălalt fir de execuție că a găsit numărul căutat.
  • dacă nu, inspectează câmpul found al structurii celuilalt fir de execuție, pentru a vedea dacă acesta a găsit deja numărul căutat.

Determinați cauza blocării, reparați programul și explicați soluția. Puteți utiliza helgrind, unul din tool-urile valgrind, pentru a detecta problema:

$ valgrind --tool=helgrind ./blocked

Așa cum ne arată și helgrind, problema constă în faptul că cele două thread-uri iau cele două mutex-uri în ordinea inversă, situație foarte probabilă în a cauza un deadlock.

==== Exercițiul 5 - Implementare comportament pthread_once (1p)====

Aveți o funcție de inițializare pe care vreți să o apelați o singură dată. Pornind de la sursa once.c din directorul 5-once, asigurați-vă că funcția init_func este apelată o singură dată. Nu aveți voie să modificați funcția init_func sau să folosiţi pthread_once.

Citiți despre funcționalitatea pthread_once și revedeți secțiunea despre mutex.

==== Exercitiul 6 - Mutex vs Spinlock (1p) ====

Dorim să testăm care varianta este mai eficientă pentru a proteja incrementarea unei variabile.

Intrați în directorul 6-spin, inspectați și compilați sursa spin.c. În urma compilării vor rezulta două executabile, unul care folosește un mutex pentru sincronizare, iar altul un spinlock.

Comparați timpii de execuție:

time ./mutex
time ./spin

Atunci când un fir de execuție găsește mutex-ul ocupat se va bloca. Atunci când un fir de execuție găsește spinlock-ul ocupat va face busy-waiting.

===== Resurse utile =====

LinuxTutorialPosixThreads

POSIX Threads Programming

sde/laboratoare/08_ro_python.1586277301.txt.gz · Last modified: 2020/04/07 19:35 by ioana_maria.culic
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