Responsabili: Radu Ciobanu, Carina Deaconu, Florin Mihalache
Laboratorul trecut, am văzut cum semaforul reprezintă o generalizare a unui mutex. Un semafor iniţializat cu 1 poate fi folosit drept lock, pentru că doar un thread are acces la zona critică la un moment de timp. Totuși, un semafor poate avea întrebuinţări mult mai complexe, deoarece poate lua diverse valori, atât negative, cât şi pozitive.
Când un semafor este iniţializat cu valoarea pozitivă x, vă puteţi gândi că x thread-uri au voie să intre în secţiunea critică. Pe măsură ce un thread face acquire(), x este decrementat. Când se ajunge la 0, alte posibile thread-uri care vor să acceseze regiunea critică vor trebui să aştepte până când valoarea semaforului creşte la o valoare pozitivă (adică până când iese câte un thread din cele aflate în regiunea critică).
Complementar, când un semafor este iniţializat cu o valoare negativă cum ar fi -1, se aşteaptă ca cel puţin două thread-uri să facă întâi release() (pentru ca valoarea semaforului să crească de la -1 la 1), înainte ca o regiune critică să poată fi accesată (adică un alt thread să poată face acquire()). Vă puteţi imagina că un al treilea thread aşteaptă „la semafor” ca alte două thread-uri să îi dea un semnal prin apelul release() când şi-au „terminat treaba”. Puteți observa un exemplu în pseudocodul de mai jos.
Semaphore sem = new Semaphore(-1); |
||
T0 | T1 | T2 |
---|---|---|
// aşteaptă la semafor // după celelalte 2 thread-uri sem.acquire(); // sem = 1, deci poate trece System.out.println( "Am trecut de semafor!"); | do_work1(); sem.release(); // sem = -1 + 1 = 0 | do_work2(); sem.release(); // sem = 0 + 1 = 1 |
Clasa AtomicInteger poate fi gândită ca o implementare atomică a unui Integer, pentru că reţine o valoare de tip întreg și oferă operații atomice pe ea. Poate fi iniţializat cu o valoare sau nu, valoarea default fiind 0.
Metodele cele mai folosite sunt get() (întoarce valoarea de tip întreg), set(x) (îi setează valoarea la x), compareAndSet(x, y) (compară întregul cu x, iar, dacă cele două valori sunt egale, setează valoarea la y), addAndGet(delta) / getAndAdd(delta) (incrementează valoarea curentă cu delta și returnează valoarea nouă, respectiv pe cea veche). Un exemplu de utilizare a acestor metode poate fi observat mai jos.
AtomicInteger nr = new AtomicInteger(4); System.out.println(nr.get()); // 4 if (nr.compareAndSet(4, 7)) System.out.println(nr.get()); // nr devine 7 System.out.println(nr.addAndGet(2)); // se aduna 2, deci devine 9 nr.set(1); System.out.println(nr.get()); // 1
Folosirea AtomicInteger este mai avantajoasă ca performanță (mai precis din punctul de vedere al timpilor de execuție) decât folosirea de locks (blocuri synchronized), deoarece la locks avem overhead creat de acquire() (lock()) și de release() (unlock()), așa cum poate fi observat mai jos.
Java are astfel de implementări atomice precum toate tipurile primitive și nu numai (AtomicBoolean, AtomicLong, AtomicIntegerArray, etc.). Mai multe informații puteți găsi în documentația Java.
ConcurrentHashMap este o clasă care are aceleaşi funcţionalităţi ca un HashTable (nu HashMap, pentru că nu permite folosi null drept cheie sau valoare).
Dacă am folosi un HashMap clasic şi am adăuga un lock pentru accesul la structură, nu ar fi foarte eficient, pentru că doar un singur thread ar putea să o acceseze la un moment de timp, lucru care nu este necesar dacă, de exemplu, ele vor să modifice elemente diferite. Dacă, în schimb, am folosi câte un lock pentru fiecare element, ne-ar trebui destul de multe şi ne-ar îngreuna lucrul.
Într-un ConcurrentHashMap, operaţiile de tip get şi put blochează accesul la un anumit element (au mutexuri în implementarea lor), dar NU blochează accesul la întreaga structură de date. Cu alte cuvinte, două sau mai multe thread-uri o pot accesa în acelaşi timp, şi operaţiile de tip get se pot suprapune cu cele de tip update (put), dar implementarea se asigură că get-urile se execută la final, după ce s-au executat operațiile de update.
Metodele cele mai folosite sunt:
Puteți observa mai jos un exemplu de utilizare.
ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<Integer, String>(); map.put(0, "Zero"); map.put(1, "One"); System.out.println(map.get(1)); // One String val = map.putIfAbsent(1, "Unu"); // nu se modifică nimic, val va fi "One" val = map.putIfAbsent(2, "Two"); // se adaugă maparea 2-"Two", val va fi null
Interfaţa BlockingQueue extinde interfaţa Queue, clasele care o implementează fiind potrivite pentru programe paralele, în care pot exista probleme de concurenţă. Dintre acestea, cele mai relevante sunt ArrayBlockingQueue, LinkedBlockingQueue şi PriorityBlockingQueue. Toate simulează o structură de tip FIFO (coadă), cu operaţii thread-safe.
Diferenţa este la implementarea din spate: ArrayBlockingQueue e bazată pe un array, LinkedBlockingQueue pe o listă înlănţuită, iar pentru PriorityBlockingQueue poate fi specificat un comparator la iniţializare, păstrând o ordine în care să fie elementele. Niciuna dintre clase nu acceptă elemente de tip null şi li se poate specifica o capacitate maximă la iniţializare (pentru ArrayBlockingQueue este obligatoriu acest lucru).
Operaţiile sunt pe principiul FIFO (se inserează la final şi se extrage de la începutul cozii), așa cum se poate observa în tabelul de mai jos.
Operație | Aruncă excepție | Valoare specială | Se blochează | Dă timeout |
---|---|---|---|---|
Inserare | add(e) | offer(e) | put(e) | offer(e, time, unit) |
Ștergere | remove() | poll() | take() | poll(time, unit) |
Examinare | element() | peek() | - | - |
Deşi sunt mai multe metode pentru aceeaşi operaţie asemănătoare, comportamentul lor este diferit. Cea mai importantă diferenţă o constituie proprietatea de a fi sau nu blocantă. De exemplu, add(obj) adaugă un element doar dacă este loc în coadă (adică dacă nu s-a atins capacitatea maximă) şi returnează true dacă operaţia a reuşit, sau IllegalStateException în caz contrar. În schimb, put(obj) se blochează dacă nu este spaţiu suficient în coadă şi aşteaptă până poate pune elementul cu succes.
Dacă dorim să lucrăm cu colecții clasice, precum ArrayList, și să avem operațiile sincronizate, putem să le instanțiem folosind:
List<Integer> syncList = Collections.synchronizedList(new ArrayList<>());
Map<Integer, String> syncMap = Collections.synchronizedMap(new HashMap<>());
Atunci când lucrăm cu structuri mai complexe, trebuie să avem grijă la sincronizarea accesului concurent la ele, dar și la eficiența finală a implementării. Ca să vedem un exemplu concret, vom presupune că avem un arbore binar. Când se dorește inserarea unui element într-un astfel de arbore, se verifică mai întâi dacă nodul unde inserăm are copil în partea stângă. Dacă nu are, atunci nodul copil va fi inserat în stânga, altfel va fi inserat în partea dreaptă:
if (node.left == null) node.left = child; else node.right = child;
Când mai multe thread-uri acționează asupra unui arbore binar, pot apărea probleme de concurență la inserare. Un astfel de scenariu ar putea avea loc atunci când două thread-uri încearcă să insereze simultan câte o valoare diferită în același nod din arbore:
T0 | T1 |
---|---|
Verifică dacă există copil în stânga | Verifică dacă există copil în stânga |
Condiția este adevărată, trece în prima ramură a if-ului | Condiția este adevărată, trece în prima ramură a if-ului |
Inserează nodul în stânga | |
Inserează nodul în stânga → se suprascrie valoarea scrisă de T0 |
În acest caz, corect ar fi ca unul din thread-uri să insereze în stânga și celălalt să insereze în dreapta. O soluție este să folosim un lock pe operația de inserare unui nod. Dacă folosim un lock global (pentru tot arborele), două thread-uri nu pot insera noduri copii în același timp pentru două noduri părinte diferite. Din acest motiv, mai eficient ar fi să existe câte un lock pentru fiecare nod.