Vas tancar el mòdul 7 amb una frase incòmoda: "el teu portàtil té vuit nuclis i BiblioTech en fa servir un". L'aplicació recorda, es configura sense recompilar, valida les seves importacions i no corromp els seus fitxers. Però fa les coses una darrere l'altra, i per això el menú es queda mut durant una importació, els dos-cents avisos triguen deu minuts i el processador està aturat mentre algú decideix quina opció teclejar.

Aquesta lliçó encara no escriu la solució: escriu el model mental sense el qual la solució és impossible de raonar. Entendràs què és exactament un fil, en què es diferencia d'un procés, què comparteixen dos fils i què no —el diagrama que explica literalment la meitat dels errors de concurrència que veuràs a la teva carrera—, en què es distingeixen concurrència i paral·lelisme, per què el nombre correcte de fils depèn de si la teva tasca gasta CPU o espera un disc, i quins perills apareixen tan bon punt dos fils toquen el mateix.

I acabaràs amb un programa de vint línies que falla de veritat: dos fils que sumen un a un comptador dos milions de vegades i produeixen un resultat menor que dos milions, de manera reproduïble. Aquest programa és l'esquer del mòdul sencer. Tot el que ve després —synchronized, volatile, les variables atòmiques, les col·leccions concurrents— existeix perquè aquell número torni a ser el que hauria de ser.

Avís de mètode. La concurrència és l'únic tema d'aquest curs on llegir no n'hi ha prou. Executa els exemples. Executa'ls diverses vegades. Els que fallen no fallen sempre, i veure el mateix programa donar dos resultats diferents en dues execucions seguides és la lliçó que cap paràgraf no pot substituir.

Contingut

  1. El problema real: BiblioTech espera
  2. Procés davant de fil
  3. Què comparteixen i què no comparteixen dos fils
  4. Concurrència davant de paral·lelisme
  5. Per què es fan servir fils: els tres motius
  6. Tasques limitades per CPU i limitades per E/S
  7. El fil principal i el fil actual
  8. Fils dimoni i quan acaba la JVM
  9. El cost real d'un fil
  10. Els tres perills: cursa, interbloqueig, inanició
  11. El primer exemple que falla: el comptador compartit
  12. Errors Comuns i Consells
  13. Exercicis

  1. El problema real: BiblioTech espera

Abans de la teoria, els tres casos concrets que la justifiquen. Són tres situacions reals del codi que ja has escrit.

Cas A — la importació bloqueja el menú. ImportadorCataleg llegeix un fitxer de cinquanta mil línies, valida cadascuna i construeix un Informe. Triga vuit segons. Durant aquests vuit segons, MenuBiblioTech no llegeix teclat, no pinta res, no respon. L'usuari no sap si el programa està treballant o s'ha penjat, i no el pot cancel·lar.

Cas B — els avisos s'envien d'un en un. CuaAvisos té dos-cents préstecs vençuts. Enviar un avís implica escriure un fitxer de notificació i esperar que el disc ho confirmi. Suposem 300 ms per avís. Dos-cents avisos són seixanta segons de rellotge, durant els quals el processador està pràcticament inactiu: està esperant el disc, no calculant.

Cas C — dos empleats alhora. Avui BiblioTech és d'un sol usuari. Si demà Marta Ruiz i Diego Alonso registressin préstecs simultàniament sobre el mateix Cataleg, res del codi no impediria que un trepitgés la feina de l'altre. No hi ha ni un synchronized a tot el projecte perquè fins ara no calia.

Els tres casos tenen la mateixa arrel —un únic flux d'execució— i tres solucions diferents que aquest mòdul desenvolupa. El cas A necessita moure la feina a un altre flux. El cas B necessita diversos fluxos solapant esperes. El cas C necessita protegir el que és compartit.

  1. Procés davant de fil

Quan llances java BiblioTechApp, el sistema operatiu crea un procés: una unitat d'execució amb el seu propi espai de memòria virtual, els seus propis descriptors de fitxer i la seva pròpia identitat de seguretat. Dins d'aquest procés, la JVM crea fils: fluxos d'execució independents que comparteixen tota aquesta memòria.

La diferència essencial cap en una frase: dos processos no es veuen la memòria; dos fils del mateix procés comparteixen tota la memòria del monticle. D'aquí es deriven totes les altres diferències.

Aspecte Procés Fil
Espai de memòria Propi i aïllat Compartit amb els altres fils del procés
Cost de creació Alt (mil·lisegons, copiar taules de pàgines) Baix (microsegons)
Cost de canvi de context Alt (canviar taules de pàgines, buidar TLB) Baix (canviar registres i punter de pila)
Comunicació entre ells Cara: fitxers, canonades, sockets, memòria compartida explícita Immediata: una variable
Aïllament davant de fallades Total: si un cau, l'altre continua Nul: un OutOfMemoryError tomba tot el procés
Ús típic de memòria Desenes o centenars de MB ~1 MB de pila reservada (Linux x64, per defecte)
Qui ho gestiona El sistema operatiu El sistema operatiu, exposat per la JVM
flowchart TB
    subgraph P1["Procés A - BiblioTechApp"]
        direction TB
        H1["Monticle compartit<br/>Cataleg, RegistrePrestecs, estàtics"]
        subgraph FilsA[" "]
            direction LR
            T1["Fil main<br/>pila pròpia"]
            T2["Fil importador<br/>pila pròpia"]
            T3["Fil avisos<br/>pila pròpia"]
        end
        T1 --- H1
        T2 --- H1
        T3 --- H1
    end
    subgraph P2["Procés B - editor de text"]
        H2["Monticle propi<br/>invisible des del procés A"]
        T4["Fil main<br/>pila pròpia"]
        T4 --- H2
    end
    P1 -.->|"només per fitxer, canonada o socket"| P2

Les fletxes contínues dins del procés A són accés directe a memòria: el fil importador pot escriure en el mateix objecte Cataleg que està llegint el fil main, sense demanar permís a ningú i sense que el sistema operatiu hi intervingui. La fletxa discontínua entre processos representa el contrari: perquè l'editor de text vegi alguna cosa del procés A cal un mecanisme explícit i car.

