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

Future și CompletableFuture

Future

Future<V> reprezintă o interfață în Java, cu ajutorul căreia putem crea task-uri de tip promisiuni, care să fie executate în mod asincron, ele oferind niște rezultate. După ce execuția task-urilor este gata, rezultatele task-urilor pot fi prelucrate.

Folosind Future și CompletableFuture (despre care vom vorbi mai jos), putem executa operații intensiv computaționale sau care au o durată destul de mare. Exemple: descărcare de fișiere mari, manipulare de structuri mari de date, servicii web, etc.

Interfața Future conține următoarele metode:

  • get() - se așteaptă terminarea unui task, apoi se obține rezultatul execuției task-ului respectiv
  • isDone() - se verifică dacă s-a terminat execuția task-ului
  • cancel() - se întrerupe execuția unui task
  • isCancelled() - se verifică dacă execuția unui task a fost întreruptă

Putem folosi Future împreună cu ExecutorService, care se ocupă de execuția task-urilor, folosind metoda submit, care primește ca parametru un obiect de tip Callable, care reprezintă task-ul care va fi executat de către ExecutorService și al cărui rezultat va fi încapsulat într-un obiect de tip Future, mai precis într-un obiect de tip FutureTask, care reprezintă o clasă ce implementează interfața Future.

Exemplu:

public class DoubleCalculator {
    private final ExecutorService executorService = Executors.newCachedThreadPool();
 
    public Future<Integer> calculate(int input) {
        return executorService.submit(() -> {
            Thread.sleep(1000);
            return 2 * input;
        });
    }
 
    public void shutdown() {
        executorService.shutdown();
    }
}
 
public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        DoubleCalculator doubleCalculator = new DoubleCalculator();
        Future<Integer> future = doubleCalculator.calculate(10);
 
        while(!future.isDone()) {
            System.out.println("Calculating...");
            Thread.sleep(300);
        }
 
        Integer result = future.get();
        System.out.println(result);
 
        doubleCalculator.shutdown();
    }
}

De asemenea, putem folosi ForkJoinPool în lucrul cu Future, având în vedere faptul că RecursiveTask<V> implementează interfața Future, cu ajutorul căreia putem prelucra rezultatele task-urilor.

Exemplu:

public class FibonacciCalculator extends RecursiveTask<Integer> {
    private final int n;
 
    public FibonacciCalculator(int n) {
        this.n = n;
    }
 
    @Override
    protected Integer compute() {
        if (n == 1) {
            return 0;
        }
 
        if (n == 2) {
            return 1;
        }
 
        FibonacciCalculator first = new FibonacciCalculator(n - 1);
        FibonacciCalculator second = new FibonacciCalculator(n - 2);
 
        first.fork();
        second.fork();
 
        return first.join() + second.join();
    }
}
 
public class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        FibonacciCalculator calculator = new FibonacciCalculator(10);
        forkJoinPool.execute(calculator);
        System.out.println(calculator.get());
    }
}

CompletableFuture

CompletableFuture<V> reprezintă o clasă care implementează interfața Future. Ce aduce această clasă în plus este o funcționalitate pentru terminarea (completarea) unui task de tip Future, prin metoda complete(V value), prin care se pune valoarea creată în cadrul unui obiect de tip CompletableFuture, valoare care va putea fi accesată, după terminarea execuției task-ului, folosind metoda get().

În mod similar ca la Future, putem folosi ExecutorService împreună cu CompletableFuture.

Exemplu:

public class DoubleCalculator {
    private final ExecutorService executorService = Executors.newCachedThreadPool();
 
    public Future<Integer> calculate(int input) {
        CompletableFuture<Integer> task = new CompletableFuture<>();
        executorService.submit(() -> {
            Thread.sleep(1000);
            task.complete(2 * input);
            return null;
        });
 
        return task;
    }
 
    public void shutdown() {
        executorService.shutdown();
    }
}
 
public class Main {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        DoubleCalculator doubleCalculator = new DoubleCalculator();
        Future<Integer> future = doubleCalculator.calculate(10);
 
        Integer result = future.get();
        System.out.println(result);
 
        doubleCalculator.shutdown();
    }
}

Putem să folosim CompletableFuture într-un mod mai elegant, fără să mai fie nevoie să folosim în mod explicit ExecutorService. În acest caz, putem folosi metodele runAsync și supplyAsync. Diferențele dintre aceste două metode sunt următoarele:

  • runAsync primește ca parametru un Runnable, iar supplyAsync primește ca parametru un Supplier<T>, care reprezintă o funcție care nu primește niciun parametru și întoarce un rezultat de tip T
  • runAsync întoarce un CompletableFuture<Void>, ceea ce înseamnă că task-ul nu va întoarce un rezultat, iar supplyAsync întoarce un CompletableFuture<T>, în acest caz putând accesa rezultatul oferit în urma execuției task-ului

