Link Cerca Menu Expand Document

Processos i fils

Resultats d’aprenentatge:

  1. Desenvolupa aplicacions compostes per diversos processos reconeixent i aplicant principis de programació paral·lela.
  2. Desenvolupa aplicacions compostes per diversos fils d’execució analitzant i aplicant llibreries específiques del llenguatge de programació.

Concurrència

La computació concurrent permet que diverses tasques dins d’una aplicació puguin executar-se sense un ordre concret, fora d’una seqüència. O sigui: no cal que una tasca es completi perquè comenci la següent. Aquesta és una propietat de l’algorisme. És a dir, cal que dissenyem la nostra aplicació perquè ho permeti.

La computació concurrent no implica que l’execució es produeix en el mateix instant de temps. La computació paral·lela sí que fa referència a l’execució en el mateix instant de temps, i treu profit de sistemes amb múltiples cores per a accelerar la computació. És una propietat de la màquina.

Per una banda, hi ha situacions on les aplicacions són inherentment concurrents. Per una altra, si no dissenyem concurrentment, les nostres aplicacions no poden aprofitar les arquitectures hardware multi-core de les CPU dels ordinadors, i estarem limitats a la capacitat i rendiment d’un sol core.

Algunes raons per a utilitzar fils:

  • A una GUI, fer operacions en un fil independent que no bloquegi la interfície.
  • Implementar alarmes i temporitzadors.
  • Implementació d’algorismes paral·lels.
  • Implementar tasques de múltiples clients concurrents, accedint a recursos compartits.

Models de concurrència

El nostre model base és tenir un fil que accedeix a les dades. La concurrència suposa afegir més d’un fil, i això es pot fer de dues formes, principalment.

El següent diagrama mostra el model d’un sol fil, el de memòria compartida i el de pas de missatges.

Memòria compartida

Els mòduls concurrents interaccionen llegint i escrivint objectes compartits i mutables en memòria. És complex, ja que cal implementar mecanismes de bloqueig per coordinar els fils. Aquests mecanismes estan implementats al llenguatge Java.

Imaginem que els fils A i B utilitzen un mateix codi per a compartir els objectes mutables. Aquest codi, que permet que diversos fils l’accedeixin simultàniament de forma segura, s’anomena “thread-safe”.

Pas de missatges

Els mòduls concurrents interaccionen enviant-se missatges entre ells (1:1 o N:1) a través d’un canal de comunicació. Els mòduls envien missatges amb objectes immutables, i els missatges entrants de cada mòdul es col·loquen en cola per a la seva gestió. Ho poden fer de forma síncrona o asíncrona, en funció de si s’espera o no la resposta.

Processos i fils

Un procés té un entorn independent d’execució, simulant un ordinador. Per exemple, té el seu espai independent de memòria. Solen ser sinònim de programa, o aplicació, encara que pugui ser un conjunt de processos. Aquests poden col·laborar mitjançant canonades (pipes) o sòcols.

Generalment, els processos no comparteixen memòria, i per tant utilitzen el pas de missatges.

L’estat d’un procés el controla el sistema operatiu.

Una màquina virtual de Java és un únic procés, habitualment.

Una aplicació Java pot crear també processos addicionals utilitzant ProcessBuilder. Aquesta classe permet crear processos de sistema (Process) i executar-los, parar-los, llegir la seva sortida, reencaminar-la, etc. Resumint, permet interactuar amb altres processos de sistema que no siguin de la màquina virtual Java.

Els fils simulen un processador. Per defecte, comparteixen memòria.

A Java, un fil està sempre associat a un objecte Thread, que pot tenir una sèrie d’estats.

Un procés pot contenir diversos fils. La diferència més important entre un procés i un fil és que cada procés té el seu espai d’adreces independent, mentre els fils (del mateix procés) s’executen en un espai de memòria compartit.

A partir d’ara, farem referència a la forma de concurrència basada en memòria compartida, implementada mitjançant fils.

Cada fil té un stack (pila de crides) independent. Per tant, mai comparteixen tipus primitius. Però sí poden compartir objectes del heap, ja que aquest és compartit. Per tant, compte amb l’execució de codi en més d’un fil que accedeixi als mateixos objectes mutables.

El problema més típic d’aquest accés simultani és la race condition, que provoca la corrupció de l’estat compartit quan dos o més fils actuen sobre aquest estat. La solució consisteix a establir seccions crítiques de codi. Però aquesta solució també genera altres problemes, com el deadlock: una espera de tots els fils que no pot avançar.