Aquesta fletxa contínua és alhora tota la potència i tot el perill del multifil. Compartir memòria és el que fa barats els fils, i és el que fa que un comptador pugui perdre increments.

Nota històrica i pràctica. El model d'"un procés per tasca" no és obsolet: és exactament el que fan els navegadors moderns amb cada pestanya, precisament per l'aïllament davant de fallades. Quan l'aïllament importa més que la comunicació barata, processos. Quan la comunicació importa més, fils.

  1. Què comparteixen i què no comparteixen dos fils

Aquest és l'apartat més important de la lliçó. Si interioritzes aquest diagrama, la majoria dels errors de concurrència deixen de ser màgia negra i passen a ser conseqüències previsibles.

Cada fil de la JVM té la seva pròpia pila, el seu propi comptador de programa i, en conseqüència, les seves pròpies variables locals i paràmetres. Tota la resta —el monticle, on viuen els objectes, i l'àrea d'estàtics— és compartida.

flowchart TB
    subgraph JVM["Procés JVM"]
        direction TB
        subgraph Compartit["COMPARTIT per tots els fils"]
            direction LR
            MONTICLE["Monticle (heap)<br/>tots els objectes<br/>new Llibre(...), arrays, cadenes"]
            EST["Àrea de classes<br/>camps static<br/>metadades, constants"]
        end
        subgraph Privat["PRIVAT de cada fil"]
            direction LR
            subgraph FA["Fil main"]
                PA["Pila<br/>marcs de mètode<br/>variables locals<br/>Comptador de programa"]
            end
            subgraph FB["Fil importador"]
                PB["Pila<br/>marcs de mètode<br/>variables locals<br/>Comptador de programa"]
            end
        end
        PA -->|"les referències apunten a"| MONTICLE
        PB -->|"les referències apunten a"| MONTICLE
        PA --> EST
        PB --> EST
    end

Traduït a codi:

public class QueEsComparteix {

    // COMPARTIT: un unic valor per a tota la JVM.
    // Si dos fils el modifiquen, es trepitgen.
    private static int comptadorGlobal = 0;

    // L'objecte al qual apunta aquesta referencia viu al monticle:
    // si dos fils tenen la mateixa referencia, veuen el MATEIX objecte.
    private final Cataleg cataleg;

    public QueEsComparteix(Cataleg cataleg) {
        this.cataleg = cataleg;
    }

    public void processar(String isbn) {
        // PRIVAT: 'trobat' i 'reintents' viuen a la pila del fil
        // que esta executant 'processar'. Si tres fils criden aquest
        // metode alhora, hi ha TRES copies independents, una per pila.
        boolean trobat = false;
        int reintents = 0;

        // 'cataleg' es una referencia (copia privada a la pila),
        // pero l'OBJECTE al qual apunta es compartit.
        // Modificar-lo afecta tots els fils.
        Material m = cataleg.cercar(isbn);
        trobat = (m != null);

        comptadorGlobal++;   // <-- COMPARTIT. Aqui hi ha el problema.
    }
}

Les tres regles que se'n dedueixen i que has de memoritzar:

  1. Les variables locals són segures per construcció. Un int i declarat dins d'un mètode no pot tenir una condició de cursa: cada fil té el seu. Això és confinament a la pila i és la forma més barata de seguretat que existeix.
  2. Els camps d'objecte i els static són compartits si l'objecte és assolible des de diversos fils. Un Llibre creat i utilitzat per un sol fil és tan segur com una variable local. El perill no és el camp: és la compartició.
  3. Una referència local a un objecte compartit no protegeix res. És l'error d'intuïció més freqüent de qui comença: Material m és local, però el Material que hi ha a l'altra banda no ho és.

Regla pràctica que faràs servir mil vegades. Abans de preguntar-te "això necessita synchronized?", pregunta't "pot aquest objecte ser assolit per dos fils alhora?". Si la resposta és no, no hi ha res a sincronitzar. La meitat de la sincronització que s'escriu a la indústria és innecessària per no fer-se aquesta pregunta.

  1. Concurrència davant de paral·lelisme

Els dos termes es fan servir com a sinònims en la conversa informal i no ho són. La distinció és la que fa Rob Pike en una frase citadíssima: "la concurrència és tractar amb moltes coses alhora; el paral·lelisme és fer moltes coses alhora".

  • Concurrència: estructura. Diverses tasques estan en curs durant el mateix període de temps. Es poden executar intercalades en un únic nucli, tornejant-se en llesques de temps d'uns pocs mil·lisegons.
  • Paral·lelisme: execució. Diverses tasques executen instruccions en el mateix instant físic, cosa que exigeix diversos nuclis.
flowchart TB
    subgraph C["CONCURRÈNCIA - 1 nucli, tasques intercalades"]
        direction LR
        c1["A"] --> c2["B"] --> c3["A"] --> c4["B"] --> c5["A"] --> c6["B"]
    end
    subgraph P["PARAL·LELISME - 2 nuclis, tasques simultànies"]
        direction TB
        subgraph N1["Nucli 1"]
            direction LR
            p1["A"] --> p2["A"] --> p3["A"]
        end
        subgraph N2["Nucli 2"]
            direction LR
            p4["B"] --> p5["B"] --> p6["B"]
        end
    end

Tres conseqüències que importen de veritat:

Primera: la concurrència serveix encara que només tinguis un nucli. El cas A de BiblioTech —el menú que respon mentre s'importa— es resol amb concurrència pura. Encara que la màquina tingués un sol nucli, intercalar les dues tasques faria que el menú respongués. No va més ràpid: va més receptiu, que és una altra cosa.

Segona: el paral·lelisme és l'única manera que un càlcul acabi abans. Si has de calcular les multes de cinquanta mil préstecs i això és pur càlcul, intercalar en un nucli no estalvia ni un mil·lisegon. Només repartir entre nuclis ho fa.

Tercera i la que més fa mal: el teu codi concurrent s'executarà en paral·lel, vulguis o no. És temptador raonar "la meva màquina de desenvolupament fa les coses per torns, així que l'error de cursa no apareixerà". El servidor de producció té trenta-dos nuclis i la reordenació de memòria que veuràs a 08-04 es manifesta allà i no al teu portàtil. Un programa concurrent correcte ho ha de ser per a qualsevol entrellaçat possible.

