De la xarxa tornem a casa: dins de cada servei de PideYa hi ha desenes de fils atenent peticions alhora, i tots comparteixen la mateixa memòria. Aquí les fallades no són timeouts visibles sinó condicions de carrera: bugs que apareixen una vegada cada mil execucions i desapareixen quan hi poses un log. A 02-02 vam mostrar el double-checked locking del Singleton gairebé com un encanteri; aquesta lliçó en dona per fi el fonament, i amb ell un catàleg de patrons perquè els fils cooperin sense trepitjar-se. La distribució entre processos va quedar coberta a la lliçó anterior; aquí tot passa dins d'una JVM.

Contingut

  1. El problema: estat compartit mutable
  2. El double-checked locking, ara amb fonament
  3. Immutabilitat com a patró
  4. Confinament d'estat
  5. Producer-Consumer amb BlockingQueue
  6. Thread Pool / Worker amb ExecutorService
  7. Future, Promise i CompletableFuture
  8. Read-Write Lock
  9. Active Object (menció) i virtual threads de Java 21
  10. Guia: quan usar què

El problema: estat compartit mutable

Dos fils executen comptadorComandes++ alhora. Aquesta línia són tres operacions (llegir, sumar, escriure); si s'intercalen, un increment es perd. Pitjor encara: encara que no s'intercalin, un fil pot no veure el que va escriure un altre, perquè cada nucli fa memòria cau i el compilador reordena instruccions. El Java Memory Model només garanteix visibilitat entre fils quan hi ha una relació happens-before: la creen synchronized, volatile, els java.util.concurrent i l'arrencada/join de fils.

L'equació del perill és: estat compartit + mutabilitat + concurrència. Tots els patrons d'aquesta lliçó eliminen almenys un factor:

Patró Factor que elimina
Immutabilitat La mutabilitat
Confinament El compartir
Producer-Consumer, Active Object L'accés simultani (serialitza per cues)
Locks (inclòs el Read-Write) L'accés simultani (exclou)

El double-checked locking, amb fonament

Recordem el Singleton mandrós de ConfiguracioPideYa:

public class ConfiguracioPideYa {
    private static volatile ConfiguracioPideYa instancia; // volatile: imprescindible

    public static ConfiguracioPideYa getInstancia() {
        if (instancia == null) {                    // 1a comprovacio, sense lock (rapida)
            synchronized (ConfiguracioPideYa.class) {
                if (instancia == null) {            // 2a comprovacio, amb lock
                    instancia = new ConfiguracioPideYa();
                }
            }
        }
        return instancia;
    }
}

Ara podem explicar el perquè de cada peça. Sense volatile, la JVM pot reordenar: publicar la referència instancia abans d'acabar el constructor. Un altre fil passaria la primera comprovació i usaria un objecte a mig construir — el bug fantasma per excel·lència. volatile prohibeix aquest reordenament i garanteix la visibilitat (happens-before entre l'escriptura i les lectures). La primera comprovació evita pagar el lock en el 99,9 % de crides; la segona evita la doble creació entre fils que esperaven el lock. I la moralitat de 02-02 continua en peu: el holder idiom o un enum aconsegueixen el mateix sense escriure res de tot això.

Immutabilitat com a patró

L'objecte immutable és el patró de concurrència més barat: allò que no canvia es pot compartir entre mil fils sense cap lock. Java ho ha anat facilitant:

// Un record es final, amb camps finals: immutable per construccio
public record LiniaComanda(String platId, int quantitat, BigDecimal preu) { }

public final class Comanda {
    private final List<LiniaComanda> linies;

    private Comanda(Builder b) {
        // List.copyOf: copia immutable — ni el builder ni ningu no pot mutar-la despres
        this.linies = List.copyOf(b.linies);
    }
    public List<LiniaComanda> getLinies() { return linies; } // segur: es immutable
}

Aquell List.copyOf que vam posar al Comanda.Builder de 02-05 per "bones pràctiques" era, en realitat, un patró de concurrència: la còpia defensiva que fa la Comanda compartible. I si la comanda "canvia"? No es muta: es crea una altra comanda (com feia clonar() a Prototype, 02-06) o, millor, es registra el canvi com a esdeveniment — la connexió amb l'Event Sourcing de 06-01. Els objectes que viatjaven pels topics de 06-03 havien de ser immutables per aquesta mateixa raó.

Confinament d'estat