Tècniques de disseny concurrent

Fes-te aquestes preguntes:

  • Primer, cal entendre la solució al problema. Habitualment, parteix de la solució seqüencial, per trobar la concurrent.
  • Segon, considera si pot ser paral·lelitzada. Alguns problemes són inherentment seqüencials.
  • Tercer, pensa en les oportunitats de paral·lelitzar que permeten les dependències entre les dades. Si no hi ha dependències, podem descompondre-les i paral·lelitzar-les.
  • Quart, busca els llocs on la solució consumeix més recursos, com a candidats de paral·lelització.
  • Cinquè, descompon en tasques el problema, per veure si aquestes poden ser executades independentment.

Concurrència a Java

La concurrència, a Java, es pot implementar a dos nivells: l’API de baix nivell i els objectes concurrents d’alt nivell.

Per crear un fil amb l’API de baix nivell, es pot fer de dues maneres:

  • Estendre la classe Thread i reescriure el mètode “run” (millor no utilitzar aquest mètode).
  • Implementar la interfície Runnable i el seu mètode “run”. Llavors, crear un Thread passant aquest objecte al constructor:
    • new Thread(new MyRunnable())

Un cop tenim el Thread, el podem executar mitjançant el seu mètode start().

A continuació, podem veure un exemple de creació d’un fil anomenat “fil” des del fil principal, “main”.

Aquest seria el codi:

public class MyFirstThreadTest implements Runnable {
    @Override
    public void run() {
        System.out.println("executant " + Thread.currentThread().getName());
    }
    public static void main(String[] args) {
        Runnable myRunnable = new MyFirstThreadTest();
        Thread thread = new Thread(myRunnable, "fill");
        thread.start();
        System.out.println("acabant " + Thread.currentThread().getName());
    }
}

Aquest exemple no presenta dificultats, ja que no es comparteix cap dada.

Operacions bàsiques

Join

Interrupt

Una interrupció és una indicació a un fil de què ha de parar de fer el que està fent i fer una altra cosa. Perquè aquest mecanisme funcioni, cal que el fil suporti interrupcions. Això es pot aconseguir de dues formes:

  • Que el fil faci crides freqüents a mètodes que facin throw de InterruptedException. Per exemple, Thread.sleep(). També serveix si la interrupció s’ha produït abans del sleep().
  • Que el fil comprovi freqüentment Thread.currentThread().isInterrupted().

Estat compartit

Es pot acabar un fil compartint una variable que un fil modifica i l’altre llegeix. En Java, cal definir la variable com a volatile, indicant que els canvis fets en un fil siguin visibles en la resta. O bé utilitzar objectes segurs creats per nosaltres (mecanismes de sincronització) o d’una llibreria segura (p. ex. la atomic de Java).

La paraula clau volatile només s’ha d’utilitzar si un fil escriu i l’altre (o altres) llegeixen. Si diversos fils escriuen i llegeixen, cal gestionar-ho com a zones crítiques.

Principis de sincronització

La sincronització és la coordinació de dues o més tasques per a obtenir un resultat desitjat. En tenim dos tipus:

  • De control: una tasca depèn d’una altra, i hem d’esperar fins que acabi la primera per iniciar la segona. Hem d’utilitzar mecanismes que permetin saber quan ha acabat una tasca, i com engegar la següent.
  • D’accés a dades: dues o més tasques volen accedir a una variable compartida, i només una ha de poder fer-ho en un instant de temps. Hem de detectar si a la nostra aplicació hi ha més d’un fil que pot necessitar modificar el mateix objecte.

Una zona crítica (o secció o regió) és una porció de codi que només pot ser executat per una tasca en un cert instant de temps.

Quan tenim dues o més tasques que escriuen a una variable compartida fora d’una zona crítica, es produeix una situació anomenada race condition: el comportament del sistema depèn de la seqüència de temps o altres esdeveniments no controlables. Aquesta circumstància sol produir-se quan múltiples fils comparteixen un estat mutable i les operacions sobre aquest estat se superposen.

Per evitar les race conditions, cal utilitzar mecanismes de sincronització, que fan que els objectes implicats siguin thread-safe. L’especificació de Java defineix les relacions happens-before, que especifiquen el comportament temporal per certs esdeveniments entre dos o més fils. Qualsevol situació no definida és impredictible.

Race condition

