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.
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:
for i in $(seq 1 20); do ./thread-list-app; done
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:
for i in $(seq 1 20); do ./thread-list-app-mutex; done
Avem acces exclusiv la datele partajate deci am rezolvat problema coruperii datelor din cauza accesului concurent neprotejat.
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.
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).
Operațiile elementare (aritmetice) sunt în general executate de o singură instrucțiune de procesor (de exemplu instrucțiunea add
sau instrucțiunea sub
). Cu toate acestea, o astfel de operație necesită citirea informației din memorie (data fetch) și apoi scrierea informației în memorie (data write) care sunt executate de instrucțiuni diferite. Dacă apare o întrerupere între aceste operații sau dacă lucrăm pe un sistem multiprocesor, rezultatul va fi imprevizibil: operația nu este atomică.
Pentru ca operația să fie atomică, se folosește suportul hardware care va bloca magistrala de comunicare cu memoria (lock) permițând accesul exclusiv la memorie; așa se întâmplă pe arhitectura x86. Altfel, pe ARM, se folosesc primitive hardware dedicate de acces exclusiv.
Pentru a demonstra operațiile atomice și neatomice pe accesăm subdirectorul sum-threads/
(x86) sau subdirectorul sum-threads-arm
(ARM); urmărim fișierul sum_threads.c
, identic în ambele subdirectoare. În fișier sunt create NUM_THREADS
thread-uri care în NUM_ROUNDS
runde efectuază operația sum += v
, cu ajutorul macro-ului do_op()
. Folosim suportul compilatorului GCC și realizăm atomizarea operației sum += v
cu ajutorul construcției __sync_fetch_and_add()
. Atunci când definim macro-ul USE_ATOMIC
realizăm operația în mod atomic.
perf
va trebui să instalați pachetul corespunzător versiunii de nucleu. Pe o distribuție Debian/Ubuntu, folosiți comanda:
sudo apt install linux-tools-$(uname -r)
Pentru x86, folosim subdirectorul sum-threads/
. La rularea comenzii make
vom obține 6 fișiere executabile:
sum_threads
: realizează operația în mod neatomic pe 64 de bițisum_threads_atomic
: realizează operația în mod atomic pe 64 de bițisum_threads_32
: realizează operația în mod neatomic pe 32 de bițisum_threads_atomic_32
: realizează operația în mod atomic pe 32 de bițisum_threads_32_longlong
: realizează operația în mod neatomic pe 32 biți folosind valori pe 64 de biți (tip long long
)sum_threads_atomic_32_longlong
: realizează operația în mod atomic pe 32 biți folosind valori pe 64 de biți (tip long long
)
La rulare, observăm că doar variantele atomice duc la rezultatul corect (4500000
):
$ ./sum_threads sum is: 682453 $ ./sum_threads sum is: 841578 $ ./sum_threads_atomic sum is: 4500000 $ ./sum_threads_atomic sum is: 4500000 $ ./sum_threads_32 sum is: 541841 $ ./sum_threads_32 sum is: 807242 $ ./sum_threads_atomic_32 sum is: 4500000 $ ./sum_threads_atomic_32 sum is: 4500000 $ ./sum_threads_32_longlong sum is: 1375752 $ ./sum_threads_32_longlong sum is: 627688 $ ./sum_threads_atomic_32_longlong sum is: 4500000 $ ./sum_threads_atomic_32_longlong sum is: 4500000
Ca să vedem diferențele între implementări dezasamblăm codul din executabile și urmărim în funcția thread_func
secvența în limbaj de asamblare pentru sum += v
(în forma neatomică sau în forma atomică dată de construcția __sync_fetch_and_add()
):
$ objdump -d -M intel sum_threads | grep -A 12 '^[0-9a-f]\+ <thread_func>:' 0000000000400737 <thread_func>: [...] 400751: 48 8b 15 18 09 20 00 mov rdx,QWORD PTR [rip+0x200918] # 601070 <sum> 400758: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8] 40075c: 48 01 d0 add rax,rdx 40075f: 48 89 05 0a 09 20 00 mov QWORD PTR [rip+0x20090a],rax # 601070 <sum> $ objdump -d -M intel sum_threads_atomic | grep -A 11 '^[0-9a-f]\+ <thread_func>:' 0000000000400737 <thread_func>: [...] 400751: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8] 400755: f0 48 01 05 13 09 20 lock add QWORD PTR [rip+0x200913],rax # 601070 <sum> 40075c: 00 $ objdump -d -M intel sum_threads_32 | grep -A 11 '[0-9a-f]\+ <thread_func>:' 080485e6 <thread_func>: [...] 80485fb: 8b 15 40 a0 04 08 mov edx,DWORD PTR ds:0x804a040 8048601: 8b 45 fc mov eax,DWORD PTR [ebp-0x4] 8048604: 01 d0 add eax,edx 8048606: a3 40 a0 04 08 mov ds:0x804a040,eax $ objdump -d -M intel sum_threads_atomic_32 | grep -A 9 '[0-9a-f]\+ <thread_func>:' 080485e6 <thread_func>: [...] 80485fb: 8b 45 fc mov eax,DWORD PTR [ebp-0x4] 80485fe: f0 01 05 40 a0 04 08 lock add DWORD PTR ds:0x804a040,eax $ objdump -d -M intel sum_threads_32_longlong | grep -A 16 '[0-9a-f]\+ <thread_func>:' 080485e6 <thread_func>: [...] 80485fc: 8b 4d f4 mov ecx,DWORD PTR [ebp-0xc] 80485ff: bb 00 00 00 00 mov ebx,0x0 8048604: a1 40 a0 04 08 mov eax,ds:0x804a040 8048609: 8b 15 44 a0 04 08 mov edx,DWORD PTR ds:0x804a044 804860f: 01 c8 add eax,ecx 8048611: 11 da adc edx,ebx 8048613: a3 40 a0 04 08 mov ds:0x804a040,eax 8048618: 89 15 44 a0 04 08 mov DWORD PTR ds:0x804a044,edx $ objdump -d -M intel sum_threads_atomic_32_longlong | grep -A 27 '[0-9a-f]\+ <thread_func>:' 080485e6 <thread_func>: [...] 80485fe: 8b 75 ec mov esi,DWORD PTR [ebp-0x14] 8048601: bf 00 00 00 00 mov edi,0x0 8048606: a1 40 a0 04 08 mov eax,ds:0x804a040 804860b: 8b 15 44 a0 04 08 mov edx,DWORD PTR ds:0x804a044 8048611: 89 c1 mov ecx,eax 8048613: 89 d3 mov ebx,edx 8048615: 01 f1 add ecx,esi 8048617: 11 fb adc ebx,edi 8048619: 89 4d d8 mov DWORD PTR [ebp-0x28],ecx 804861c: 89 5d dc mov DWORD PTR [ebp-0x24],ebx 804861f: 8b 5d d8 mov ebx,DWORD PTR [ebp-0x28] 8048622: 8b 4d dc mov ecx,DWORD PTR [ebp-0x24] 8048625: f0 0f c7 0d 40 a0 04 lock cmpxchg8b QWORD PTR ds:0x804a040 804862c: 08 804862d: 0f 94 c1 sete cl 8048630: 84 c9 test cl,cl 8048632: 74 dd je 8048611 <thread_func+0x2b>
În cazul sum_threads
(neatomic, 64 de biți), pentru lucrul cu variabila sum
(rip+0x200918
) se efectuează trei instrucțiuni: data fetch, add și write back. Între cele trei instrucțiuni pot apărea întreruperi și atunci ansamblul de instrucțiuni este neatomic: operația este neatomică.
În cazul sum_threads_atomic
(atomic, 64 de biți), pentru lucrul cu variabila sum
(rip+0x200913
) se efectuează o singură instrucțiune (add
), deci operația este atomică la nivelul procesorului. Pentru cazul multi-core se protejează instrucțiunea cu prefixul lock
care asigură accesul exclusiv la magistrală pe durata instrucțiunii, compusă din ciclul clasic: data fetch, add și writeback. În felul acesta operația este atomică inclusiv în context multi-core.
În cazul sum_threads_32
(neatomic, 32 de biți), pentru lucrul cu variabila sum
(ds:0x804a040
) se efectuează trei instrucțiuni la fel în cazul sum_threads
: operația este neatomică.
În cazul sum_threads_atomic_32
(atomic, 32 de biți), pentru lucrul cu variabila sum
(ds:0x804a040
) se efectuează o singură instrucțiuni (add
) protejată de prefixul lock
pentru cazul multi-core: operația este atomică inclusiv în context multi-core.
În cazul sum_threads_32_longlong
(neatomic, 32 de biți), pentru lucrul cu variabila sum
(ds:0x804a040
, 8 octeți) se folosesc perechi de registre pe 32 de biți pentru a efectua operații pe 64 de biți (tipul de date long long
). Sunt folosite registrele edx:eax
pentru reține suma (sum
) și registrele ebx:ecx
pentru a reține valoarea (val
) apoi sunt adunate două câte două (edx
cu ebx
și eax
cu ecx
). Sunt multe operații între care poate apărea o instrucțiune și atunci ansamblul de instrucțiuni este neatomic: operația este neatomică.
În cazul sum_threads_atomic_32_longlong
(neatomic, 32 de biți), pentru lucrul cu variabila sum
(ds:0x804a040
, 8 octeți) se folosesc perechi de registre pe 32 de biți pentru a efectua operații pe 64 de biți (tipul de date long long
). Sunt folosite registrele edx:eax
pentru reține suma (sum
), registrele edi:esi
pentru a reține valoarea (val
) apoi sunt adunate două câte două în registrele ebx:ecx
(ebx ← edx + edi
, ecx ← eax + esi
). Sunt multe operații între care poate apărea o instrucțiune și atunci ansamblul de instrucțiuni este neatomic. Punctul cheie este folosirea instrucțiunii cmpxchg8b. Instrucțiunea compară în mod atomic valoarea sumei din memorie (ds:0x804a040
) cu suma reținută în registre (edx:eax
). Dacă acestea sunt identice (adică dacă NU au fost modificări ale sumei în memorie - de alte thread / procesor), atunci se încarcă în memorie noua valoare, din registrele ebx:ecx
. Astfel avem suma nou calculată. În același timp se activează ZF
(zero flag); dacă ZF a fost activat, operația a reușit. Altfel, se reîncearcă operația prin salt la adresa de început a secvenței (0x8048611
). În final, după eventual mai multe încercări, operația va reuși atomic. Operația cmpxchg8b
este protejată de prefixul lock
pentru cazul multi-core: operația este atomică inclusiv în context multi-core.
Pentru a verifica impactul de performanță dat de operațiile atomice, putem folosi perf
:
$ sudo perf stat ./sum_threads sum is: 974359 Performance counter stats for './sum_threads': 22.408346 task-clock (msec) # 5.670 CPUs utilized 4 cpu-migrations # 0.179 K/sec 85 page-faults # 0.004 M/sec 62,480,526 cycles # 2.788 GHz 8,715,598 instructions # 0.14 insn per cycle 1,328,346 branches # 59.279 M/sec 14,605 branch-misses # 1.10% of all branches 0.003952352 seconds time elapsed $ sudo perf stat ./sum_threads_atomic sum is: 4500000 Performance counter stats for './sum_threads_atomic': 116.167475 task-clock (msec) # 6.852 CPUs utilized 9 context-switches # 0.077 K/sec 4 cpu-migrations # 0.034 K/sec 81 page-faults # 0.697 K/sec 428,643,912 cycles # 3.690 GHz 6,810,381 instructions # 0.02 insn per cycle 1,345,633 branches # 11.584 M/sec 15,216 branch-misses # 1.13% of all branches 0.016954975 seconds time elapsed $ sudo perf stat ./sum_threads_32 sum is: 899485 Performance counter stats for './sum_threads_32': 21.302230 task-clock (msec) # 5.606 CPUs utilized 8 context-switches # 0.376 K/sec 4 cpu-migrations # 0.188 K/sec 61 page-faults # 0.003 M/sec 78,517,636 cycles # 3.686 GHz 8,510,544 instructions # 0.11 insn per cycle 1,293,122 branches # 60.704 M/sec 13,379 branch-misses # 1.03% of all branches 0.003799853 seconds time elapsed $ sudo perf stat ./sum_threads_atomic_32 sum is: 4500000 Performance counter stats for './sum_threads_atomic_32': 134.351733 task-clock (msec) # 7.151 CPUs utilized 8 context-switches # 0.060 K/sec 3 cpu-migrations # 0.022 K/sec 62 page-faults # 0.461 K/sec 453,735,505 cycles # 3.377 GHz 6,725,869 instructions # 0.01 insn per cycle 1,331,426 branches # 9.910 M/sec 14,755 branch-misses # 1.11% of all branches 0.018787564 seconds time elapsed $ sudo perf stat ./sum_threads_32_longlong sum is: 1661489 Performance counter stats for './sum_threads_32_longlong': 33.744994 task-clock (msec) # 6.420 CPUs utilized 8 context-switches # 0.237 K/sec 3 cpu-migrations # 0.089 K/sec 60 page-faults # 0.002 M/sec 124,455,183 cycles # 3.688 GHz 12,602,237 instructions # 0.10 insn per cycle 1,309,271 branches # 38.799 M/sec 13,912 branch-misses # 1.06% of all branches 0.005256141 seconds time elapsed $ sudo perf stat ./sum_threads_atomic_32_longlong sum is: 4500000 Performance counter stats for './sum_threads_atomic_32_longlong': 649.797615 task-clock (msec) # 7.242 CPUs utilized 25 context-switches # 0.038 K/sec 4 cpu-migrations # 0.006 K/sec 61 page-faults # 0.094 K/sec 2,329,594,557 cycles # 3.585 GHz 61,285,022 instructions # 0.03 insn per cycle 5,775,210 branches # 8.888 M/sec 1,012,473 branch-misses # 17.53% of all branches 0.089722917 seconds time elapsed
Observăm că durata și numărul de cicli de procesor sunt mai mari în cazul atomic decât în cazul atomic, foarte evident în cazul operației atomiei pe 32 de biți cu numere pe 64 de biți unde se poate repeta de mai multe ori secvența de adunare pentru a fi corect rezultatul.
sudo apt-get install gcc make gcc-arm-linux-gnueabi binutils-arm-linux-gnueabi libc6-armel-cross libc6-dev-armel-cross sudo apt-get install qemu-system-arm qemu-user
Pentru ARM, folosim subdirectorul sum-threads-arm/
. La rularea comenzii make
vom obține 2 fișiere executabile:
sum_threads
: realizează operația în mod neatomicsum_threads_atomic
: realizează operația în mod atomic
La rulare, observăm că doar variantele atomice duc la rezultatul corect (4500000
):
$ ./sum_threads sum is: 1333911 $ ./sum_threads sum is: 1020572 $ ./sum_threads_atomic sum is: 4500000
Ca să vedem diferențele între implementări dezasamblăm codul din executabile și urmărim în funcția thread_func
secvența în limbaj de asamblare pentru sum += v
(în forma neatomică sau în forma atomică dată de construcția __sync_fetch_and_add()
):
$ arm-linux-gnueabi-objdump -d sum_threads | grep -A 15 '^[0-9a-f]\+ <thread_func>:' 000102dc <thread_func>: [...] 10300: e59f3040 ldr r3, [pc, #64] ; 10348 <thread_func+0x6c> 10304: e5932000 ldr r2, [r3] 10308: e51b3008 ldr r3, [fp, #-8] 1030c: e0823003 add r3, r2, r3 10310: e59f2030 ldr r2, [pc, #48] ; 10348 <thread_func+0x6c> 10314: e5823000 str r3, [r2] $ arm-linux-gnueabi-objdump -d sum_threads_atomic | grep -A 13 '^[0-9a-f]\+ <thread_func>:' 000102dc <thread_func>: [...] 10300: e59f3034 ldr r3, [pc, #52] ; 1033c <thread_func+0x60> 10304: e51b1008 ldr r1, [fp, #-8] 10308: e1a00003 mov r0, r3 1030c: eb00181d bl 16388 <__sync_fetch_and_add_4> $ arm-linux-gnueabi-objdump -d sum_threads_atomic | grep -A 14 '^[0-9a-f]\+ <__sync_fetch_and_add_4>:' 00016388 <__sync_fetch_and_add_4>: 16388: e92d41f0 push {r4, r5, r6, r7, r8, lr} 1638c: e1a05000 mov r5, r0 16390: e1a07001 mov r7, r1 16394: e59f6020 ldr r6, [pc, #32] ; 163bc <__sync_fetch_and_add_4+0x34> 16398: e5954000 ldr r4, [r5] 1639c: e1a02005 mov r2, r5 163a0: e0841007 add r1, r4, r7 163a4: e1a00004 mov r0, r4 163a8: e12fff36 blx r6 163ac: e3500000 cmp r0, #0 163b0: 1afffff8 bne 16398 <__sync_fetch_and_add_4+0x10> 163b4: e1a00004 mov r0, r4 163b8: e8bd81f0 pop {r4, r5, r6, r7, r8, pc} 163bc: ffff0fc0 .word 0xffff0fc0
În cazul sum_threads
(neatomic), pentru lucrul cu variabila sum
(adresa 0x10348
) se efectuează trei instrucțiuni: data fetch(ldr
), add și write back (str
). Între cele trei instrucțiuni pot apărea întreruperi și atunci ansamblul de instrucțiuni este neatomic: operația este neatomică.
În cazul sum_threads_atomic
(atomic), pentru lucrul cu variabila sum
(adresa 0x1033c
) se apelează funcția __sync_fetch_and_add_4()
unde are loc toată magia. Funcția are un comportament similar implementării sincronizării pentru sum_thread_atomic_32_longlong
pentru x86. Partea cea mai interesantă este apelul blx r6
care duce la execuția codului de la adresa 0xffff0fc0
(r6
are valoarea 0x163bc
). La acea adresă se găsește un cod din kernel space accesibil user space, numit cod kernel user helper, anume implementarea funcției __kuser_cmpxchg
. În contextul funcției __sync_fetch_and_add_4()
, aceasta compară valoarea registrului r4
(suma inițială) cu ce se găsește la adresa indicată de r5
(suma din memorie); dacă sunt egale, pune la valorea indicată de registrul r5
noua sumă, din registrul r1
. Dacă operația a reușit, în registrul r0
se plasează valoarea 0
. Dacă nu a reușit, se reîncearcă operația prin intermediul instrucțiunii bne 16398
. În felul acesta operația este atomică.
Întrucât acest cod rulează emulat, nu este atât de relevantă comparația timpilor de rulare. Dar putem investiga folosind comanda time
:
$ /usr/bin/time -v ./sum_threads sum is: 1306038 Command being timed: "./sum_threads" User time (seconds): 0.09 System time (seconds): 0.00 Percent of CPU this job got: 333% Elapsed (wall clock) time (h:mm:ss or m:ss): 0:00.02 Average shared text size (kbytes): 0 Average unshared data size (kbytes): 0 Average stack size (kbytes): 0 Average total size (kbytes): 0 Maximum resident set size (kbytes): 9380 Average resident set size (kbytes): 0 Major (requiring I/O) page faults: 0 Minor (reclaiming a frame) page faults: 1041 Voluntary context switches: 1015 Involuntary context switches: 3 Swaps: 0 File system inputs: 0 File system outputs: 0 Socket messages sent: 0 Socket messages received: 0 Signals delivered: 0 Page size (bytes): 4096 Exit status: 0 $ /usr/bin/time -v ./sum_threads_atomic sum is: 4500000 Command being timed: "./sum_threads_atomic" User time (seconds): 42.39 System time (seconds): 45.72 Percent of CPU this job got: 449% Elapsed (wall clock) time (h:mm:ss or m:ss): 0:19.60 Average shared text size (kbytes): 0 Average unshared data size (kbytes): 0 Average stack size (kbytes): 0 Average total size (kbytes): 0 Maximum resident set size (kbytes): 9420 Average resident set size (kbytes): 0 Major (requiring I/O) page faults: 0 Minor (reclaiming a frame) page faults: 1051 Voluntary context switches: 18502292 Involuntary context switches: 588 Swaps: 0 File system inputs: 0 File system outputs: 0 Socket messages sent: 0 Socket messages received: 0 Signals delivered: 0 Page size (bytes): 4096 Exit status: 0
Observăm o durată mult mai mare pentru varianta atomică. Operațiile atomice serializează secvențe de cod și au impact de performanță.
Dorim să investigăm diferite moduri de a realiza accesul exclusiv în modul busy waiting pentru crearea unei regiuni critice. Pentru aceasta accesăm subdirectorul lock/
; urmărim conținutul fișierului lock.c
unde avem implementate 3 forme de locking: busy waiting simplu dar incorect (simple_lock()
și simple_unlock()
), busy waiting folosind suportul compilatorului (cu funcția _sync_bool_compare_and_swap()
) și busy waiting folosind spinlock-uri POSIX.
În director se găsesc deja compilate 3 fișiere executabile:
lock
: folosește implementarea simplălock_atomic
: folosește suportul compilatoruluilock_spin
: folosește spinlock-uri POSIX
make
pentru a nu modifica fișierele. Vom dezasambla codul și atunci ne sunt utile informațiile neschimbate (adrese).
La rularea celor trei executabile vedem că implementarea simplă este incorectă, în vreme ce implementările celelalte sunt corecte:
$ ./lock sum is: 1042179 $ ./lock sum is: 1173743 $ ./lock_atomic sum is: 4500000 $ ./lock_atomic sum is: 4500000 $ ./lock_spin sum is: 4500000 $ ./lock_spin sum is: 4500000
Implementarea simplă este incorectă pentru că între comparația din bucla while
din funcția simple_lock()
și atribuirea glock = 1
programul poate fi întrerupt.
Ca să vedem impactul de performanță al fiecărei implementări putem folosi perf
:
$ sudo perf stat ./lock sum is: 1531839 Performance counter stats for './lock': 71.512060 task-clock (msec) # 6.824 CPUs utilized 15 context-switches # 0.210 K/sec 4 cpu-migrations # 0.056 K/sec 81 page-faults # 0.001 M/sec 263,849,410 cycles # 3.690 GHz 26,814,836 instructions # 0.10 insn per cycle 6,355,162 branches # 88.868 M/sec 32,210 branch-misses # 0.51% of all branches 0.010480000 seconds time elapsed $ sudo perf stat ./lock_atomic sum is: 4500000 Performance counter stats for './lock_atomic': 2267.178750 task-clock (msec) # 7.680 CPUs utilized 58 context-switches # 0.026 K/sec[K 7 cpu-migrations # 0.003 K/sec 81 page-faults # 0.036 K/sec 8,338,793,835 cycles # 3.678 GHz 150,365,398 instructions # 0.02 insn per cycle 23,652,236 branches # 10.432 M/sec 959,916 branch-misses # 4.06% of all branches 0.295213041 seconds time elapsed $ sudo perf stat ./lock_spin sum is: 4500000 Performance counter stats for './lock_spin': 841.995688 task-clock (msec) # 7.698 CPUs utilized 33 context-switches # 0.039 K/sec 5 cpu-migrations # 0.006 K/sec 45 page-faults # 0.053 K/sec 2,992,569,499 cycles # 3.554 GHz 76,846,679 instructions # 0.03 insn per cycle 32,136,591 branches # 38.167 M/sec 1,832,402 branch-misses # 5.70% of all branches 0.109379961 seconds time elapsed
Așa cum era de așteptat, implementarea simplă (dar incorectă) este rapidă. Implementările corecte sunt mai încete cu o performanță mai ridicată pentru implementarea cu spinlock-uri POSIX.
Pentru detalii, dezasamblăm codul celor 3 executabile:
$ objdump -d -M intel lock | grep -A 8 '[0-9a-f]\+ <simple_lock>:' 00000000004009bd <simple_lock>: [...] 4009c2: 8b 05 e8 16 20 00 mov eax,DWORD PTR [rip+0x2016e8] # 6020b0 <glock> 4009c8: 85 c0 test eax,eax 4009ca: 75 f6 jne 4009c2 <simple_lock+0x5> 4009cc: c7 05 da 16 20 00 01 mov DWORD PTR [rip+0x2016da],0x1 # 6020b0 <glock> 4009d3: 00 00 00 $ objdump -d -M intel lock_atomic | grep -A 11 '[0-9a-f]\+ <atomic_lock>:' 0000000000400966 <atomic_lock>: [...] 40096b: b8 00 00 00 00 mov eax,0x0 400970: ba 01 00 00 00 mov edx,0x1 400975: f0 0f b1 15 33 17 20 lock cmpxchg DWORD PTR [rip+0x201733],edx # 6020b0 <glock> 40097c: 00 40097d: 0f 94 c0 sete al 400980: 83 f0 01 xor eax,0x1 400983: 84 c0 test al,al 400985: 75 e4 jne 40096b <atomic_lock+0x5> $ objdump -d -M intel lock_spin | grep -A 7 '[0-9a-f]\+ <spin_lock>:' 0000000000400a03 <spin_lock>: 400a03: 55 push rbp 400a04: 48 89 e5 mov rbp,rsp 400a07: bf f4 ab 6d 00 mov edi,0x6dabf4 400a0c: e8 5f 3f 00 00 call 404970 <pthread_spin_lock> 400a11: 90 nop 400a12: 5d pop rbp 400a13: c3 ret $ objdump -d -M intel lock_spin | grep -A 4 '[0-9a-f]\+ <pthread_spin_lock>:' 0000000000404970 <pthread_spin_lock>: 404970: f0 ff 0f lock dec DWORD PTR [rdi] 404973: 75 0b jne 404980 <pthread_spin_lock+0x10> 404975: 31 c0 xor eax,eax 404977: c3 ret
În cazul implementării simple (dar incorecte), funcția simple_lock()
conține mai multe instrucțiuni (atribuire, comparație, salt); o întrerupere între acestea va duce la rezultate inconsecvente, de unde implementarea incorectă (neatomică).
În cazul suportului de compilator (folosind __sync_bool_compare_and_swap
) implementarea este similară cu sum_threads_atomic_32_longlong
cu o comparație atomică lock cmpxhcg
și apoi reluarea secvenței dacă între timp zona de memorie s-a modificat.
În cazul spinlock-ului POSIX, implementarea este mai simplă de unde și performanța mai bună față de cazul suportului în compilator. Funcția spin_lock()
apelează funcția pthread_spin_lock()
. Aceasta din urmă decrementează valoarea din variabila care reprezintă spinlock-ul (de la adresa indicată de registrul rdi
- o valoare întreagă). Doar în cazul în care această valoare era 1
și ajunge în cazul decrementării la 0
se va ieși din bucla funcției; altfel se va bucla continuu până când un thread va elibera spinlock-ul prin atribuirea valorii 1 spinlock-ului.
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 -v ./spinlock /usr/bin/time -v ./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.
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.
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.
În multe dintre cazurile în care există entități de tipul producător și entități de tipul consumator, structura folosită este un buffer circular (ring buffer). Într-un astfel de buffer producătorii folosesc un capăt al buffer-ului, iar consumatorii celălalt capăt.
O implementare de buffer circular, cu sincronizarea aferentă, se găsește în subdirectorul ring-buffer/
. Structura buffer-ului circular (struct rbuf
) este declarată în fișierul header ring_buffer.h
, împreună cu declarațiile funcțiilor. Implementarea funcțiilor se găsește în fișierul cod sursă ring_buffer.c
.
În fișierul cod sursă producer_consumer.c
se creează NUM_CONSUMERS
thread-uri de tip consumator și NUM_PRODUCERS
thread-uri de tip producător. În timp de NUM_ROUNDS
runde, aceste thread-uri consumă, respectiv produc câte un element folosind buffer-ul circular.
Compilăm folosind make
și obținem fișierul executabil producer_consumer
. Rulăm acest executabil pentru a urmări folosirea cu succes a buffer-ului circular sincronizat:
./producer_consumer