This is an old revision of the document!


Laboratorul 06 - Structuri și operații atomice în Java

Responsabili: Radu Ciobanu, Carina Deaconu, Florin Mihalache

Semaphore (continuare)

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
 
 

AtomicInteger

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.

Click pentru exemplu

Click pentru exemplu

import java.util.concurrent.atomic.AtomicInteger;
 
public class Main {
    public static final int size = 1000000;
    public static final int noThreads = 8;
    public static int[] arr = new int[size];
    public static final Object lock = new Object();
 
    public static void main(String[] args) {
        for (int i = 0; i < size; i++) {
            arr[i] = i;
        }
 
        Thread[] properThreads = new Thread[noThreads];
        Thread[] atomicThreads = new Thread[noThreads];
 
        long startTime = System.nanoTime();
        for (int i = 0; i < properThreads.length; i++) {
            properThreads[i] = new ProperThread(i);
            properThreads[i].start();
        }
 
        for (Thread properThread : properThreads) {
            try {
                properThread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        long stopTime = System.nanoTime();
        System.out.println("With locks = " + (stopTime - startTime));
 
        startTime = System.nanoTime();
        for (int i = 0; i < atomicThreads.length; i++) {
            atomicThreads[i] = new AtomicThread(i);
            atomicThreads[i].start();
        }
 
        for (Thread atomicThread : atomicThreads) {
            try {
                atomicThread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        stopTime = System.nanoTime();
        System.out.println("With AtomicInteger = " + (stopTime - startTime));
 
        System.out.println("Atomic sum = " + AtomicThread.sum.get());
        System.out.println("Locking sum = " + ProperThread.sum);
    }
}
 
class AtomicThread extends Thread {
    public static AtomicInteger sum = new AtomicInteger(0);
    private final int id;
 
    public AtomicThread(int id) {
        this.id = id;
    }
 
    @Override
    public void run() {
        int start = id * (int) Math.ceil((double) Main.size / Main.noThreads);
        int end = Math.min(Main.size, (id + 1) * (int) Math.ceil((double) Main.size / Main.noThreads));
        for (int i = start; i < end; i++) {
            sum.getAndAdd(Main.arr[i]);
        }
    }
}
 
class ProperThread extends Thread {
    public static int sum = 0;
    private final int id;
 
    public ProperThread(int id) {
        this.id = id;
    }
 
    @Override
    public void run() {
        int start = id * (int) Math.ceil((double) Main.size / Main.noThreads);
        int end = Math.min(Main.size, (id + 1) * (int) Math.ceil((double) Main.size / Main.noThreads));
        for (int i = start; i < end; i++) {
            synchronized (Main.lock) {
                sum += Main.arr[i];
            }
        }
    }
}

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

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:

  • get(key) - returnează valoarea asociată cu o cheie
  • contains(val) - verifică dacă o valoare există în map
  • containsKey(key) - verifică dacă o cheie există în map
  • put(key, val) - adaugă o valoare la cheia specificată
  • putIfAbsent(key, val) - adaugă o asociere doar dacă nu există deja o valoare pentru cheia respectivă; este întoarsă valoarea veche, dacă există, altfel e întors null
  • remove(key) - şterge o asociere cheie-valoare
  • replace(key, val) - modifică valoarea asociată cu o cheie.

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 "Unu"
 
val = map.putIfAbsent(2, "Two"); // se adaugă maparea 2-"Two", val va fi null

BlockingQueue

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.

Collections.synchronized

Dacă dorim să lucrăm cu colecții clasice, precum ArrayList, și să avem operațiile sincronizate, putem să le instanțiem folosind:

  • Collections.synchronizedList (pentru liste):
    List<Integer> syncList = Collections.synchronizedList(new ArrayList<>());
  • Collections.synchronizedMap (pentru dicționare):
    Map<Integer, String> syncMap = Collections.synchronizedMap(new HashMap<>());

Arbori concurenți

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

Exerciții

  1. Pornind de la arhiva de laborator, rezolvați problema de sincronizare din pachetul multipleProducersMultipleConsumers, folosind ArrayBlockingQueue. (2p)
  2. Rezolvați problema de sincronizare din pachetul synchronizationProblem, folosind un AtomicInteger. (2p)
  3. Rezolvați problema de sincronizare din pachetul bugConcurrentHashMap, folosind metode din clasa ConcurrentHashMap. (2p)
  4. Paralelizați programul din pachetul synchronizedSortedList, unde:
    1. trei thread-uri citesc numere din trei fișiere (câte un thread citește câte un fișier) și adaugă numerele din fișiere într-o listă partajată
    2. al patrulea thread sortează lista cu numerele
    3. lista poate fi sortată doar după ce citirea a fost efectuată de către toate cele trei thread-uri care citesc din fișier
    4. sincronizarea între thread-uri trebuie realizată folosind un semafor. (2p)
  5. Paralelizați programul din pachetul parallelTree, unde:
    1. două thread-uri construiesc un arbore binar în paralel, citind ID-urile și părintele nodurilor noi din câte un fișier, și apoi executând operația de inserare
    2. al treilea verifică dacă arborele a fost construit corect
    3. verificarea arborelui nu se poate face decât după finalizarea alcătuirii sale
    4. sincronizarea între thread-uri trebuie realizată folosind o barieră
    5. la inserția în arbore, sincronizarea nu trebuie făcută global (pe tot arborele), ci pentru fiecare nod din arbore. (2p)
apd/laboratoare/06.1605562086.txt.gz · Last modified: 2020/11/16 23:28 by cosmin.stoica
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