Solució amb zona crítica

Happens-before (Passa abans)

Els esdeveniments es poden ordenar mitjançant una relació “Happens-before”. Una escriptura a un fil serà visible per una lectura d’un altre només si l’escriptura “passa-abans” que la lectura.

Aquestes són les “passa-abans” definides pel llenguatge Java:

  • Regla del fil únic: cada acció d’un fil únic passa abans que qualsevol altra que vingui després en l’ordre del programa.
  • Regla del monitor: un unlock d’un monitor (sortida d’un bloc o mètode sincronitzat) passa abans que l’obtenció subseqüent del mateix monitor (lock).
  • Regla de la variable volàtil: l’escriptura a un camp volàtil passa-abans que qualsevol lectura subseqüent.
  • Regla d’inici d’un fil: una crida a start() d’un fil passa-abans que qualsevol acció del fil iniciat.
  • Regla del join: totes les accions d’un fil passen-abans que qualsevol altra acció d’un fil que fa un join sobre el primer.
  • Transitivitat: si A passa abans que B, i B passa abans que C, llavors A passa abans que C.

Disseny thread-safe

Hi ha bàsicament quatre tècniques per assegurar-nos que no tindrem problemes accedint a variables en memòria compartida:

  • Confinament. No compartiu la variable entre fils.
  • Immutabilitat. Feu que les dades compartides siguin immutables. Tots els camps de la classe han de ser finals.
  • Tipus de dades thread-safe. Encapsulem les dades compartides en un tipus de dades existent amb seguretat que realitzi la coordinació.
    • Per exemple, el paquet java.util.concurrent també conté algunes classes concurrents de mapes, cues, conjunts, llistes i variables atòmiques. Aquestes classes es poden utilitzar i compartir sense por a provocar race conditions.
  • Sincronització. Utilitzeu la sincronització per evitar que els fils accedeixin al mateix temps.
    • Els objectes monitor són objectes a què només pot accedir un fil alhora. Aquests permeten definir zones crítiques de codi. És el mètode més utilitzat.
    • També es pot utilitzar els reentrant locks (lock / unlock).

Hi ha una dificultat important a l’hora de dissenyar codi thread-safe, és a dir, que sigui segur davant l’accés de múltiples fils. Es poden preparar proves per al nostre codi que comprovin si, un nombre important de fils executant simultàniament el nostre codi, provoca problemes. Però no sempre és fàcil simular aquesta situació. Per això també necessitem tècniques per preveure aquesta situació.

Si mirem la documentació de la Java Standard Edition, veurem que de vegades es fa referència a la condició “thread-safe” de les classes.

Per exemple, a la classe java.util.regex.Pattern es diu:

  • Instances of this class are immutable and are safe for use by multiple concurrent threads. Instances of the Matcher class are not safe for such use.

És important que quan dissenyem el nostre codi siguem conscients de si necessitem que més d’un fil accedeixi. I si és així, dissenyar la classe en conseqüència.

Mecanismes de sincronització

Monitors (intrinsic lock)

A Java, la sincronització es fa mitjançant monitors. Un monitor és un objecte qualsevol que pot tenir un únic fil propietari. Qualsevol fil pot demanar la propietat d’un monitor i a canvi accedir a una zona crítica de codi restringida. Si ja hi ha propietari, cal que s’esperi fins que ho deixi de ser.

Per demanar la propietat d’un monitor i l’accés a la zona crítica de codi, podem utilitzar la paraula reservada “synchronized”. En funció d’on ho fem, l’objecte monitor canvia:

  • Mètodes d’instància: L’objecte monitor és la instància. Per tant, només un fil per cada instància.
  • Mètodes de classe: L’objecte monitor és la classe. Per tant, només un fil per cada classe.
  • Blocs de codi: s’ha d’indicar l’objecte monitor dins dels parèntesis. Qualsevol objecte pot ser monitor (p. ex. new Object()), tot i que habitualment fem que el monitor sigui el mateix objecte sobre el qual volem exercir control d’accés.

Un exemple de mètodes d’instància:

public class SynchronizedCounter {
    private int c = 0;
    public synchronized void increment() {
        c++;
    }
    public synchronized void decrement() {
        c--;
    }
    public synchronized int value() {
        return c;
    }
}