Si una dada la toca un sol fil, no necessita sincronització encara que sigui mutable. Maneres de confinar:

  • Confinament a pila: variables locals; cada fil té la seva pila. Un StringBuilder local no necessita locks.
  • Confinament per fil: ThreadLocal<T> dona a cada fil la seva pròpia còpia (així desa Spring la transacció actual).
  • Confinament per disseny: un únic fil posseeix l'estructura i els altres li demanen coses per missatges — que és exactament el patró següent.

Producer-Consumer amb BlockingQueue

Les comandes de cuina de PideYa: els fils web que confirmen comandes (productors) no han d'esperar que la cuina processi; la dipositen en una cua i continuen.

BlockingQueue<ComandaCuina> cuaCuina = new LinkedBlockingQueue<>(100); // capacitat acotada

// Productor (fil web): si la cua es plena, put() BLOQUEJA → contrapressio
public void confirmarComanda(Comanda c) throws InterruptedException {
    cuaCuina.put(new ComandaCuina(c.getId(), c.getLinies()));
}

// Consumidor (fil de cuina): take() bloqueja fins que hi hagi comanda
Runnable cuiner = () -> {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            ComandaCuina cc = cuaCuina.take();
            prepararComanda(cc);         // nomes AQUEST fil toca l'estat de la comanda
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt(); // restaurar el flag i sortir
        }
    }
};

La BlockingQueue encapsula tota la sincronització: put i take són segurs i bloquegen quan toca. La capacitat acotada (100) és la decisió de disseny important: si la cuina no dona l'abast, els productors es frenen (contrapressió) en lloc d'esgotar la memòria. És la versió intra-procés de les cues de missatges de 06-03: mateixa intenció, sense xarxa ni broker — i sense les seves garanties de durabilitat: si el procés mor, la cua en memòria es perd.

Thread Pool / Worker amb ExecutorService

Crear un fil per tasca és car (memòria i arrencada) i perillós (un pic de trànsit crea deu mil fils i tomba la JVM). El patró Thread Pool reutilitza un conjunt fix de workers que consumeixen tasques d'una cua interna — és un Producer-Consumer empaquetat:

// Dimensionat per al processament de comandes:
// tasques amb I/O (BD, xarxa) → mes fils que nuclis; tasques de pur CPU → ~num de nuclis
int nuclis = Runtime.getRuntime().availableProcessors();
ExecutorService poolComandes = Executors.newFixedThreadPool(nuclis * 4);

poolComandes.submit(() -> processarComanda(comanda)); // encuar tasca, no crear fil

Regla de dimensionat clàssica: fils ≈ nuclis × (1 + tempsEspera/tempsCpu). El processament d'una comanda de PideYa passa ~75 % del temps esperant la BD i Pagaments, d'aquí el × 4. Un pool per a CPU pura (calcular rutes de repartiment) es quedaria en nuclis. I fixa-t'hi: tenir pools separats per a "crides a Pagaments" i "crides a Catàleg" és exactament el bulkhead de 06-03, versió local.

Future, Promise i CompletableFuture

Un Future<T> és un rebut: "el resultat arribarà; mentrestant, continua amb la teva vida". Un promise és el costat escrivible del rebut. En Java tots dos papers els compleix CompletableFuture, que a més permet compondre passos asíncrons. El checkout de PideYa: cobrar i reservar estoc no depenen l'un de l'altre — al monòlit seqüencial sumaven les seves latències; en paral·lel, només costa la més gran:

CompletableFuture<ResultatCobrament> cobrament =
    CompletableFuture.supplyAsync(() -> passarella.cobrar(comanda, targeta), poolComandes);

CompletableFuture<Reserva> estoc =
    CompletableFuture.supplyAsync(() -> magatzem.reservar(comanda.getLinies()), poolComandes);

// Combinar tots dos resultats quan TOTS DOS acabin
CompletableFuture<Confirmacio> confirmacio =
    cobrament.thenCombine(estoc, (r, s) -> confirmar(comanda, r, s))
         .orTimeout(3, TimeUnit.SECONDS)                  // el timeout de 06-03, local
         .exceptionally(ex -> compensarIRebutjar(comanda, ex));