Concurrència Paral·lelisme
Què és Una propietat del disseny Una propietat de l'execució
Nuclis necessaris Un n'hi ha prou Dos o més
Què millora Receptivitat, aprofitament d'esperes Temps total de càlcul
Cas BiblioTech Menú viu durant la importació 200 avisos repartits entre 8 nuclis
S'aconsegueix amb Fils, tasques, asincronia Fils executant alhora sobre diversos nuclis

  1. Per què es fan servir fils: els tres motius

Només hi ha tres raons legítimes per introduir fils en un programa. Si el teu motiu no és una d'elles, probablement estiguis afegint complexitat sense guany.

Motiu 1: aprofitar diversos nuclis. Un programa seqüencial en una màquina de vuit nuclis fa servir el 12,5 % de la capacitat de càlcul. Si la feina és divisible, repartir-la pot acostar-se a un factor de vuit. Pot: la llei d'Amdahl diu que l'acceleració està limitada per la fracció que no es pot paral·lelitzar. Si el 20 % de la teva feina és inherentment seqüencial, l'acceleració màxima amb infinits nuclis és 5×, no infinita.

Motiu 2: no bloquejar la interfície. Tot programa amb interacció —consola, escriptori, web— té un flux que ha de continuar responent. És el cas A: el menú de BiblioTech no es pot quedar mut vuit segons. La solució no és fer la importació més ràpida: és treure-la del fil que atén l'usuari.

Motiu 3: solapar espera d'E/S amb feina útil. Aquest és el motiu més rendible i el pitjor entès. Quan un fil crida Files.readAllLines, el sistema operatiu el suspèn fins que el disc respongui. Durant aquests mil·lisegons el fil no consumeix CPU: està aparcat. Si tens dos-cents avisos per escriure i cadascun espera 300 ms el disc, un sol fil suma seixanta segons d'espera; vint fils solapen aquestes esperes i baixen a tres segons —sense necessitar vint nuclis, perquè no estan calculant, estan esperant.

Els tres motius mapen exactament sobre els tres casos de l'apartat 1:

Cas BiblioTech Motiu Què es guanya
A: la importació bloqueja el menú No bloquejar la interfície Receptivitat i cancel·lació
B: 200 avisos d'un en un Solapar espera d'E/S De 60 s a ~3 s
C: dos empleats alhora (Cap: és el cost) Res; cal pagar protecció

El cas C és important precisament perquè no és un motiu, és una factura. Tan bon punt introdueixes fils pels motius 1, 2 o 3, apareix l'obligació de protegir el que és compartit. La concurrència no és gratis: canvia rendiment i receptivitat per complexitat i classes senceres d'errors nous.

  1. Tasques limitades per CPU i limitades per E/S

Tota tasca cau, aproximadament, en una de dues categories. La categoria determina quants fils convé fer servir, i equivocar-se aquí és la causa habitual que "vaig afegir fils i va més lent".

  • Limitada per CPU (CPU-bound): el fil passa gairebé tot el temps executant instruccions. Calcular multes, ordenar un milió d'elements, comprimir, xifrar, analitzar text.
  • Limitada per E/S (I/O-bound): el fil passa gairebé tot el temps esperant un dispositiu. Llegir un fitxer, escriure al disc, esperar una base de dades, esperar la xarxa.
import java.util.concurrent.TimeUnit;

public class TipusDeTasca {

    /** Limitada per CPU: el nucli treballa al 100% durant tot el metode. */
    static long calcularHash(String text, int voltes) {
        long h = 0;
        for (int v = 0; v < voltes; v++) {
            for (int i = 0; i < text.length(); i++) {
                h = h * 31 + text.charAt(i);
            }
        }
        return h;
    }

    /** Limitada per E/S: el fil esta suspes, el nucli queda lliure. */
    static void escriureAvis(String destinatari) throws InterruptedException {
        // Simulem la latencia del disc. Durant aquests 300 ms el fil
        // NO consumeix CPU: el sistema operatiu el te aparcat.
        TimeUnit.MILLISECONDS.sleep(300);
    }
}

La regla de dimensionament, que es desenvoluparà amb pool de fils a 08-05:

Limitada per CPU Limitada per E/S
On és el fil Executant Esperant, suspès
Fils útils ≈ nombre de nuclis Molts més que nuclis (desenes o centenars)
Què passa si en poses més Pitjor: els canvis de context són pur cost Millor: més esperes solapades
Què passa si en poses menys Nuclis ociosos Temps de rellotge malgastat esperant
Fórmula orientativa N = nuclis (o N+1) N × (1 + espera/càlcul)
Cas BiblioTech Recalcular totes les multes Enviar els 200 avisos

Java et diu quants nuclis hi ha:

public class Nuclis {
    public static void main(String[] args) {
        int nuclis = Runtime.getRuntime().availableProcessors();
        System.out.println("Processadors disponibles: " + nuclis);

        // Dimensionament orientatiu per a tasques de calcul pur.
        System.out.println("Pool suggerit (CPU): " + nuclis);

        // Per a E/S amb ratio espera/calcul ~= 9 (300 ms esperant per
        // cada 30 ms calculant), la formula dona N * (1 + 9) = N * 10.
        System.out.println("Pool suggerit (E/S, ratio 9): " + (nuclis * 10));
    }
}

Dos matisos sobre availableProcessors() que eviten sorpreses en producció:

  1. Retorna fils de maquinari, no nuclis físics. Un processador de 4 nuclis amb hyperthreading en retorna 8. Per a tasques de càlcul pur, els fils lògics rendeixen bastant menys que un nucli real.
  2. En un contenidor amb límit de CPU, les JVM modernes (Java 10+) respecten el límit del cgroup. En Java 8 antic retornava els nuclis de la màquina amfitriona, cosa que provocava pools gegants en contenidors diminuts. És un clàssic de les migracions.

  1. El fil principal i el fil actual

No has escrit mai un programa d'un sol fil. Quan la JVM arrenca, crea el fil main i li fa executar el teu mètode main; a més hi ha diversos fils interns —el recol·lector de brossa, el compilador JIT, el finalitzador— treballant en segon pla des del primer instant.