Conseqüències:

  • Primer, no és possible que dos fils puguin cridar simultàniament a dos mètodes sincronitzats. Les subseqüents crides se suspenen fins que el primer fil acabi amb l’objecte.
  • Segon, quan un mètode sincronitzat acaba, estableix una relació happens-before: les crides subseqüents tindran visibles els canvis fets.

Important: dins d’un bloc sincronitzat, cal fer la feina mínima possible: llegir les dades i si cal, transformar-les.

Un exemple de blocs de codi:

public class MsLunch {
    private long c1 = 0;
    private long c2 = 0;
    private Object lock1 = new Object();
    private Object lock2 = new Object();

    public void inc1() {
        synchronized(lock1) {
            c1++;
        }
    }
    public void inc2() {
        synchronized(lock2) {
            c2++;
        }
    }
}

Aquest mètode permet tenir un gra més fi: pot haver-hi un fil a la zona de codi de lock1 i un altre a la de lock2.

Wait / Notify (guarded lock)

Imaginem que volem esperar fins que es compleixi una condició:

public void alegriaControlada() {
    // Control senzill. Gasta CPU, no fer-ho mai!
    while (!alegria) {}
    System.out.println("Alegria aconseguida!");
}

Això ho podem fer entre fils mitjançant el mètode clàssic de comunicació wait i notify, que permet:

  • Esperar fins que una condició que implica dades compartides sigui certa, i
  • notificar a altres fils que les dades compartides han canviat, probablement activant una condició per la que esperen altres fils.

Els mètodes són:

  • wait(): quan es crida, el fil actual espera fins que un altre fil cridi notify() o notifyall() sobre aquest monitor.
  • notify(): desperta un fil qualsevol de tots els que estiguin esperant a aquest monitor.
  • notifyAll(): desperta tots els fils que estiguin esperant a aquest monitor.

Els mètodes wait() i notify s’han de cridar des de dins d’un bloc sincronitzat per a l’objecte monitor.

A més, tal i com es comenta a Object, el mètode wait() ha de ser a dins d’un bucle esperant per una condició:

    synchronized (monitor) {
        while (!condicio) {
            monitor.wait();
        }
    }
  
    synchronized (monitor) {
        monitor.notify();
    }

En el nostre cas:

    synchronized (monitor) {
        while (!alegria) {
            monitor.wait();
        }
        // aqui ja tenim alegria!
    }
    ...
    synchronized (monitor) {
        // treballar-nos la felicitat
        alegria = true;
        monitor.notify();
    }

Funcionament del wait / notify (alt nivell)

Funcionament del wait / notify (baix nivell)

Vitalitat (liveness) d’un sistema multifil

La vitalitat d’una aplicació (liveness) és la seva capacitat per a executar-se en el temps que toca. Els problemes més habituals que poden desbaratar aquesta vitalitat són:

  • El deadlock (interbloqueig): dos o més fils es bloquegen per sempre, esperant l’un per l’altre. Pot passar si dos fils bloquegen recursos que necessiten esperant que estiguin lliures d’altres, que mai ho seran.
  • La starvation (inanició): la denegació perpètua dels recursos necessaris per a processar una feina. Un exemple seria l’ús de prioritats, on sempre els fils amb més prioritat són atesos, i els altres mai ho són.
  • El livelock és molt semblant al deadlock, però els fils sí que canvien el seu estat, tot i que mai s’arriba a una situació de desbloqueig.

Quan un client fa una petició a un servidor, el servidor ha d’aconseguir l’accés exclusiu als recursos compartits necessaris. L’ús correcte de les zones crítiques permetrà que el sistema tingui una millor vitalitat quan la càrrega de peticions sigui alta.

Llibreria Java concurrent

La llibreria java.util.concurrent conté classes útils quan fem concurrència:

  • Executors: la interfície Executor permet representar un objecte que executa tasques. ExecutorService permet el processament asíncron, gestionant una cua i executant les tasques enviades segons la disponibilitat dels fils.
  • Cues: ConcurrentLinkedQueue, BlockingQueue.
  • Sincronitzadors: els clàssics semàfors (Semaphore), CountDownLatch.
  • Col·leccions concurrents: per exemple, ConcurrentHashMap, o els mètodes de Collections synchronizedMap(), synchronizedList() i synchronizedSet().
  • Variables que permeten operacions atòmiques sense bloqueig al paquet java.util.concurrent.atomic: AtomicBoolean, AtomicInteger, etc.

Sempre és preferible utilitzar aquestes classes que els mètodes de sincronització wait/notify, perquè simplifiquen la programació. De la mateixa manera que és millor utilitzar executors i tasques que fils directament.