Exemple:

int n = 10;
CompletableFuture<Integer> calculatorTask = CompletableFuture.supplyAsync(() -> n * 2);
System.out.println(calculatorTask.get());
 
CompletableFuture<Void> printTask = CompletableFuture.runAsync(() -> System.out.println("Running..."));
printTask.get();

Un avantaj pe care îl prezintă CompletableFuture este faptul că putem să aplicăm operații pentru prelucrarea rezultatului unui task, cu scopul să obținem un Future cu un rezultat final sau cu o acțiune finală, și să facem execuții înlănțuite de task-uri.

Când dorim să aplicăm operații pentru prelucrarea rezultatului unui task, putem folosi următoarele metode:

  • thenApply - aplică o funcție de tip Function (funcție care întoarce un rezultat) pe un CompletableFuture<V> și întoarce un CompletableFuture<T>, care conține un rezultat
  • thenAccept - aplică o funcție de tip Consumer (funcție de tip void), și întoarce un CompletableFuture<Void>

Dacă nu dorim să prelucrăm rezultatul unui task (posibil să nu ne intereseze), ci doar să apelăm funcții în lanț, în acest caz putem folosi thenRun, care aplică o funcție de tip Consumer și care întoarce un CompletableFuture<Void>.

Exemple de thenApply, thenAccept și thenRun:

CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> f1 = cf1.thenApply(s -> s + " World");
System.out.println(f1.get());
 
CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<Void> f2 = cf2.thenAccept(s -> System.out.println("Computation returned: " + s));
f2.get();
 
CompletableFuture<String> cf3 = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<Void> f3 = cf3.thenRun(() -> System.out.println("Computation finished."));
f3.get();

Dacă dorim să executăm mai multe CompletableFuture în lanț, în acest caz putem folosi metoda thenCompose, care execută o funcție de tip Function și întoarce un CompletableFuture<T>.

Exemplu:

CompletableFuture<String> cf4 = CompletableFuture.supplyAsync(() -> "Hello")
    .thenCompose(s -> CompletableFuture.supplyAsync(() -> s + " World"))
    .thenCompose(s -> CompletableFuture.supplyAsync(() -> s + "!"));
System.out.println(cf4.get());

Putem folosi CompletableFuture împreună cu ExecutorService, de exemplu pentru căutarea unui fișier într-un director, care conține o ierarhie de fișiere și directoare:

class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService tpe = Executors.newFixedThreadPool(4);
	CompletableFuture<String> completableFuture = new CompletableFuture<>();
	AtomicInteger counter = new AtomicInteger(0);
	counter.incrementAndGet();
	tpe.submit(new MyRunnable(tpe, "files", "somefile.txt", counter, completableFuture));
 
	var result = completableFuture.get();
	if (result != null) {
	    System.out.println("File was found at this path: " + result);
	} else {
	    System.out.println("File was not found");
	}
    }
}
 
public class MyRunnable implements Runnable {
    private final ExecutorService tpe;
    private final String path;
    private final String filename;
    private final AtomicInteger counter;
    private final CompletableFuture<String> completableFuture;
 
    public MyRunnable(
        ExecutorService tpe,
	String path,
	String filename,
        AtomicInteger counter,
	CompletableFuture<String> completableFuture
    ) {
        this.tpe = tpe;
	this.path = path;
	this.filename = filename;
        this.counter = counter;
	this.completableFuture = completableFuture;
    }
 
    @Override
    public void run() {
        File file = new File(path);
	if (file.isFile()) {
	    if (file.getName().equals(filename)) {
	        completableFuture.complete(file.getAbsolutePath());
		tpe.shutdown();
	    }
	} else if (file.isDirectory()) {
	    var files = file.listFiles();
	    if (files != null) {
	        for (var f : files) {
                    counter.incrementAndGet();
		    Runnable t = new MyRunnable(tpe, f.getPath(), filename, counter, completableFuture);
		    tpe.submit(t);
		}
	    }
	}
 
        int left = counter.decrementAndGet();
	if (left == 0) {
	    completableFuture.complete(null);
	    tpe.shutdown();
	}
    }
}

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.
  7. Paralelizați căutarea unei valori în cadrul unui arbore binar de căutare pe baza scheletului oferit (în pachetul task7) folosind ExecutorService și CompletableFuture.
  8. Paralelizați calculul înălțimii maxime a unui arbore binar de căutare pe baza scheletului oferit (în pachetul task8) folosind ForkJoinPool și Future.
apd/laboratoare/07.txt · Last modified: 2021/12/01 14:06 by florin.mihalache
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