Thread.currentThread() retorna el fil que està executant aquella línia en aquell moment. És l'eina de diagnòstic número u d'aquest mòdul:

public class FilActual {
    public static void main(String[] args) {
        Thread actual = Thread.currentThread();

        System.out.println("Nom    : " + actual.getName());
        System.out.println("Id     : " + actual.threadId());   // Java 19+; abans getId()
        System.out.println("Estat  : " + actual.getState());
        System.out.println("Prio.  : " + actual.getPriority());
        System.out.println("Dimoni : " + actual.isDaemon());
        System.out.println("Grup   : " + actual.getThreadGroup().getName());

        saludar();
    }

    static void saludar() {
        // El fil actual no depen del metode: depen de QUI el crida.
        System.out.println("saludar() executa a: "
                + Thread.currentThread().getName());
    }
}

Sortida:

Nom    : main
Id     : 1
Estat  : RUNNABLE
Prio.  : 5
Dimoni : false
Grup   : main
saludar() executa a: main

Fixa't en el detall del mètode saludar(): un mètode no "pertany" a un fil. El mateix mètode pot estar executant-se simultàniament en cinc fils diferents, cadascun amb el seu propi joc de variables locals a la seva pròpia pila. Això torna a ser l'apartat 3, vist des del codi.

Consell que aplicaràs a tot el mòdul. Quan passi alguna cosa estranya, imprimeix o registra Thread.currentThread().getName(). La pregunta "en quin fil s'està executant això?" resol una proporció sorprenent dels misteris de concurrència. A BiblioTech, RegistreOperacions hauria d'incloure el nom del fil a cada entrada; ho faràs a l'exercici 3.

  1. Fils dimoni i quan acaba la JVM

Java distingeix dues classes de fils:

  • No dimoni (user thread): el fil normal. main ho és.
  • Dimoni (daemon): fil de servei, pensat per a tasques de suport que no han d'impedir el tancament del programa.

La regla és taxativa i s'ha de saber de memòria:

La JVM acaba quan no queda cap fil NO dimoni viu. Els fils dimoni s'avorten en aquell instant, sense previ avís, sense executar finally, sense tancar recursos.

Que main acabi no apaga la JVM: si un fil no dimoni continua viu, el procés es manté dret. És la causa clàssica de "el programa no acaba i no sé per què".

public class DimoniVsUsuari {
    public static void main(String[] args) throws InterruptedException {
        Runnable tasca = () -> {
            for (int i = 1; i <= 5; i++) {
                System.out.println(Thread.currentThread().getName() + " pas " + i);
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
            }
            System.out.println(Thread.currentThread().getName() + " ACABAT");
        };

        Thread usuari = new Thread(tasca, "fil-usuari");
        Thread dimoni = new Thread(tasca, "fil-dimoni");
        dimoni.setDaemon(true);   // OBLIGATORI abans de start()

        usuari.start();
        dimoni.start();

        Thread.sleep(500);
        System.out.println(">>> main acaba aqui");
    }
}

Sortida típica:

fil-usuari pas 1
fil-dimoni pas 1
fil-usuari pas 2
fil-dimoni pas 2
fil-usuari pas 3
fil-dimoni pas 3
>>> main acaba aqui
fil-usuari pas 4
fil-usuari pas 5
fil-usuari ACABAT

Llegeix-ho amb cura, perquè hi ha dos fets en aquesta sortida:

  1. main acaba al pas 3 i el procés no mor: fil-usuari és no dimoni i la JVM l'espera fins que imprimeix ACABAT.
  2. fil-dimoni no arriba mai als passos 4 i 5. En l'instant en què fil-usuari acaba, no queda cap fil no dimoni, la JVM s'apaga i el dimoni es talla a mig camí.
No dimoni Dimoni
Impedeix que la JVM acabi No
En apagar-se la JVM Se l'espera S'avorta sense avisar
Executa el seu finally en apagar Sí, acaba normalment No garantit
Ús típic Feina del negoci Monitoratge, neteja periòdica, memòries cau
Com es marca Per defecte setDaemon(true) abans de start()

Regles pràctiques:

  • setDaemon(true) després de start() llança IllegalThreadStateException. Sempre abans.
  • Un fil hereta per defecte la condició de dimoni del fil que el crea.
  • No posis mai feina que no es pugui perdre en un fil dimoni. Si un dimoni està escrivint el fitxer del catàleg quan la JVM s'apaga, el fitxer queda a mitges. Per a feina que s'ha de completar, fil no dimoni i espera explícita amb join() (08-02).
  • El shutdown hook que vas afegir a BiblioTech al mòdul 7 sí que s'executa en apagar, i és el lloc correcte per al desat d'estat. No el substitueixis per un dimoni.

  1. El cost real d'un fil

Un fil sembla gratis perquè new Thread(...) és una línia. No ho és.

Memòria. Cada fil reserva una pila. En un Linux de 64 bits el valor per defecte és d'1 MB (ajustable amb -Xss). És espai d'adreces reservat i la memòria física es va tocant a mesura que creix la pila, però l'ordre de magnitud mana: deu mil fils són deu gigabytes de piles reservades. En una màquina normal, la JVM cau amb OutOfMemoryError: unable to create native thread molt abans.

Creació. Crear un fil de plataforma implica una crida al sistema i que el kernel munti les seves estructures. L'ordre és de desenes a centenars de microsegons: mil vegades més car que crear un objecte normal. Si crees un fil per cada petició i les peticions són curtes, el cost de creació pot superar la feina útil.

Canvi de context. Quan el planificador treu un fil i en posa un altre, cal desar i restaurar registres, i —el que és car— la memòria cau de dades del nucli queda plena de dades del fil anterior. El cost directe ronda el microsegon; l'indirecte, per fallades de memòria cau, pot ser molt més gran. Amb més fils actius que nuclis, la màquina pot passar més temps canviant de fil que treballant: és el thrashing de planificació.

public class CostDeCrearFils {
    public static void main(String[] args) throws InterruptedException {
        int n = 10_000;

        long t0 = System.nanoTime();
        Thread[] fils = new Thread[n];
        for (int i = 0; i < n; i++) {
            fils[i] = new Thread(() -> { /* no fa res */ });
        }
        long tCreacio = System.nanoTime() - t0;

        long t1 = System.nanoTime();
        for (Thread f : fils) f.start();
        for (Thread f : fils) f.join();
        long tArrencada = System.nanoTime() - t1;

        System.out.printf("Construir %d objectes Thread: %.1f ms%n",
                n, tCreacio / 1_000_000.0);
        System.out.printf("Arrencar i esperar %d fils: %.1f ms%n",
                n, tArrencada / 1_000_000.0);
        System.out.printf("Cost mitja per fil arrencat: %.1f us%n",
                tArrencada / 1_000.0 / n);
    }
}

Sortida orientativa (varia molt segons la màquina):

Construir 10000 objectes Thread: 4,7 ms
Arrencar i esperar 10000 fils: 812,3 ms
Cost mitja per fil arrencat: 81,2 us

Vuitanta microsegons per fil sense fer absolutament res. Si la teva tasca real dura menys que això, crear un fil per a ella és una pèrdua neta.

D'aquí surt la conclusió que governa la pràctica moderna: "un fil per tasca" no escala. La solució clàssica és el pool de fils, un nombre fix de fils reutilitzats que van consumint tasques d'una cua; és 08-05. La solució nova són els fils virtuals de Java 21, que tornen el cost tan baix que "un fil per tasca" torna a ser viable; s'expliquen a 10-06.

  1. Els tres perills: cursa, interbloqueig, inanició

Abans d'escriure codi concurrent convé conèixer els tres modes de fallada. Aquí només es presenten amb una intuïció; resoldre'ls és la feina de 08-04 i següents.

Condició de cursa. El resultat depèn de l'ordre en què s'entrellacin els fils, i aquest ordre no el controles.

Intuïció: dos empleats miren alhora el mateix full d'inventari, tots dos llegeixen "en queden 3 exemplars", tots dos anoten "2" després de prestar-ne un. S'han prestat dos exemplars i l'inventari diu que se'n va prestar un.

Interbloqueig (deadlock). Dos fils s'esperen mútuament per sempre, cadascun retenint el que l'altre necessita.

Intuïció: la Marta té la clau de l'arxiu i necessita la del magatzem; en Diego té la del magatzem i necessita la de l'arxiu. Cap dels dos no deixa anar la seva. Tots dos esperen indefinidament i no hi ha error, ni excepció, ni traça: simplement res no avança.

Inanició (starvation). Un fil no aconsegueix mai el recurs que necessita perquè uns altres se l'emporten sempre.

Intuïció: la impressora de la biblioteca atén primer els treballs curts. L'informe anual de 400 pàgines de la Nuria Vidal no arriba a imprimir-se mai, perquè sempre hi ha algú amb un treball de dues pàgines al davant.

Fallada Símptoma S'estudia i es resol a
Condició de cursa Resultats incorrectes, intermitents, irreproduïbles 08-04 (synchronized, atòmiques), 08-06
Interbloqueig Bloqueig total i silenciós, sense error 08-04 (ordre global, tryLock), 08-03 (diagnòstic)
Inanició Una tasca no progressa mai mentre les altres sí 08-04 (equitat), 08-05 (pools)

Afegeix una quarta característica comuna als tres que fa la concurrència especialment traïdora: són heisenbugs. Afegir un System.out.println per depurar canvia els temps i fa desaparèixer la fallada. Executar amb el depurador, igual. I un programa amb una condició de cursa pot funcionar correctament durant mesos fins que un dia, amb més càrrega o en una màquina amb més nuclis, produeix una dada incorrecta. Per això aquest mòdul insisteix tant en la correcció per disseny i no a provar fins que sembli que funciona.

  1. El primer exemple que falla: el comptador compartit

Aquí tens el programa promès. Dos fils, un comptador compartit, cada fil suma un un milió de vegades. El resultat esperat és 2.000.000. Gairebé mai no ho és.

public class ComptadorTrencat {

    // Compartit pels dos fils: un unic int al monticle.
    private int valor = 0;

    public void incrementar() {
        valor++;   // Sembla una operacio. En son TRES. Veure 08-04.
    }

    public int valor() {
        return valor;
    }

    public static void main(String[] args) throws InterruptedException {
        final int VOLTES = 1_000_000;

        // Repetim l'experiment diverses vegades: la fallada es intermitent
        // i una sola execucio et podria enganyar.
        for (int intent = 1; intent <= 5; intent++) {
            ComptadorTrencat comptador = new ComptadorTrencat();

            Thread f1 = new Thread(() -> {
                for (int i = 0; i < VOLTES; i++) comptador.incrementar();
            }, "sumador-1");

            Thread f2 = new Thread(() -> {
                for (int i = 0; i < VOLTES; i++) comptador.incrementar();
            }, "sumador-2");

            f1.start();
            f2.start();

            // join() espera que el fil acabi. Sense aixo llegiriem
            // el comptador mentre encara esta canviant. Detall a 08-02.
            f1.join();
            f2.join();

            int esperat = VOLTES * 2;
            int real = comptador.valor();
            System.out.printf("Intent %d -> esperat %d, real %d, perduts %d (%.2f%%)%n",
                    intent, esperat, real, esperat - real,
                    100.0 * (esperat - real) / esperat);
        }
    }
}

Sortida real d'una execució (els números canvien a cada execució, aquest és justament el punt):

Intent 1 -> esperat 2000000, real 1362544, perduts 637456 (31,87%)
Intent 2 -> esperat 2000000, real 1198801, perduts 801199 (40,06%)
Intent 3 -> esperat 2000000, real 2000000, perduts 0 (0,00%)
Intent 4 -> esperat 2000000, real 1455913, perduts 544087 (27,22%)
Intent 5 -> esperat 2000000, real 1734022, perduts 265978 (13,30%)

Quatre observacions, totes importants:

Primera: no es perd un increment, se'n perden centenars de milers. No és un cas raríssim que es dona una vegada entre un milió: és una fallada massiva. La raó s'explica a 08-04, però el resum és que valor++ es compila a tres instruccions —llegir el valor, sumar-li un, escriure el resultat— i qualsevol entrellaçat entre aquestes tres perd feina.

Segona: l'intent 3 va donar el resultat correcte. Això és el veritablement perillós. Si haguessis executat el programa una sola vegada i hagués sortit l'intent 3, hauries conclòs que el codi està bé. Que un programa concurrent doni el resultat correcte no demostra res.

Tercera: mai no surt un valor més gran de 2.000.000. Els increments es perden, no es dupliquen. Amb un int de 32 bits les escriptures són atòmiques —cada escriptura escriu un valor complet—, així que l'error és sempre per defecte. Amb un long en una JVM de 32 bits es podien veure valors impossibles, meitat d'una escriptura i meitat d'una altra; també a 08-04.

Quarta: si substitueixes VOLTES per 1000, probablement surti sempre correcte. Amb poca feina, el primer fil acaba abans que el segon arrenqui i no arriba a haver-hi solapament. És la raó per la qual aquests errors no apareixen en proves petites i sí en producció.

Un experiment que convé fer. Canvia private int valor per private volatile int valor i torna a executar. Continua fallant. És la demostració que volatile no resol això, i una de les confusions més esteses del tema; el perquè és a 08-04.

Deixa aquest programa a mà. Hi tornaràs amb synchronized a 08-04 i amb AtomicInteger a 08-06, i veuràs les dues formes correctes d'arreglar-lo, amb el seu cost respectiu.

Errors Comuns i Consells

Error 1: creure que "no l'he vist fallar" significa "és correcte". És l'error conceptual més greu del mòdul. Un entrellaçat desfavorable pot ser rar al teu portàtil i freqüent en un servidor de 32 nuclis amb càrrega. Raona la correcció; no la provis a base d'execucions.

Error 2: confondre "és local" amb "és segur". Material m = cataleg.cercar(isbn) declara una referència local a un objecte compartit. El que és segur és la referència; l'objecte no.

Error 3: pensar que més fils sempre van més ràpid. Per a tasques de càlcul, passar de 8 a 80 fils en 8 nuclis empitjora el rendiment per canvis de context. Mesura abans de decidir.

Error 4: cridar setDaemon(true) després de start(). Llança IllegalThreadStateException. I pitjor: posar feina important en un dimoni i descobrir que es talla a mitja escriptura.

Error 5: suposar que main en acabar apaga la JVM. Amb un fil no dimoni viu, el procés continua. Si el teu programa "no acaba", busca fils no dimoni que continuen corrent (veuràs com diagnosticar-ho amb bolcats de fils a 08-03).

Error 6: afegir fils sense un motiu dels tres de l'apartat 5. Complexitat, errors nous i diagnòstic difícil a canvi de res.

Error 7: depurar concurrència amb System.out.println. println està internament sincronitzat, així que canvia el comportament: pot fer desaparèixer la cursa que intentes veure. Fes servir el logger de 06-07, inclou el nom del fil, i acumula dades en memòria per bolcar-les al final quan mesuris.

Consell 1: pensa en "tasques", no en "fils". La resta del mòdul va en aquesta direcció: descrius quina feina cal fer i deixes que un executor decideixi on es fa.

Consell 2: la millor sincronització és no compartir. Si cada fil treballa sobre les seves pròpies dades i només es combinen els resultats al final, no hi ha curses possibles. És el principi de confinament, i es desenvolupa a 08-04.

Consell 3: posa nom a tots els teus fils des del primer dia. Un bolcat amb Thread-0, Thread-1 i Thread-2 no diu res; amb bibliotech-importador, bibliotech-avisos-3 et diu on mirar. Es fa a 08-02.

Consell 4: mesura amb System.nanoTime(), no amb currentTimeMillis(). nanoTime és un rellotge monòton pensat per mesurar intervals; currentTimeMillis pot saltar cap enrere si el sistema ajusta l'hora.

Exercicis

Exercici 1: Diagnòstic de l'entorn d'execució

Escriu una classe DiagnosticConcurrencia amb un main que imprimeixi un informe de l'entorn: nombre de processadors disponibles, memòria màxima de la JVM, nom, identificador, prioritat i condició de dimoni del fil actual, i el nombre total de fils vius a la JVM en aquell moment. A més, per a una tasca limitada per E/S amb una espera mitjana de 400 ms i un càlcul mitjà de 50 ms per tasca, calcula i imprimeix la mida de pool recomanada segons la fórmula N × (1 + espera/càlcul).

Pista: Thread.activeCount() dona una aproximació dels fils vius del grup actual; Runtime.getRuntime().maxMemory() dona la memòria màxima en bytes.

Exercici 2: Demostrar la pèrdua d'increments en funció de la càrrega

Amplia l'exemple de l'apartat 11 per respondre empíricament a aquesta pregunta: a partir de quantes voltes comença a fallar? Escriu LlindarDeCursa que, per a cada valor de voltes a {100, 1_000, 10_000, 100_000, 1_000_000}, repeteixi l'experiment 10 vegades amb dos fils i compti en quantes de les 10 repeticions el resultat va ser incorrecte. Imprimeix una taula amb la càrrega, el percentatge d'execucions fallides i la pèrdua mitjana.

Exercici 3: Traces amb identitat de fil

Afegeix a BiblioTech un mètode d'utilitat RegistreOperacions.tracaFil(String operacio) que registri al logger de 06-07, en nivell INFO, una línia amb el nom del fil actual, el seu identificador i l'operació. Després escriu un main de demostració que llanci dos fils anomenats bibliotech-importador i bibliotech-avisos, cadascun dels quals cridi tracaFil tres vegades amb pauses de 100 ms, i comprova a la sortida que les línies dels dos fils apareixen intercalades i no en blocs.

Solucions

Solució a l'Exercici 1

public class DiagnosticConcurrencia {