Tasques i executors

La majoria d’aplicacions concurrents s’organitzen mitjançant tasques. Una tasca realitza una feina concreta. D’aquesta forma, podem simplificar el disseny i el funcionament.

Veiem una possible solució per a la gestió de connexions a un servidor. Suposem que tenim un mètode, atendrePeticio(), que atén una petició web.

Execució seqüencial

class ServidorWebUnFil {
    public static void main(String[] args) throws IOException {
        ServerSocket socol = new ServerSocket(80);
        while (true) {
            Socket connexio = socol.accept();
            atendrePeticio(connexio);
        }
    }
}

Un fil per cada petició

class ServidorWebUnFilPerPeticio {
    public static void main(String[] args) throws IOException {
        ServerSocket socol = new ServerSocket(80);
        while (true) {
            Socket connexio = socol.accept();
            Runnable tasca = new Runnable() {
                @Override
                public void run() {
                    atendrePeticio(connexio);
                }
            }
            new Thread(tasca).start();
        }
    }
}

Grup compartit de fils

class ServidorWebExecucioTasques {
    private static final int NFILS = 100;
    private static final Executor executor = Executors.newFixedThreadPool(NFILS);

    public static void main(String[] args) throws IOException {
        ServerSocket socol = new ServerSocket(80);
        while (true) {
            final Socket connexio = socol.accept();
            Runnable tasca = new Runnable() {
                public void run() {
                    atendrePeticio(connexio);
                }
            };
            executor.execute(tasca);
        }
    }
}

En aquesta solució hem introduït la interfície Executor:

public interface Executor {
    void execute(Runnable command);
}

És un objecte que permet executar Runnables. Internament, el que fa és executar tasques de forma asíncrona, creant un fil per cada tasca en execució, i retornant el control al fil que crida el seu mètode execute. Les tasques poden tenir quatre estats:

  • Creada
  • Enviada
  • Iniciada
  • Completada

Els Executors es poden crear des de la classe amb mètodes estàtics Executors. Aquesta classe retorna una subclasse de Executor, l’ExecutorService. Aquesta subclasse usa el patró Thread Pool, que reutilitza un nombre màxim de fils entre una sèrie de tasques a una cua.

Un ExecutorService ha de parar-se sempre amb el mètode shutdown(), que para tots el fils del pool.

Tasques amb resultats

Algunes tasques retornen resultats. Per implementar-les, podem utilitzar les interfícies Callable i Future:

public interface Callable<V> {
    V call() throws Exception;
}

public interface Future<V> {
    boolean cancel(boolean mayInterruptIfRunning);
    boolean isCancelled();
    boolean isDone();
    V get() throws InterruptedException, ExecutionException, CancellationException;
    V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, CancellationException, TimeoutException;
}

Callable<V> permet executar la tasca i retornar un valor del tipus V. Per tal de poder executar-la, necessitem un ExecutorService. En particular, els seus dos mètodes:

  • Future<?> submit(Runnable task)
  • <T> Future<T> submit(Callable<T> task)

Aquests permeten executar un Runnable / Callable i retornen un Future, que és un objecte que permet obtenir el resultat en diferit mitjançant el mètode get() (bloqueig) o get(long timeout, TimeUnit unit) (bloqueig per un temps).

També podem cancel·lar la tasca mitjançant cancel(boolean mayInterruptIfRunning): el paràmetre diu si es vol interrompre també si ja ha començat.

Els ExecutorService poden crear-se mitjançant la mateixa classe que hem vist abans, Executors.

A continuació, un exemple de funcionament. Com canvia l’execució si fem Executors.newFixedThreadPool(2)?

public class SimpleCallableTest {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(1);
        Future<String> f1 = executor.submit(new ToUpperCallable("hello"));
        Future<String> f2 = executor.submit(new ToUpperCallable("world"));

        try {
            long millis = System.currentTimeMillis();
            System.out.println("main " + f1.get() + " " + f2.get() + 
                    " in millis: " + (System.currentTimeMillis() - millis));            
        } catch (InterruptedException | ExecutionException ex) {
            ex.printStackTrace();
        }

        executor.shutdown();
    }

    private static final class ToUpperCallable implements Callable<String> {
        private String word;

        public ToUpperCallable(String word) {
            this.word = word;
        }

        @Override
        public String call() throws Exception {
            String name = Thread.currentThread().getName();            
            System.out.println(name + " calling for " + word);
            Thread.sleep(2500);
            String result = word.toUpperCase();
            System.out.println(name + " result " + word + " => " + result);
            return result;
        }
    }
}

