Curs 08 - Fire de execuție

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-08-demo.zip

și apoi decomprimăm arhiva

unzip curs-08-demo.zip

și accesăm directorul rezultat în urma decomprimării

cd curs-08-demo/

Acum putem parcurge secțiunile cu demo-uri de mai jos.

Timp de creare procese și thread-uri

Dorim să investigăm timpul de creare a proceselor și thread-urilor; timpul de creare al proceselor va fi mai mare, ne interesează cu cât. Pentru aceasta accesăm subdirectorul creation-time/; urmărim conținutul fișierelor process-overhead.c și thread-overhead.c. În aceste fișiere se creează, în 100 de runde, câte 100 de procese, respectiv thread-uri. Vor fi create, respectiv, 100 de procese și 100 de thread-uri.

Compilăm cele două programe folosind make. Rezultă două fișiere în format executabil: process-overhead și thread-overhead.

Pentru a măsura timpul de creare vom rula cele două executabile sub comanda /usr/bin/time:

/usr/bin/time -v ./process-overhead  > /dev/null
/usr/bin/time -v ./thread-overhead  > /dev/null

Acum urmărim diferențele dintre output-ul celor două comenzi.

Observăm că timpul de creare al proceselor este 2 până la 3 ori mai mare decât în cazul thread-urilor. Și este încă foarte mic ținând cont de faptul că am creat 10000 de procese: 100 runde a câte 100 de procese. Deși timpul de creare a thread-urilor este mai mic, în termeni absoluți ambii timpi sunt neglijabili. Desigur, contează faptul că se realizează doar fork, fără exec. Mecanismul de copy-on-write aferent fork reduce semnificativ timpul de creare.

Observăm, de asemenea, că dimensiunea maximă a spațiului de memorie alocată (Maximum resident set size) este mai mare în cazul thread-urilor. Un thread nou creat ocupă spațiu suplimentar în memorie. Un proces nou creat va avea spațiul virtual creat propriu, dar, pentru început, va folosi spațiul de memorie fizică al primului proces, prin intermediul copy-on-write.

Diferențe sesizabile se observă în cazul numărului de page fault-uri și al schimbărilor de context involuntare. Schimbările de context involuntare (la schimbarea cuantei) sunt mai numeroase în cadrul proceselor întrucât se schimbă contextul procesului curent cu procesele pe care le creează. În cazul thread-urilor nu există schimbări de context între thread-uri, deoarece aparțin aceluiași proces. Numărul de page fault-uri în cadrul proceselor este cauzat de accesele de scriere proceselor noi peste zonele marcate copy-on-write. Sunt relativ puține accese, dar se resimt la nivelul numărului de page fault-uri.

Creare de thread-uri și spațiul de adresă

Dorim să vedem cum afectează crearea thread-urilor spațiul de adresă al procesului. Pentru aceasta accesăm subdirectorul address-space/; urmărim conținutul fișierului address-space.c. În cadrul fișierului se creează 5 thread-uri. La începutul programului și după fiecare creare de thread se așteaptă apăsarea tastei ENTER de utilizator; în această vreme utilizatorul poate inspecta spațiul de adresă. Thread-urile afișează un mesaj simplu și apoi așteaptă 100 de secunde.

Compilăm codul sursă folosind comanda make. Rezultă executabilul address-space.

Rulăm executabilul:

./address-space

Pentru a vizualiza spațiul de adrese al procesului, folosim comanda pmap. Deschidem o altă consolă și rulăm comanda pmap din output-ul căreia eliminăm referințele la biblioteci:

pmap -p $(pidof address-space) | grep -v '/lib/'

Acum se afișează zonele de memorie aferente procesului înainte de crearea unui thread.

Apoi apăsăm ENTER în prima consolă. Acum se va crea un thread.

Investigăm din nou spațiul de adrese al procesului prin rularea comenzii pmap în forma de mai sus în a doua consolă. Observăm apariția unei zone de 8192K (adică 8MB). Apăsăm iarăși ENTER în prima consolă și afișăm spațiul de adrese. Repetăm procesul de încă 3 ori până la crearea tuturor celor 5 thread-uri. Observăm că pentru fiecare thread au fost create două zone: o zonă de 8192K cu permisiuni de citire și scriere și încă o pagină de gardă (4K) fără permisiuni.

Zona de 8192K creată pentru fiecare thread este stiva acelui thread. Se rezervă spațiu virtual pentru stiva fiecărui thread la creare. Observăm că pe un sistem pe 32 de biți, cu dimensiune relativ scăzută a spațiului de adrese, o valoare implicită de 8192K a stivei unui thread va limita numărul de thread-uri care pot fi create, întrucât se umple spațiul de adrese.1)

Partajare informație între procese și thread-uri

Ne propunem să investigăm modul în care thread-urile partajează datele, iar procesele nu. Pentru aceasta accesăm subdirectorul shared-data/; urmărim conținutul fișierelor process.c și thread.c. În ambele fișiere se incrementează o variabilă globală (data_var) în cadrul unui proces nou și într-un thread nou. Variabila globală este inițializată la 0.