    public static void main(String[] args) {
        Runtime rt = Runtime.getRuntime();
        Thread actual = Thread.currentThread();

        int nuclis = rt.availableProcessors();

        System.out.println("=== ENTORN D'EXECUCIO ===");
        System.out.println("Java                 : " + System.getProperty("java.version"));
        System.out.println("Sistema operatiu     : " + System.getProperty("os.name"));
        System.out.println("Processadors dispon. : " + nuclis);
        System.out.printf ("Memoria maxima JVM   : %.0f MB%n", rt.maxMemory() / (1024.0 * 1024));
        System.out.printf ("Memoria total actual : %.0f MB%n", rt.totalMemory() / (1024.0 * 1024));

        System.out.println();
        System.out.println("=== FIL ACTUAL ===");
        System.out.println("Nom       : " + actual.getName());
        System.out.println("Id        : " + actual.threadId());
        System.out.println("Prioritat : " + actual.getPriority());
        System.out.println("Dimoni    : " + actual.isDaemon());
        System.out.println("Estat     : " + actual.getState());

        // activeCount() compta els fils vius del grup actual i els seus subgrups.
        // Es una ESTIMACIO: el numero pot canviar mentre es calcula.
        System.out.println("Fils vius (aprox.): " + Thread.activeCount());

        System.out.println();
        System.out.println("=== DIMENSIONAMENT DE POOL ===");

        // Tasca limitada per CPU: un fil per nucli mante els nuclis
        // ocupats sense pagar canvis de context innecessaris.
        System.out.println("Pool per a tasques de CPU : " + nuclis);

        // Tasca limitada per E/S: formula N * (1 + espera/calcul).
        // Amb 400 ms d'espera i 50 ms de calcul, el ratio es 8,
        // aixi que cada nucli pot sostenir 9 fils utils.
        double espera = 400.0;
        double calcul = 50.0;
        double ratio = espera / calcul;
        int poolIo = (int) Math.round(nuclis * (1 + ratio));

        System.out.printf("Ratio espera/calcul       : %.1f%n", ratio);
        System.out.println("Pool per a tasques d'E/S  : " + poolIo);
        System.out.println();
        System.out.println("Nota: son punts de partida, no veritats. "
                + "La mida definitiva es decideix mesurant (08-05).");
    }
}

Punts didàctics:

  • availableProcessors() pot retornar valors diferents en execucions diferents si la JVM corre en un contenidor amb límits dinàmics. No el guardis en memòria cau en un static final si el procés és de llarga durada.
  • activeCount() és explícitament aproximat: els fils poden néixer i morir mentre es compten. Per a diagnòstic seriós es fa servir ThreadMXBean (08-03).
  • El pool d'E/S surt molt més gran que el nombre de nuclis, i aquesta és exactament la intuïció de l'apartat 6: els fils que esperen no competeixen per CPU.

Solució a l'Exercici 2

public class LlindarDeCursa {

    /** Comptador deliberadament insegur. */
    static class Comptador {
        private int valor = 0;
        void incrementar() { valor++; }
        int valor() { return valor; }
    }

    /**
     * Executa un experiment: dos fils incrementant 'voltes' vegades.
     * Retorna quants increments s'han perdut (0 si tot ha anat be).
     */
    static int experiment(int voltes) throws InterruptedException {
        Comptador c = new Comptador();

        // Un unic Runnable compartit pels dos fils: tots dos
        // incrementen EL MATEIX objecte Comptador.
        Runnable tasca = () -> {
            for (int i = 0; i < voltes; i++) c.incrementar();
        };

        Thread f1 = new Thread(tasca, "sumador-1");
        Thread f2 = new Thread(tasca, "sumador-2");

        f1.start();
        f2.start();
        f1.join();
        f2.join();

        return (voltes * 2) - c.valor();
    }

    public static void main(String[] args) throws InterruptedException {
        int[] carregues = { 100, 1_000, 10_000, 100_000, 1_000_000 };
        int REPETICIONS = 10;

        System.out.printf("%-12s | %-14s | %-16s%n",
                "Voltes", "Fallades /10", "Perdua mitjana");
        System.out.println("-------------|----------------|------------------");

        for (int voltes : carregues) {
            int fallades = 0;
            long perduaTotal = 0;

            for (int r = 0; r < REPETICIONS; r++) {
                int perduts = experiment(voltes);
                if (perduts != 0) {
                    fallades++;
                    perduaTotal += perduts;
                }
            }

            double perduaMitjana = fallades == 0 ? 0 : (double) perduaTotal / fallades;
            System.out.printf("%-12d | %-14s | %-16.1f%n",
                    voltes, fallades + " / " + REPETICIONS, perduaMitjana);
        }

        System.out.println();
        System.out.println("Conclusio: la fallada NO desapareix amb carregues petites,");
        System.out.println("simplement es torna menys probable. Un codi que 'mai no");
        System.out.println("ha fallat' amb 100 voltes es exactament igual d'incorrecte");
        System.out.println("que un que falla sempre amb 1.000.000.");
    }
}

Sortida orientativa:

Voltes       | Fallades /10   | Perdua mitjana
-------------|----------------|------------------
100          | 0 / 10         | 0,0
1000         | 1 / 10         | 412,0
10000        | 6 / 10         | 2871,3
100000       | 10 / 10        | 39104,7
1000000      | 10 / 10        | 548219,2

L'important d'aquest exercici no és el número: és la forma de la corba. Amb 100 voltes no falla mai perquè el primer fil acaba abans que el segon comenci a solapar-se; amb un milió falla sempre. Un programa de producció és la columna de la dreta, i la teva prova unitària sol ser la de l'esquerra.

Solució a l'Exercici 3

package com.nexussoftware.bibliotech.infraestructura;

import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Ampliacio de RegistreOperacions (06-07) amb identitat de fil.
 * A partir del modul 8, TOTA traca hauria de dir en quin fil passa:
 * sense aquesta dada, un log concurrent es illegible.
 */
public final class RegistreOperacions {

    private static final Logger LOG =
            Logger.getLogger(RegistreOperacions.class.getName());

    private RegistreOperacions() { }

    /** Registra una operacio incloent-hi el fil que l'executa. */
    public static void tracaFil(String operacio) {
        Thread f = Thread.currentThread();
        LOG.log(Level.INFO, "[fil={0} id={1}] {2}",
                new Object[] { f.getName(), f.threadId(), operacio });
    }