A Java 7 es va introduir el framework fork/join.

A Java 8 es va introduir el CompletableFuture, que permet combinar futurs i gestionar millor els errors que es produeixen. Un exemple és l’ús del mètode complete per a completar un futur, en un altre fil:

CompletableFuture<String> completableFuture = new CompletableFuture<>();
//...
String resultat = completableFuture.get();
// mentre en un altre fil...
completableFuture.complete("Hola, món!);

O bé, la possibilitat d’executar directament amb supplyAsync:

Supplier<String> supplier = new Supplier<String>() {
    @Override
    public String get() {
        return "Hola, món!";
    }
};

Future<String> future = CompletableFuture.supplyAsync(supplier, executor); // executor és opcional
System.out.println(future.get());

Pas de missatges

El pas de missatges pot implementar-se:

  • Dins d’un procés, mitjançant fils. Utilitzant buffers o cues, per exemple.
  • Entre processos. Habitualment, es fa utilitzant el paradigma client/servidor i mitjançant xarxes. Un possible mecanisme és l’ús de sòcols, com es podrà veure a la UF Sòcols i serveis. En aquesta comunicació, no hi ha compartició de dades mutables, però pot passar que múltiples clients accedeixin simultàniament a un mateix servidor.

En el diagrama pot veure’s una implementació entre processos.

Model de programació síncron i asíncron

La comunicació entre les dues parts es pot realitzar de forma síncrona o de forma asíncrona, segons hi hagi un bloqueig E/S (entrada/sortida).

Comes pot veure al diagrama, en la forma síncrona el client espera la resposta del servidor (bloqueig E/S), i mentrestant no fa res. A la forma asíncrona envia la petició, continua treballant i en un moment donat rep la resposta (sense bloqueig E/S).

Quina forma és més convenient? Depèn de les circumstàncies. La forma síncrona és més fàcil d’implementar, però l’asíncrona permet millorar el rendiment del sistema introduint la concurrència.

Comunicació asíncrona

Les peticions asíncrones han de permetre al client conèixer el resultat a posteriori. Alguns solucions possibles:

  • Cap: el client només pot saber com va anar fent una o diverses consultes posteriors (polling).
  • Una crida de codi: quan acaba la petició, el servidor fa una crida al codi. Podria implementar-se mitjançant callbacks.
  • Un missatge: quan acaba la petició, el servidor envia un missatge que pot rebre el client. Aquest missatge pot viatjar en diferents protocols, i se sol implementar mitjançant algun tipus de middleware. Habitualment, els missatges van a parar a cues, que després gestionen els servidors.

Gestió síncrona de peticions

Quan utilitzem el model síncron (amb bloqueig), un sol fil no pot gestionar diverses peticions simultànies. Això vol dir que necessitem crear un fil per gestionar cada petició i retornar la resposta. En diem arquitectura basada en fils.

Habitualment, es limita el nombre de fils que es permeten gestionar simultàniament per evitar el consum excessiu de recursos.

Gestió asíncrona de peticions

Es reprodueix el patró productor-consumidor: els productors són l’origen dels esdeveniments, i només saben que un ha ocorregut; mentre els consumidors necessiten saber que hi ha un nou esdeveniment, i l’han d’atendre (handle). En diem arquitectura basada en esdeveniments.

Algunes tècniques per implementar el servei:

  • El patró reactor: les peticions es reben i es processen de forma síncrona, en un mateix fil. Funciona si les peticions es processen ràpidament.
  • El patró proactor: les peticions es reben i es divideix el processament asíncronament, introduint concurrència.

A Java tenim Vert.x, una implementació multireactor (amb N bucles d’esdeveniments).

Una altra tècnica per a gestionar peticions asíncrones és el model d’actors. Aquest model permet crear programes concurrents utilitzant actors no concurrents.

  • Un actor és una unitat de computació lleugera i desacoblada.
  • Els actors tenen estat, però no poden accedir a l’estat d’altres actors.
  • Es pot comunicar amb altres actors mitjançant missatges asíncrons immutables.
  • L’actor processa els missatges seqüencialment, evitant contenció sobre l’estat.
  • Els missatges poden estar distribuïts per la xarxa.
  • No es pressuposa cap ordre concret en els missatges.

A Java, tenim un exemple de llibreria: Akka.

Exemples

Una forma d’implementar-lo és passar missatges entre fils mitjançant l’ús d’una cua sincronitzada. Pot haver-hi un o més productors i un o més consumidors. La cua ha de ser thread-safe. A Java, les implementacions de BlockingQueue, ArrayBlockingQueue i LinkedBlockingQueue, en són exemples. Els objectes a aquestes cues han de ser d’un tipus immutable.

Buffer asíncron (cua)

En aquest exemple, un fil productor envia treballs (1, 2, 3, 4) a un fil consumidor mitjançant una cua thread-safe. La mida màxima de la cua es 2.

Les accions són:

  • put (prod): afegir un treball, esperant si no hi ha prou espai.
  • take (cons): llegir un treball per processar-lo, i esperar si no hi ha cap.

Flux de crides de la impressora asíncrona

De vegades, les peticions fan referència a un recurs compartit que no permet el seu ús per més d’un client alhora. En aquests casos, es pot implementar una cua que gestioni les peticions de forma asíncrona:

  • El client realitza la petició asíncrona, i més endavant pot rebre la resposta o confirmació de la petició.
  • El servidor registra la petició en una cua, que va atenent per ordre a un fil independent.

La impressora és un únic fil (servidor) que va llegint els treballs afegits a la cua per diferents usuaris (fils), i atenent-los.

També podríem tenir més d’una cua, si hi ha la possibilitat de tenir més d’un punt per atendre les peticions (diverses impressores).

Programació i sistemes reactius

La programació passiva és la tradicional als dissenys OO: un mòdul delega en un altre per a produir un canvi al model.

L’alternativa plantejada es diu programació reactiva, on utilitzem callbacks per a invertir la responsabilitat.

El terme “reactiu” s’utilitza en dos contextos:

  • La programació reactiva està basada en esdeveniments (event-driven). Un esdeveniment permet el registre de diversos observadors. Habitualment funciona de forma local.
  • Els sistemes reactius generalment es basen en missatges (message-driven) amb un únic destí. Es corresponen més sovint a processos distribuïts que es comuniquen a través d’una xarxa, potser com a microserveis que cooperen.

En l’exemple de la cistella de la compra, podem veure com implementar-ho amb programació passiva i reactiva:

  • Amb passiva, la cistella actualitza la factura. Per tant, la cistella és la responsable del canvi i depèn de la factura.
  • Amb reactiva, la factura rep un esdeveniment de producte afegit i s’actualiza a si mateixa. La factura depèn de la cistella, ja que li ha de dir que vol sentir els seus esdeveniments.

Pros i contres:

  • La programació reactiva permet entendre millor com funciona un mòdul: només cal mirar al seu codi, ja que és responsable d’ell mateix. Amb la passiva és més difícil, ja que cal mirar-se els altres mòduls que el modifiquen.
  • Per altra banda, amb programació passiva és més fàcil entendre a quins mòduls afecta un: mirant quines referències es fan. Amb programació reactiva cal mirar-se quins mòduls generen un cert esdeveniment.

La programació reactiva és asíncrona i sense bloqueig. Els fils que busquen recursos compartits no bloquegen l’espera que el recurs estigui disponible. En el seu lloc, continuen la seva execució i són notificats després quan el servei s’ha completat.

Les extensions reactives permeten que llenguatges imperatius, com Java, puguin implementar programació reactiva. Ho fan utilitzant programació asíncrona i streams observables, que emeten tres tipus d’esdeveniments als seus subscriptors: següent, error i completat.

Des de Java 9 s’han definit els streams reactius utilitzant el patró Publish-Subscribe (molt semblant al patró observador) mitjançant les interfícies Flow. Les implementacions més utilitzades són Project Reactor (p. ex. Spring WebFlux) i RxJava (p. ex. Android).

Per altra banda, un sistema reactiu és un estil d’arquitectura que permet que diverses aplicacions puguin comportar-se com una sola, reaccionant al seu entorn, mantenint-se al corrent els uns dels altres, i permetent la seva elasticitat, resiliència i responsivitat basats (habitualment) en cues de missatges dirigits a receptors concrets (vegeu el Reactive Manifesto). Una aplicació dels sistemes reactius són els microserveis.

Tant els patrons reactor/proactor com el model d’actors permeten implementar sistemes reactius.

Referències

Programació i sistemes reactius: