Laboratorul 7 - Modelul Replicated Workers

Responsabili: Radu Ciobanu, Carina Deaconu, Florin Mihalache

În programarea paralelă, modelul Replicated Workers (sau Thread Pool) este un design pattern folosit pentru obținerea de concurență în execuția unui program. În acest model, există două componente principale: un pool de sarcini de executat (task-uri), reprezentat în general ca o coadă, și un grup de workeri (care sunt în general thread-uri). Fiecare worker din modelul replicated workers are următorul comportament:

while (1)
    ia un task din pool
    execută task-ul
    adaugă zero sau mai multe task-uri rezultate în pool

Așa cum se poate observa în pseudocodul de mai sus, un worker ia un task din pool și îl execută. Ca urmare a execuției unui task, este posibil să se genereze task-uri adiționale, pe care worker-ul le adaugă în pool, după care repetă pașii precedenți.

Modelul Replicated Workers este util atunci când:

  • nu se cunoaște în avans dimensiunea problemei, adică numărul de pași care se vor executa
  • există multe task-uri de dimensiuni mici care trebuie executate și este mai eficient să se refolosească aceleași thread-uri decât să se creeze unele noi la fiecare pas
  • există posibilitatea ca thread-urile să fie dezechilibrate (de exemplu, unele core-uri sunt mai ocupate, sau chiar diferite între ele, și o împărțire egală a sarcinilor de calcul ar duce la întârzieri în program).

O reprezentare grafică a modelului replicated workers poate fi observată în figura de mai jos.

ExecutorService

ExecutorService este o interfață în Java ce permite executarea de task-uri asincrone, în background, în mod concurent, pe baza modelului Replicated Workers. De exemplu, putem avea nevoie să trimitem un număr de cereri, dar ar fi ineficient să le trimitem pe rând, secvențial, așteptând după fiecare să se termine. Soluția ar fi să lucrăm asincron, adică să trimitem o cerere, să nu așteptăm „după ea” (deci să se trimită în background) și să folosim thread-uri pentru a împărți numărul de cereri și a trimite mai multe deodată (concurent). Acesta ar fi cazul când nu știm în avans dimensiunea problemei pe care o rezolvăm, și fie avem nevoie de toate soluțiile problemei (pentru că trebuie să trimitem toate cererile), fie nu vrem să găsim toate soluțiile, ci minim una.

În continuare, se prezintă un exemplu de program care folosește ExecutorService pentru a afișa toate fișierele dintr-un director (inclusiv din fiecare subdirector în parte). Pentru că nu știm în avans câte fișiere și directoare va trebui să analizăm, putem spune că nu cunoaștem dimensiunea problemei.

import java.io.File;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
 
public class Example1 {
    public static void main(String[] args) {
        AtomicInteger inQueue = new AtomicInteger(0);
        ExecutorService tpe = Executors.newFixedThreadPool(4);
 
        inQueue.incrementAndGet();
        tpe.submit(new MyRunnable("files", tpe, inQueue));
    }
}
 
class MyRunnable implements Runnable {
    String path;
    ExecutorService tpe;
    AtomicInteger inQueue;
 
    public MyRunnable(String path, ExecutorService tpe, AtomicInteger inQueue) {
        this.path = path;
        this.tpe = tpe;
        this.inQueue = inQueue;
    }
 
    @Override
    public void run() {
        File file = new File(path);
        if (file.isFile()) {
            System.out.println(file.getPath());
        } else if (file.isDirectory()) {
            File[] files = file.listFiles();
 
            if (files != null) {
                for (File f : files) {
                    inQueue.incrementAndGet();
                    tpe.submit(new MyRunnable(f.getPath(), tpe, inQueue));
                }
            }
        }
 
        int left = inQueue.decrementAndGet();
        if (left == 0) {
            tpe.shutdown();
        }
    }
}

Așa cum se poate observa în codul de mai sus, instanțierea unui ExecutorService cu 4 workeri se realizează în thread-ul principal. Prin metoda submit(), se introduce un task nou în pool, corespunzător directorului părinte. Metoda submit() poate primi un obiect ce implementează interfața Runnable și conține metoda run() sau unul care implementează interfața Callable și conține metoda call(). Diferența dintre ele este că run() returnează void, iar call() returnează un obiect și poate arunca și o excepție. Cu alte cuvinte, Callable este potrivit când ne interesează rezultatul final și eventualele erori la executarea task-ului. În cadrul laboratorului, vom lucra cu interfața Runnable.

În exemplul de mai sus, clasa noastră ce reprezintă un task se numește MyRunnable. În metoda run, se verifică dacă avem de-a face cu un fișier sau cu un director. Dacă lucrăm pe un fișier, doar se printează numele acestuia. Dacă lucrăm cu un director, înseamnă că trebuie create task-uri noi, pentru fiecare fișier sau subdirector în parte conținut de directorul părinte. Astfel, se creează câte o instanță de MyRunnable pentru fiecare din ele și se adaugă în pool-ul de ExecutorService.

În cazul exemplului prezentat în acest laborator, ne trebuie toate soluțille problemei (toate fișierele și subdirectoarele), dar nu știm dinainte dimensiunea problemei, așa că o metodă prin care ne putem opri este să ținem evidența task-urilor din pool și să finalizăm execuția atunci când acesta se golește. Altfel, programul nostru va rula la infinit. Modul prin care se finalizează execuția unui ExecutorService poate fi observat în codul de mai sus. Mai precis, se folosește metoda shutdown() pentru a opri ExecutorService-ul din a primi task-uri noi. Dacă ne-ar fi interesat o singură soluție a problemei noastre (și nu toate, ca în exemplul de mai sus), am fi putut opri execuția în momentul în care ajungeam la acea soluție.

