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.
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.
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)
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
.
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ță:
2
secunde.local_var
la valoarea 0x11111111
.stack_var_pointer
la adresa variabilei local_var
.local_var
.5
secunde.stack_var_pointer
, adică valoarea variabilei local_var
la valoarea 0x22222222
.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.
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:
reentrant
folosește funcția ctime
(varianta nereentrantă).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ă.
În Java și Python implementările de thread-uri sunt cu suport din partea sistemului de operare. Adică o operație de creare a unui thread rezultă în crearea unui thread în kernel space (kernel-level threads).
Pentru a verifica acest lucru accesăm subdirectorul lang/
unde avem fișierul sursă MultithreadingTest.java
și threading_demo.py
. Pentru a compila fișierul MultithreadingTest.java
folosim comanda make
.
Rularea ambelor programe duce la crearea de a câte unui thread în momentul apăsării tastei ENTER
. Pentru Java rulăm comanda:
$ java MultithreadingTest Press ENTER to create new thread ... Press ENTER to create new thread ... Thread 12 is running Press ENTER to create new thread ... Thread 13 is running Press ENTER to create new thread ... Thread 14 is running Press ENTER to create new thread ... Thread 15 is running [...]
Durează SLEEP_TIME
secunde ca thread-urile să își încheie execuția.
Pentru Python rulăm:
$ python threading_demo.py Press ENTER to create new thread ... Thread 140481023772416 is runningPress ENTER to create new thread ... Thread 140481015379712 is running Press ENTER to create new thread ... Press ENTER to create new thread ... Thread 140481006987008 is running Thread 140480796948224 is running Press ENTER to create new thread ... [...]
La fel, durează SLEEP_TIME
secunde ca thread-urile să își încheie execuția.
Pentru a verifica faptul că sunt într-adevăr create thread-uri noi rulăm în altă terminal comenzile de mai jos pentru Java:
$ ps -efL | grep Multithreading | wc -l 35 $ ps -efL | grep Multithreading | wc -l 36 $ ps -efL | grep Multithreading | wc -l 37 $ ps -efL | grep Multithreading | wc -l 43
Observăm creșterea numărului de thread-uri pe măsură ce le creăm folosind opțiunea -L
a comenzii ps
(pentru lightweight process).
La fel procedăm și pentru Python:
$ ps -efL | grep threading_demo | wc -l 2 $ ps -efL | grep threading_demo | wc -l 4 $ ps -efL | grep threading_demo | wc -l 8
De avut în vedere că în Python, deși thread-urile au suport în kernel și au potențialul de a rula simultan, nu vor face aceste lucru din cauza unui lock global din interpretorul Python numit GIL (Global Interpreter Lock). Este vorba de CPython, intepretorul de referință Python. IronPython nu are GIL și poate rula cu adevărat în paralel thread-uri cu suport de la nivelul nucleului.