    // --- Demostracio ---
    public static void main(String[] args) throws InterruptedException {

        Runnable feina = () -> {
            for (int pas = 1; pas <= 3; pas++) {
                tracaFil("pas " + pas);
                try {
                    TimeUnit.MILLISECONDS.sleep(100);
                } catch (InterruptedException e) {
                    // Protocol correcte de 08-02: restaurar el marcador i sortir.
                    Thread.currentThread().interrupt();
                    return;
                }
            }
        };

        Thread importador = new Thread(feina, "bibliotech-importador");
        Thread avisos     = new Thread(feina, "bibliotech-avisos");

        tracaFil("arrencant fils de treball");
        importador.start();
        avisos.start();

        importador.join();
        avisos.join();
        tracaFil("tots els fils han acabat");
    }
}

Sortida (amb el formatador de ConfiguracioLog):

INFO: [fil=main id=1] arrencant fils de treball
INFO: [fil=bibliotech-importador id=21] pas 1
INFO: [fil=bibliotech-avisos id=22] pas 1
INFO: [fil=bibliotech-avisos id=22] pas 2
INFO: [fil=bibliotech-importador id=21] pas 2
INFO: [fil=bibliotech-importador id=21] pas 3
INFO: [fil=bibliotech-avisos id=22] pas 3
INFO: [fil=main id=1] tots els fils han acabat

Tres coses que ensenya aquesta sortida:

  1. Les línies estan intercalades i l'ordre entre fils canvia a cada execució. Al pas 2 el fil d'avisos es va avançar a l'importador; en una altra execució serà al revés. No hi ha cap ordre garantit entre fils diferents.
  2. Dins d'un mateix fil, l'ordre sí que està garantit: pas 1 sempre precedeix pas 2 en el mateix fil. Aquesta és la primera intuïció del model de memòria: dins d'un fil, tot passa en l'ordre del programa.
  3. java.util.logging és segur per a diversos fils: els Handler sincronitzen la publicació, per això les línies no surten entremesclades caràcter a caràcter. És una altra raó per fer servir el logger de 06-07 i no System.out.println a partir d'ara.

Conclusió

Ja tens el model mental. Encara no has escrit concurrència útil, però has construït les tres idees sense les quals la resta del mòdul seria memorització.

La primera: un fil és un flux d'execució dins d'un procés, molt més barat de crear i de commutar que un procés, i amb una diferència decisiva: comparteix tota la memòria del monticle i els estàtics amb els altres fils, i només té privades la seva pila, les seves variables locals i el seu comptador de programa. Aquest repartiment —compartit a dalt, privat a baix— explica per què una variable local no pateix mai una condició de cursa i per què un objecte assolible des de dos fils sempre en pot patir una. La pregunta correcta no és mai "és aquest camp perillós?", sinó "pot aquest objecte ser assolit per dos fils alhora?".

La segona: concurrència i paral·lelisme no són el mateix. La concurrència és una propietat del disseny —diverses tasques en curs alhora, encara que s'intercalin en un sol nucli— i serveix perquè el menú de BiblioTech continuï viu durant una importació. El paral·lelisme és una propietat de l'execució —diverses tasques avançant en el mateix instant— i és l'única cosa que fa que un càlcul acabi abans. I la conseqüència pràctica: el teu codi s'executarà en màquines amb molts més nuclis que la teva, així que ha de ser correcte per a qualsevol entrellaçat, no només per al que veus.

La tercera: els fils s'introdueixen per tres motius i només tres —aprofitar nuclis, no bloquejar la interfície, solapar espera d'E/S—, i costen diners: al voltant d'1 MB de pila i desenes de microsegons per fil, més el canvi de context. D'aquí que el nombre adequat de fils depengui de si la tasca està limitada per CPU (≈ nuclis) o limitada per E/S (molts més, segons el ratio espera/càlcul), i que "un fil per tasca" no escali, que és la raó de ser dels pools de 08-05.

Saps a més distingir el fil main del fil actual, consultar Thread.currentThread().getName() com a primera eina de diagnòstic, i la regla de l'apagada: la JVM acaba quan no queda cap fil no dimoni, i els dimonis s'avorten sense executar el seu finally —per això no se'ls confia mai feina que no es pugui perdre—.

I coneixes els tres modes de fallada que defineixen la resta del mòdul: la condició de cursa que dona resultats incorrectes i intermitents, l'interbloqueig que ho atura tot en silenci i sense excepció, i la inanició que deixa un fil esperant per sempre. Amb l'advertiment que els fa especialment traïdors: són heisenbugs, i afegir una traça per veure'ls els pot fer desaparèixer.

Sobretot, tens el comptador trencat. Dos fils, un valor++, dos milions d'increments i un resultat que unes vegades és 1.362.544, altres 1.734.022 i de tant en tant —el més perillós de tot— exactament 2.000.000. Aquest programa de vint línies conté el mòdul sencer en miniatura: una fallada real, reproduïble, invisible en proves petites i massiva sota càrrega.

A la propera lliçó, Creació de Fils, deixes de contemplar el problema i comences a construir. Veuràs les quatre formes de crear un fil i per què Runnable guanya a estendre Thread; l'error clàssic de cridar run() en lloc de start(), amb la demostració que executa al fil equivocat; com esperar amb join(), com posar nom als fils perquè els logs serveixin d'alguna cosa, i per què les prioritats gairebé mai no fan el que et penses; el protocol complet d'interrupcióinterrupt(), InterruptedException, i per què empassar-se aquesta excepció és un dels pitjors errors que es poden cometre en Java—; i què passa quan un fil llança una excepció que ningú no captura. En acabar, la importació del catàleg de BiblioTech s'executarà al seu propi fil, amb nom propi, i es podrà cancel·lar.

Curs de Programació en Java

Mòdul 1: Introducció a Java

Mòdul 2: Flux de control

Mòdul 3: Programació orientada a objectes

Mòdul 4: Programació orientada a objectes avançada

Mòdul 5: Estructures de dades i col·leccions

Mòdul 6: Gestió d'excepcions

Mòdul 7: Entrada/sortida de fitxers

Mòdul 8: Multifil i concurrència

Mòdul 9: Xarxes

Mòdul 10: Temes avançats

Mòdul 11: Frameworks i llibreries de Java

Mòdul 12: Construcció d'aplicacions del món real

© Copyright 2026. Tots els drets reservats