Compilăm cele două programe folosind make. Rezultă două fișiere în format executabil: process și thread.

Rulăm cele două executabile:

$ ./process 
data_var = 1
data_var = 1
$ ./thread 
data_var = 1
data_var = 2

Observăm că procesele au o secțiune de date proprie; fiecare incrementare s-a făcut de la 0 la 1. Thread-urile (aceluiași proces) partajează însă secțiunea de date. În acest caz thread-ul nou creat incrementează variabila de la 0 la 1, pe când thread-ul inițial incrementează variabila de la 1 la 2.

Stiva unui thread în spațiul de adresă

Ne propunem să urmărim separația efectivă a stivelor thread-urilor aceluiași proces. Pentru aceasta accesăm subdirectorul stack-access/; urmărim conținutul fișierului stack-access.c. În cadrul fișierului se creează două thread-uri, unul care citește și altul care scrie într-o variabilă.

Programul este făcut în așa fel încât să se întâmple următoarea secvență:

  1. Thread-ul writer doarme preț de 2 secunde.
  2. Thread-ul reader inițializează variabila local_var la valoarea 0x11111111.
  3. Thread-ul reader inițializează variabila globală stack_var_pointer la adresa variabilei local_var.
  4. Thread-ul reader afișează valoarea variabilei local_var.
  5. Thread-ul reader doarme preț de 5 secunde.
  6. Thread-ul writer modifică valorea zonei referite de stack_var_pointer, adică valoarea variabilei local_var la valoarea 0x22222222.
  7. Thread-ul writer își încheie execuția.
  8. Thread-ul reader afișează valoarea variabilei local_var.

Programul de față arată că un thread poate accesa și scrie fără nici o problemă pe stiva altui thread cât timp știe unde este stiva (sau variabila de pe stivă). În cazul de față este vorba de variabila local_var locală thread-ului reader; adresa acestei variabile este stocată în variabila globală de tip pointer stack_var_pointer accesibilă și thread-ului writer.

Pentru a testa programul compilăm folosind make. Obținem fișierul în format executabil stack-access pe care îl rulăm:

$ ./stack-access 
writer: going to sleep for 2 seconds ...
reader: local_var is 0x11111111, local_var address is: 0x7fcaf4f90f4c
reader: going to for 5 seconds ...
writer: write 0x22222222 to reader local_var (address is 0x7fcaf4f90f4c)
writer: end execution
reader: local_var is 0x22222222
reader: end execution

Observăm că, la final, thread-ul reader a afișat valoarea variabilei locale (de pe stiva sa) local_var iar valoarea acesteia este 0x22222222, valoare stabilită de thread-ul writer. Thread-ul writer a reușit să scrie în stiva thread-ului reader. Acest lucru este posibil pentru că thread-urile partajează spațiul de adrese al procesului din care fac parte; deși fiecare thread are stiva sa, accesul unui thread la stiva altuia nu este protejat de sistemul de operare; ambele stive se găsesc în același spațiu de adresă și orice thread le poate modifica pe oricare dintre ele.

Utilitate apeluri reentrante

Dorim să validăm importanța apelurilor reentrante în lucrul cu thread-uri. Pentru aceasta accesăm subdirectorul reentrant/; parcurgem fișierul cod sursă rentrant.c. Fișierul creează NUM_THREADS thread-uri și timp de NUM_THREADS runde apelează funcția nereentrantă ctime sau varianta reentrantă a acesteia ctime_r.

Compilăm programul folosind comanda make. În urma compilării rezultă două fișiere în format executabil:

  • Fișierul reentrant folosește funcția ctime (varianta nereentrantă).
  • Fișierul reentrant-ok folosește funcția ctime_r (varianta reentrantă).

Rulăm cele două executabile cu redirectarea output-ului într-un fișier:

./reentrant > out
./reentrant-ok > out-ok

Urmărim diferențele între cele două fișiere:

$ ls -l
total 9596
[...] 4780850 Apr  8 00:47 out
[...] 4992000 Apr  8 00:48 out-ok

Observăm că fișierul de ieșire în cazul executabilului cu varianta nereentrantă are dimensiunea mai mică, deci absența reentranței a cauzat incoerența datelor obținute. Acest lucru se întâmplă întrucât, în momentul în care un thread folosește funcția printf pentru a afișa bufferul str_time[i] un alt thread îl poate modifica chiar în acel moment, iar datele devin incoerente.

Dacă folosim comanda file vom vedea că se raportează că fișierul out este conținut binar, încă o dovadă a incoerenței informațiilor furnizate:

$ file out
out: data
$ file out-ok 
out-ok: ASCII text

În cazul folosirii programelor cu thread-uri, trebuie folosite versiunile reentrante ale funcțiilor de bibliotecă.

1) Dimensiunea la creare a stivei unui thread poate fi schimbată de la valoarea implicită cu ajutorul apelului pthread_attr_setstacksize.
so/cursuri/curs-08.txt · Last modified: 2017/02/23 16:31 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