Llegeix-ho com una canonada declarativa: supplyAsync llança cada tasca al pool, thenCombine espera les dues i combina, orTimeout acota l'espera i exceptionally és el pla B (que aquí ha de compensar: si el cobrament va anar bé però l'estoc va fallar, cal reemborsar — la lògica de saga de 06-02 en miniatura). Res no bloqueja el fil cridador: els callbacks s'encadenen com els Decorator encadenaven comportament.

Read-Write Lock

La carta de PideYa (el Composite de 03-04) es llegeix en cada petició de cada client i s'escriu unes poques vegades al dia. Un synchronized normal serialitza també les lectures entre si — un malbaratament, perquè llegir en paral·lel és inofensiu. El Read-Write Lock distingeix:

public class CartaCompartida {
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private SeccioCarta arrel;

    public Menu cercarMenu(String id) {
        lock.readLock().lock();          // N lectors poden entrar ALHORA
        try { return arrel.cercar(id); }
        finally { lock.readLock().unlock(); }
    }

    public void actualitzar(SeccioCarta novaArrel) {
        lock.writeLock().lock();         // l'escriptor entra SOL, sense lectors
        try { this.arrel = novaArrel; }
        finally { lock.writeLock().unlock(); }
    }
}

Molts lectors simultanis, escriptors en exclusiva. Alternativa encara millor quan les escriptures són tan rares: publicar una carta immutable i que actualitzar reemplaci la referència (volatile o AtomicReference) — copy-on-write, que combina els patrons 3 i 8 d'aquesta lliçó i no bloqueja ningú.

Active Object i virtual threads

Active Object (POSA): un objecte amb el seu propi fil i la seva pròpia cua de peticions; els mètodes públics no executen, encuen, i retornen futures. És Command + Producer-Consumer + Future en un paquet: així es podria implementar la CentralRepartiment perquè tot el seu estat quedi confinat a un fil. Ho deixem en menció: la combinació de peces ja la coneixes.

Virtual threads (Java 21): fils baratíssims gestionats per la JVM (milions, no milers), que alliberen el fil de plataforma en bloquejar-se en I/O. Què canvien: l'estil "un fil per petició" amb codi bloquejant seqüencial torna a ser viable — menys necessitat d'encadenar CompletableFuture per escalar (continuen valent per paral·lelitzar, com el cobrament+estoc). Què no canvien: res de la correcció — les carreres, la visibilitat, els locks i la immutabilitat continuen exactament igual. Els patrons d'aquesta lliçó no caduquen amb ells.

Guia: quan usar què

Situació a PideYa Patró Eina Java
Dades que viatgen entre fils Immutabilitat record, List.copyOf, final
Estat auxiliar per petició Confinament Variables locals, ThreadLocal
Feina desacoblada amb contrapressió (comandes de cuina) Producer-Consumer BlockingQueue acotada
Moltes tasques, fils controlats Thread Pool ExecutorService dimensionat
Passos independents en paral·lel (cobrament + estoc) Future/Promise CompletableFuture.thenCombine
Es llegeix molt, s'escriu poc (carta) Read-Write Lock / copy-on-write ReentrantReadWriteLock, AtomicReference
Estat complex amb un sol amo Active Object Fil propi + cua + futures
Milers de peticions bloquejants d'I/O Virtual threads Executors.newVirtualThreadPerTaskExecutor()
Comptador o referència solta compartida (atòmics) AtomicInteger, LongAdder

Errors Comuns i Consells

  • Sincronitzar "per si de cas" o no sincronitzar "perquè funciona": tots dos neixen de no raonar el model de memòria. Pregunta sistemàtica: quins fils toquen aquesta dada i quina relació happens-before els ordena? Si no saps respondre, hi ha un bug latent.
  • Double-checked locking sense volatile: compila, passa els tests i falla en producció sota càrrega. Si necessites inicialització mandrosa, prefereix el holder idiom.
  • Cues sense acotar: new LinkedBlockingQueue<>() sense capacitat converteix un pic de trànsit en un OutOfMemoryError diferit. Acota i decideix què passa quan s'ompli (bloquejar, rebutjar, descartar).
  • Bloquejar dins d'un CompletableFuture amb join()/get() al mig de la cadena: anul·la l'asincronia i pot provocar deadlocks de pool. Compondre, no esperar.
  • Compartir el pool per a tot: les tasques d'I/O lentes de Pagaments ofeguen les ràpides del catàleg. Pools separats per tipus de feina (bulkhead local).
  • Locks niats en ordre diferent: el fil A pren lock1→lock2, el fil B pren lock2→lock1: deadlock. Estableix un ordre global d'adquisició o redissenya per no niar.
  • Empassar-se la InterruptedException: un catch buit impedeix aturar el sistema netament. Restaura el flag (Thread.currentThread().interrupt()) i acaba.

