Responsabili: Alexandru-Ionuț Mustață, Gabriel Guțu-Robu
Spre deosebire de limbajul C unde implementarea unui thread și a mecanismelor de sincronizare depinde de o bibliotecă ce este specifică unui anumit tip de sistem de operare (Linux, Windows, MacOSX), Java oferă suport pentru lucrul cu thread-urile direct din cadrul SDK său.
În Java există două modalități prin care putem să implementăm un nou thread.
Prima variantă de implementare are la bază crearea unei clase ce implementează interfața Runnable ce conține metoda void run(). Codul ce va reprezenta logica noului thread va fi plasat în interiorul acestei metode.
public class Task implements Runnable { public void run() { System.out.println("Hello from my new thread!"); } }
O altă metodă de implementare a unui thread constă din crearea unei clase ce extinde clasa Thread și suprascrie (overrides) metoda void run() din cadrul acesteia. La fel ca în primul caz, logica noului thread va fi implementată în cadrul acestei metode.
public class MyThread extends Thread { public void run() { System.out.println("Hello from my new thread!"); } }
În cazul în care s-a folosit mecanismul de implementare a interfeței Runnable, atunci pentru a putea crea un thread nou ce va conține logica definită în clasa Task se va crea o instanță a clasei Task ce va fi furnizată ca parametru constructorului clasei Thread. Pentru a rula în paralel noul thread creat cu ajutorul constructorului se va apela metoda public void start() a acestuia.
public class Main { public static void main(String[] args) { Thread t = new Thread(new Task()); t.start(); } }
În cazul în care s-a extins clasa Thread pentru implementarea noului tip de thread se va putea crea un thread nou prin instanțierea directă a clasei MyThread și pentru a se porni excecuția în paralel a acestuia se va apela metoda public void start() moștenită din cadrul clasei Thread.
public class Main { public static void main(String[] args) { MyThread t = new MyThread(); t.start(); } }
Pentru a aștepta terminarea execuției unui thread, Java ne pune la dispoziție metoda public final void join() a clasei Thread. Trebuie ținut cont de faptul că această metodă poate arunca excepții de tipul InterruptedException.
public class Main { public static void main(String[] args) { MyThread t = new MyThread(); t.start(); try { t.join(); } catch (InterruptedException e) { e.printStackTrace(); } } }
Pentru a trimite parametrii unui thread se va folosi constructorul clasei care inglobeaza logica thread-ului pe care dorim sa il implementam (indiferent de metoda de implementare a acestuia - prin moștenire sau implementeare de interfață). Pentru a obține un rezultat de la un thread care și-a terminat execuția (apelul metodei join() a întors) putem folosi fie metode de tip getter care vor întoarce rezultatul fie putem accesa în mod direct câmpul rezultat în cazul în care acest este definit ca public.
public class Task extends Thread { private int id; private int result; public Task(int id) { this.id = id; } public void run() { result = id * id; } public int getResult() { return result; } }
public class Main { public static void main(String[] args) { int NUMBER_OF_THREADS = 4; Thread[] t = new Thread[NUMBER_OF_THREADS]; for (int i = 0; i < NUMBER_OF_THREADS; ++i) { t[i] = new Task(id); t[i].start(); } for (int i = 0; i < NUMBER_OF_THREADS; ++i) { try { t[i].join(); System.out.println("Thread " + i + " computed result " + ((Task)t[i]).getResult() + "."); } catch (InterruptedException e) { e.printStackTrace(); } } } }
Cuvântul rezervat synchronized are rolul de a defini blocuri de cod și metode ce reprezintă secțiuni/regiuni critice.
public class MyConcurrentArray<T> { private static int numberOfInstances = 0; private T[] content; public MyConcurrentArray(int size) { if (size > 0) { content = new T[size]; } else { throw new RuntimeException("Negative size provided for MyConcurrentArray instantiation."); } synchronized(MyConcurrentArray.class) { ++numberOfInstances; } } //Metodă sincronizată. public synchronized T get(int index) { if (index < content.length) { return content[index]; } throw new IndexOutOfBoundsException(index + " is out of bounds for MyConcurrentArray of size " + content.length); } public void set(int index, T newT) { //Bloc de cod sincronizat ce folosește instanța curentă (this) pe post de zăvor. synchronized(this) { if (index < content.length) { content[index] = newT; } throw new IndexOutOfBoundsException(index + " is out of bounds for MyConcurrentArray of size " + content.length); } } //Metodă sincronizată statică. public static synchronized int getNumberOfInstances(){ return numberOfInstances; } public void size() { return content.length; } }
Pentru a putea discuta comportamentul blocurilor de cod sincronizate vom folosi exemplul de mai sus în care este definită o clasă ce implementează sumar conceptul de vector de dimensiune fixă ce poate fi folosit într-un program multithreading (structură de date thread-safe).
Se observă faptul ca metoda get este definită ca fiind synchronized. În momentul când un thread va apela aceasta metodă pe o instanță a clasei MyConcurrentArray va trebui mai întâi să obțină monitorul asociat acesteia (obiectului) pentru a putea executa corpul metodei. Dacă monitorul nu este deținut de nici un alt thread, atunci thread-ul apelant va putea să execute corpul de instrucțiuni al metodei, altfel se va bloca (va aștepta) până când acesta devine disponibil. La încheierea execuției corpului metodei acesta restituie accesul la monitor.
În cazul metodei set este prezent un bloc de instrucțiuni sincronizat. Acesta va folosi monitorul obiectului desemnat între paranteze pentru a oferi thread-ului curent acces excluziv în cadrul regiunii critice. În cazul nostru se folosește instanța curentă a obiectului (this). Mecanismul de intrare și ieșire în/din secțiunea critică este același ca cel prezentat mai sus pentru cazul metodelor sincronizate.
În cazul apelării metodelor sincronizate statice se va încerca obținerea monitorului asociat clasei pentru a executa codul acestora. Acest lucru se întâmplă deoarce o metodă statică aparține clasei, ea nu aparține nici unei instanțe a clasei. De aceea când este necesar accesul exclusiv la un câmp static dintr-o clasă se va folosi clasa în antetul blocului synchronized (MyClass.class, după cum este prezentat și în constructorul din exemplu).
Bariera ciclică reprezintă un mecanism de (re)sincronizare al mai multor thread-uri care are scopul de a bloca un număr specificat de thread-uri și de a le lăsa sa își continue execuția într-un mod sincron după ce toate au apelat metoda acesteia de resincronizare. În Java aceast mecanism este reprezentat de clasa CyclicBarrier. La instanțierea acesteia se va specifica numărul de thread-uri pe care aceasta le va resincroniza. Acest mecanism de sincronizare al thread-urilor este util în cazul algoritmilor iterativi ce sunt rulați în paralel și au nevoie de o etapă de resincronizare a firelor de execuția înainte de a trece la noua iterație de calcul. Trebuie ținut cont de faptul că apelul metodei await() pe bariera ciclică poate arunca o excepție de tipul BrokenBarrierException sau InterruptedException.
public class Task extends Thread { private int id; public Task(int id) { this.id = id; } public void run() { iterationCounter = 0; while (!solved) { executeAlgorithmStep(); try { //Resincronizarea thread-urilor pentru urmatorul pas al algoritmului. Main.barrier.await(); ++iterationCounter; } catch (BrokenBarrierException | InterruptedException e) { e.printStackTrace(); } } } }
public class Main { public static CyclicBarrier barrier; public static void main(String[] args) { int NUMBER_OF_THREADS = 4; barrier = new CyclicBarrier(NUMBER_OF_THREADS); Task[] t = new Task[NUMBER_OF_THREADS]; for (int i = 0; i < NUMBER_OF_THREADS; ++i) { t[i] = new Task(id); t[i].start(); } for (int i = 0; i < NUMBER_OF_THREADS; ++i) { try { t[i].join(); } catch (InterruptedException e) { e.printStackTrace(); } } } }
Cuvântul rezervat volatile asociat unei variabile specifică faptul că acea variabilă nu va intra în procesul de cacheing (nici la nivel de regiștrii, nici la nivel de cache L1, L2, L3) și fiecare scriere și citire asociată acesteia se va realiza direct lucrând cu memoria RAM. Acest lucru este util în prevenția citirii unei date vechi din cache-ul sau registrul asociat core-ului pe care rulează un thread în momentul în care variabila a căpătat o valoare nouă pe alt thread ce lucrează pe alt core.
public static volatile int counter = 0;
În cadrul laboratorului 2 am observat cum operația de incrementare (exemplul cu instrucțiunea a+=2) nu este o operație atomică (operație ce nu poate fi divizată atunci când este executată de către un thread). Java oferă suport pentru o serie de tipuri de date (tipuri atomice) ce au asociate operații atomice [1], [2]. Acestea sunt utile pentru a fi folosite pe post de contori sau acumulatori fără a mai folosi mecanisme de sincronizate.
1. Creați un program care să lanseze un număr de thread-uri egal cu numărul de core-uri de care dispune calculatorul vostru. Fiecare thread trebuie să afișeze la consolă un text de tipul ”Hello from thread #id”.
2. Rezolvați bug-urile prezente în scheletul de laborator. Folosiți-vă de hint-uri din surse.
3. Paralelizați dublarea elementelor unui vector, plecând de la scheletul de laborator, unde aveți versiunea serială.
4. Paralelizați algoritmul Floyd-Warshall, plecând de la scheletul de laborator, unde aveți versiunea serială.