This is an old revision of the document!
Î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).
.heap
, .data
și .bss
(deci și variabilele stocate în ele)/dev/sda1
).(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.
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:
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).
Din punctul de vedere al implementării, există 3 categorii de fire de execuție:
Î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.
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)
None
;Thread-N
, unde e un numar;target
; parametrii vor fi pasati in ordinea in care functia ii primeste;target
, sub forma numele parametru-valoare;
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()
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.
join
pentru threadurile pe care le generează.
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.
join
pe un thread de tip daemon.
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
, 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 ignoratPentru ș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.
==== 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:
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:
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
).
==== 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.
#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) =====
===== Linux (9p) =====
Pentru rezolvarea laboratorului, va rugam sa clonati repository-ul. daca il aveti deja, va rugam sa rulati git pull
.
utils
din arhivă există un fișier utils.h
cu funcții utile.
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) ====
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
.
helgrind
, care poate detecta automat aceste condiții de cursă. Îl putem folosi în cazul nostru așa:
valgrind --tool=helgrind ./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ă.
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ă:
found
pentru a înștiința și celălalt fir de execuție că a găsit numărul căutat.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
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.
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