Exercicis

  1. Caça la carrera. ComptadorComandesDiaprivate int total; i el mètode public void incrementar() { total++; }, cridat des dels fils web. Explica els dos problemes diferents (atomicitat i visibilitat) i dona dues solucions: una amb lock i una sense lock.
  2. Dimensiona el pool. El servei de notificacions envia emails: cada enviament usa ~5 ms de CPU i espera ~195 ms la xarxa del proveïdor. La màquina té 8 nuclis. Calcula la mida de pool raonable amb la fórmula de la lliçó i explica quin patró de 06-03 convé posar davant del pool.
  3. Paral·lelitza el checkout. En confirmar una comanda cal: (a) cobrar, (b) reservar estoc, (c) calcular punts de fidelització — les tres independents entre si — i (d) amb els resultats de les tres, emetre la confirmació. Escriu la composició amb CompletableFuture i afegeix-hi un timeout global de 2 segons.

Solucions

  1. Atomicitat: total++ són tres passos; dos fils poden llegir el mateix valor i perdre's un increment. Visibilitat: sense happens-before, un fil pot no veure mai les escriptures d'un altre (i volatile només arreglaria la visibilitat, no l'atomicitat). Amb lock: public synchronized void incrementar() { total++; } (i també synchronized al getter). Sense lock: private final AtomicInteger total = new AtomicInteger(); public void incrementar() { total.incrementAndGet(); } — o LongAdder si la contenció és alta.

  2. fils ≈ 8 × (1 + 195/5) = 8 × 40 = 320 fils. Amb tanta espera d'I/O el pool surt enorme — senyal que els virtual threads hi encaixarien encara millor. Davant del pool: una cua productor-consumidor acotada amb contrapressió, i conceptualment l'enviament ja venia d'un broker (06-03), amb reintents i DLQ per als emails que fallin.

  3. La composició queda així:

    var cobrament = CompletableFuture.supplyAsync(() -> passarella.cobrar(comanda, targeta), pool);
    var estoc     = CompletableFuture.supplyAsync(() -> magatzem.reservar(comanda.getLinies()), pool);
    var punts     = CompletableFuture.supplyAsync(() -> fidelitzacio.calcularPunts(comanda), pool);
    
    CompletableFuture<Confirmacio> conf =
        cobrament.thenCombine(estoc, ParPagamentEstoc::new)
             .thenCombine(punts, (par, pts) -> emetreConfirmacio(comanda, par.cobrament(), par.reserva(), pts))
             .orTimeout(2, TimeUnit.SECONDS)
             .exceptionally(ex -> compensarIRebutjar(comanda, ex));
    

    thenCombine en cascada ajunta els tres resultats (amb un record auxiliar ParPagamentEstoc); orTimeout acota l'operació completa i exceptionally centralitza la compensació (reemborsar/alliberar allò que sí que es va arribar a executar).

Conclusió

Hem baixat al nivell on els bugs no donen la cara: l'estat compartit mutable. El catàleg de resposta — immutabilitat, confinament, cues amb contrapressió, pools ben dimensionats, futures compostos, read-write locks, i els virtual threads que abarateixen els fils sense derogar cap regla — reutilitza intencions que ja dominaves: el Builder que copiava llistes era concurrència preventiva, el Producer-Consumer és la cua de missatges sense xarxa, l'Active Object és Command més Future. Amb això es tanca la part tècnica del catàleg modern. Queda una pregunta diferent, ni de xarxa ni de fils sinó de persones i de temps: com conviuen tots aquests patrons amb el desenvolupament iteratiu, els sprints i el "no ho necessitaràs"? Ho veiem a Patrons de Disseny en Desenvolupament Àgil.

Curs de Patrons de Disseny de Programari

Mòdul 1: Introducció als Patrons de Disseny

Mòdul 2: Patrons Creacionals

Mòdul 3: Patrons Estructurals

Mòdul 4: Patrons de Comportament

Mòdul 5: Aplicació de Patrons de Disseny

Mòdul 6: Patrons de Disseny Avançats

Mòdul 7: Recursos Addicionals i Conclusió

© Copyright 2026. Tots els drets reservats