ForkJoinPool

O altă metodă de a implementa modelul Replicated Workers în Java este framework-ul Fork/Join, care folosește o abordare divide et impera, ceea ce înseamnă că întâi are locul procesul de împărțire (Fork) recursivă a task-ului inițial în subtask-uri independente mai mici, până când acestea sunt suficient de mici cât să poată fi executate asincron. După aceea, urmează partea de colectare recursivă a rezultatelor (Join) într-un singur rezultat (în cazul unui task care nu returnează un rezultat propriu-zis, pur și simplu se așteaptă ca toate subtask-urile să se termine de executat).

Pentru o execuție paralelă eficientă, framework-ul Fork/Join folosește un pool de thread-uri numit ForkJoinPool, care gestionează thread-uri worker de tipul ForkJoinWorkerThread. ForkJoinPool este o implementare de ExecutorService care gestionează thread-uri de tip worker, care pot executa câte un singur task la un moment dat de timp. În implementarea sa, fiecare thread are propria sa coadă de task-uri, dar, atunci când aceasta se golește, worker-ul poate să „fure” task-uri din coada altui worker sau din pool-ul global de task-uri.

Un task care va fi executat folosind framework-ul Fork/Join poate fi definit în două moduri, în funcție de valoarea de retur. Dacă task-ul nu trebuie să returneze nimic, un task se definește prin moștenirea clasei RecursiveAction. Dacă task-ul returnează o valoare de tip V, atunci trebuie să se moștenească clasa RecursiveTask<V> pentru definirea unui task. Ambele clase părinte au o metodă numită compute() în care se definește logica unui task (echivalentul metodei run() de la ExecutorService).

Pentru a adăuga task-uri care trebuie executate de workeri, se poate folosi metoda invoke(), care creează un task și îi așteaptă rezultatul, sau combinația de metode fork() și join(). Pentru al doilea caz, metoda fork() trimite un task în pool, dar nu îl marchează spre execuție, acest lucru făcându-se explicit prin intermediul metodei join(). Atât invoke(), cât și join(), returnează o valoare de tipul V pentru un task de tip RecursiveTask.

Pentru că, în cazul unui task care nu returnează nimic, se poate folosi RecursiveTask<V>, prezentăm mai jos un exemplu complet de implementare care folosește RecursiveTask, pentru aceeași problemă ca în exemplul de ExecutorService.

import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
 
public class Example2 {
    public static void main(String[] args) {
        ForkJoinPool fjp = new ForkJoinPool(4);
        fjp.invoke(new MyTask("files"));
        fjp.shutdown();
    }
}
 
class MyTask extends RecursiveTask<Void> {
    private final String path;
 
    public MyTask(String path) {
        this.path = path;
    }
 
    @Override
    protected Void compute() {
        File file = new File(path);
        if (file.isFile()) {
            System.out.println(file.getPath());
            return null;
        } else if (file.isDirectory()) {
            File[] files = file.listFiles();
            List<MyTask> tasks = new ArrayList<>();
            if (files != null) {
                for (File f : files) {
                    MyTask t = new MyTask(f.getPath());
                    tasks.add(t);
                    t.fork();
                }
            }
            for (MyTask task : tasks) {
                task.join();
            }
        }
 
        return null;
    }
}

În exemplul de mai sus, se pot observa ambele moduri de a crea și submite spre execuție un task nou. În main(), se folosește invoke(), care se va bloca până când toate task-urile s-au terminat, deci până este sigur să apelăm shutdown(). În schimb, în interiorul unui task, se adaugă toate task-urile folosind fork(), și apoi se așteaptă finalizarea lor folosind join().

Exerciții

Hint

În acest laborator, veți lucra cu acest schelet, pe care îl veți utiliza să paralelizați trei probleme pe baza modelului Replicated Workers, folosind ExecutorService și ForkJoinPool:

  • găsirea căilor dintre nodurile unui graf
  • colorarea unui graf → găsiți detalii adiționale aici
  • problema damelor → găsiți detalii adiționale aici.

  1. Paralelizați găsirea căilor între două noduri pe baza scheletului oferit (în pachetul task1) folosind ExecutorService.
  2. Paralelizați problema colorării unui graf pe baza scheletului oferit (în pachetul task2) folosind ExecutorService.
  3. Paralelizați problema damelor pe baza scheletului oferit (în pachetul task3, cu soluțiile [(2, 1), (4, 2), (1, 3), (3, 4)] și [(3, 1), (1, 2), (4, 3), (2, 4)]) folosind ExecutorService.
  4. Paralelizați găsirea căilor între două noduri pe baza scheletului oferit (în pachetul task4) folosind ForkJoinPool.
  5. Paralelizați problema colorării unui graf pe baza scheletului oferit (în pachetul task5) folosind ForkJoinPool.
  6. Paralelizați problema damelor pe baza scheletului oferit (în pachetul task6) folosind ForkJoinPool.
apd/laboratoare/07.txt · Last modified: 2022/11/23 09:50 by radu.ciobanu
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