Capitol 10 - Sincronizare

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-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.

Nevoie de acces exclusiv

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

Dacă nu dă eroare, putem actualiza în fișierul 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.

Race condition (TOCTTOU)

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.

Granularitate regiune critică

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).

În general preferăm granularitate fină pentru regiunile critice (adică regiuni critice de mică dimensiune); dar granularitatea fină poate înseamna operații foarte dese pe mutex (lock contention). De aceea este recomandat ca folosirea granularității fine să fie echilibrată de un număr redus de thread-uri care să dorească să acceseze la un moment dat regiunea critică, pentru a genera cât mai puțină încărcare (contention) pe mutex.

Operații atomice și neatomice

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.

Pentru folosirea 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)

x86

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ți
  • sum_threads_atomic: realizează operația în mod atomic pe 64 de biți
  • sum_threads_32: realizează operația în mod neatomic pe 32 de biți
  • sum_threads_atomic_32: realizează operația în mod atomic pe 32 de biți
  • sum_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.

ARM

Pentru ARM trebuie să avem instalate pachetele pentru cross-compiling pe ARM și rulare sub emulatorul QEMU. Pe distribuțiile Debian/Ubuntu, rulăm:

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 neatomic
  • sum_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ță.

Implementarea accesului exclusiv

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 compilatorului
  • lock_spin: folosește spinlock-uri POSIX

NU recompilați fișierele executabile folosind comanda 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.

Consum de timp mutex și spinlock

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.

Deadlock

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.

Pentru a evita apariția deadlock-urilor în momentul în care folosim lock-uri/mutex-uri, o condiție importantă este ca lock-urile să fie achiziționate în aceeași ordine.

Așteptare nedefinită

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.

Buffer circular

Î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
so/curs/sync.txt · Last modified: 2020/05/04 10:56 by alexandru.radovici
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