Cicle: Desenvolupament d’Aplicacions Multiplataforma (DAM)
Mòdul: 0490. Programació de serveis i processos
Un procés és un programa en execució amb el seu propi espai de memòria, els seus propis descriptors de fitxers i els seus propis recursos del sistema operatiu. Un fil (en anglès thread) és una línia d’execució que viu dins d’un procés. Un mateix procés pot tenir un o més fils, i tots els fils d’un procés comparteixen el mateix espai de memòria (variables estàtiques, heap, fitxers oberts…).
Per aquest motiu als fils se’ls anomena sovint processos lleugers: crear-los i canviar de context entre ells és molt més barat que fer-ho amb processos, perquè el sistema operatiu no ha de duplicar tot l’espai de memòria.
Procés
├── Fil principal (main)
├── Fil 1
└── Fil 2Els fils són útils sempre que una part de la feina d’un programa pugui (o hagi de) executar-se sense bloquejar la resta. Alguns exemples típics:
Com que tots els fils d’un mateix procés comparteixen memòria, poden accedir als mateixos objectes. Això és un avantatge (permet comunicar-se fàcilment) però també la font del problema principal de la programació multifil: si dos fils llegeixen i escriuen el mateix recurs sense cap control, el resultat final pot dependre de l’ordre exacte en què el planificador del sistema operatiu decideixi executar-los, i aquest ordre no és determinista. Aquest fenomen es coneix com a condició de carrera (race condition) i el tractarem a Compartició d’informació entre fils i condicions de carrera.
El planificador de fils del sistema operatiu decideix quin fil s’executa en cada moment i durant quant de temps. Un programa multifil ben dissenyat no ha de fer cap suposició sobre aquest ordre.
En Java, tot el suport per a fils viu al paquet java.lang, per la qual cosa no cal cap import
addicional per a les classes bàsiques (Thread, Runnable). Hi ha dues formes de dotar una
classe de comportament de fil:
| Tècnica | Quan usar-la |
|---|---|
Estendre Thread |
Quan la classe no necessita heretar de cap altra |
Implementar Runnable |
Quan la classe ja hereta d’una altra classe, o quan volem separar la tasca del fil que l’executa |
Threadpublic class ComptadorFil extends Thread {
private final String etiqueta;
private final int repeticions;
public ComptadorFil(String etiqueta, int repeticions) {
this.etiqueta = etiqueta;
this.repeticions = repeticions;
}
@Override
public void run() {
for (int i = 1; i <= repeticions; i++) {
System.out.println(etiqueta + " -> iteració " + i);
}
}
}Per crear-lo i arrencar-lo:
ComptadorFil f = new ComptadorFil("Sensor-A", 5);
f.start(); // NO cridis mai run() directament!start() és el mètode que demana a la JVM que reservi un
fil natiu del sistema operatiu i, quan estigui llest, cridi el nostre run(). Si, en canvi, crides f.run()
directament, el codi s’executarà de manera síncrona
dins del fil que l’ha cridat, sense crear cap fil nou.
RunnableRunnable és una interfície funcional amb un únic mètode
abstracte: void run(). És la tècnica recomanada quan la
nostra classe ja estén d’una altra (Java no permet herència múltiple) o
quan volem que la tasca es pugui reutilitzar amb diferents mecanismes
d’execució (fils clàssics, ExecutorService, etc.):
public class TascaSensor implements Runnable {
private final String nom;
public TascaSensor(String nom) {
this.nom = nom;
}
@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println(nom + " llegint mostra " + i);
}
}
}Per executar-lo cal embolicar-lo dins d’un Thread:
Thread t = new Thread(new TascaSensor("Sensor-B"));
t.start();Des de Java 8 també es pot fer servir una expressió lambda, ja que Runnable és una interfície funcional:
Thread t = new Thread(() -> {
for (int i = 1; i <= 5; i++) {
System.out.println("Fil lambda, iteració " + i);
}
});
t.start();Thread
vs. Runnable: quina triem?En la pràctica professional, Runnable és la
millor opció per defecte:
ExecutorService
(vegeu punt 8) sense tocar-ne el codi.Thread només té sentit quan volem
sobreescriure algun altre comportament de la classe Thread a més del run().
Un error freqüent és crear un objecte Thread/Runnable i intentar arrencar-lo dues
vegades:
Thread t = new Thread(() -> System.out.println("Hola"));
t.start();
t.start(); // llança IllegalThreadStateException!Un objecte Thread només es pot arrencar una
vegada. Si necessitem repetir la tasca, cal crear una nova
instància.
Java modela l’estat d’un fil amb l’enumeració Thread.State, que es pot consultar amb getState(). Els estats possibles són:
| Estat | Significat |
|---|---|
NEW |
El fil s’ha creat (new Thread(...)) però encara no s’ha
cridat start() |
RUNNABLE |
El fil és candidat a executar-se; pot estar realment corrent a la CPU o esperant que el planificador li assigni temps |
BLOCKED |
El fil espera a poder entrar en una secció synchronized
que un altre fil té bloquejada |
WAITING |
El fil espera indefinidament que un altre fil el notifiqui
(wait() sense timeout, join() sense
timeout) |
TIMED_WAITING |
Igual que WAITING però amb un temps màxim
(sleep(ms), wait(ms), join(ms)) |
TERMINATED |
El mètode run() ha acabat, sigui amb normalitat o per
una excepció no capturada |
public class DemoEstats {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println("Abans de start(): " + t.getState()); // NEW
t.start();
Thread.sleep(200);
System.out.println("Mentre dorm: " + t.getState()); // TIMED_WAITING
t.join();
System.out.println("Després de join(): " + t.getState()); // TERMINATED
}
}El llibre de text clàssic sol descriure només quatre estats
(new, runnable, blocked, dead), un
model simplificat previ a Java 5. Des de Java 5, Thread.State distingeix BLOCKED, WAITING i TIMED_WAITING, cosa que permet
diagnosticar amb més precisió per què un fil no avança (per exemple, amb
una eina com jstack o el thread dump de
l’IDE).
Els mètodes stop(), suspend() i resume() de la classe Thread estan
obsolets (deprecated) des de fa moltíssimes versions de
Java i no s’han d’utilitzar mai: stop()
pot deixar objectes compartits a mig actualitzar perquè no allibera els
blocatges que el fil tenia adquirits, i suspend()/resume() poden provocar
interbloquejos.
La forma correcta i segura d’aturar un fil és mitjançant una variable de control o la interrupció cooperativa:
Opció A — variable booleana pròpia:
public class FilAturable extends Thread {
private volatile boolean actiu = true;
public void aturar() {
actiu = false;
}
@Override
public void run() {
while (actiu) {
// feina del fil
}
System.out.println("Fil aturat de forma neta");
}
}Cal marcar la variable com a volatile perquè els canvis
fets des d’un altre fil siguin visibles immediatament (sense volatile, el compilador o la CPU podrien mantenir una còpia
en cache del valor i el bucle no es tallaria mai).
Opció B — interrupció estàndard de Java:
public class FilInterrompible extends Thread {
@Override
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
// feina del fil
Thread.sleep(50);
}
} catch (InterruptedException e) {
System.out.println("Interromput mentre dormia");
}
System.out.println("Fil finalitzat");
}
}FilInterrompible f = new FilInterrompible();
f.start();
Thread.sleep(500);
f.interrupt(); // marca el flag d'interrupció (i llança InterruptedException si estava en sleep/wait/join)interrupt() no atura el fil per la
força: només activa un indicador intern (i, si el fil estava
bloquejat en sleep(), wait() o join(), provoca que aquestes crides llencin InterruptedException). És responsabilitat del mateix codi
del run() comprovar aquest indicador i decidir acabar de
manera ordenada.
| Mètode | Què fa |
|---|---|
start() |
Arrenca el fil; la JVM crida internament run() |
run() |
Conté el codi que executarà el fil |
sleep(long ms) (estàtic) |
Posa a dormir el fil actual el temps indicat |
join() |
El fil que crida join() sobre un altre s’espera fins
que aquest acabi |
join(long ms) |
Igual, però amb un temps màxim d’espera |
interrupt() |
Envia un senyal d’interrupció al fil |
isInterrupted() |
Retorna si el fil ha estat interromput |
isAlive() |
Retorna si el fil ha arrencat i encara no ha acabat |
getState() |
Retorna l’estat actual (Thread.State) |
setName(String) / getName() |
Assigna o consulta el nom del fil |
setDaemon(boolean) |
Marca el fil com a daemon (la JVM no espera els fils daemon per acabar el programa) |
currentThread() (estàtic) |
Retorna una referència al fil que executa el codi actual |
join(): esperar
que un fil acabijoin() és imprescindible quan el fil principal
(main) necessita esperar que un o més fils
secundaris hagin acabat abans de continuar (per exemple, per combinar-ne
els resultats):
public class TascaSuma extends Thread {
private final int desde, fins;
private long resultat;
public TascaSuma(int desde, int fins) {
this.desde = desde;
this.fins = fins;
}
@Override
public void run() {
long suma = 0;
for (int i = desde; i <= fins; i++) suma += i;
resultat = suma;
}
public long getResultat() { return resultat; }
}
public class SumaParalela {
public static void main(String[] args) throws InterruptedException {
TascaSuma part1 = new TascaSuma(1, 500_000);
TascaSuma part2 = new TascaSuma(500_001, 1_000_000);
part1.start();
part2.start();
part1.join(); // esperem que acabin tots dos
part2.join();
System.out.println("Suma total: " + (part1.getResultat() + part2.getResultat()));
}
}Sense els join(), el main() podria arribar
a la línia del System.out.println abans
que els fils haguessin acabat de calcular, i el resultat imprès seria
incorrecte (probablement 0).
Un fil daemon és un fil de “servei” que la JVM no té en compte per decidir si el programa ha d’acabar: quan tots els fils normals (no daemon) acaben, la JVM finalitza encara que hi hagi fils daemon en marxa. S’utilitzen típicament per a tasques de fons com un recol·lector d’estadístiques o un keep-alive:
Thread rellotgeFons = new Thread(() -> {
while (true) {
System.out.println("Tic...");
try { Thread.sleep(1000); } catch (InterruptedException e) { return; }
}
});
rellotgeFons.setDaemon(true); // s'ha de cridar ABANS de start()
rellotgeFons.start();Quan diversos fils accedeixen al mateix objecte i almenys un d’ells el modifica, poden aparèixer resultats incoherents. Vegem-ho amb un comptador compartit:
public class Comptador {
private int valor = 0;
public void incrementa() {
valor++; // en realitat són 3 operacions: llegir, sumar 1, escriure
}
public int getValor() { return valor; }
}public class ProvaCarrera {
public static void main(String[] args) throws InterruptedException {
Comptador c = new Comptador();
Runnable tasca = () -> {
for (int i = 0; i < 100_000; i++) c.incrementa();
};
Thread t1 = new Thread(tasca);
Thread t2 = new Thread(tasca);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Valor esperat: 200000");
System.out.println("Valor obtingut: " + c.getValor());
}
}Si executem aquest codi diverses vegades, el valor final gairebé mai
serà 200000, sinó un valor menor i
variable. La raó és que valor++ no és una operació
atòmica: internament és “llegeix valor”, “suma 1” i “desa
el resultat”. Si els dos fils fan la lectura abans que l’altre hagi
desat el seu increment, es “perd” una actualització. Això és una
condició de carrera.
synchronizedJava proporciona el mecanisme de monitor (o
lock intrínsec) mitjançant la paraula clau synchronized. Cada objecte Java té associat un monitor;
quan un fil entra en un bloc o mètode synchronized sobre un
objecte, bloqueja aquest monitor, i cap altre fil pot
entrar en cap altre bloc synchronized que faci servir el
mateix objecte fins que el primer en surti.
Sincronitzant el mètode sencer:
public class ComptadorSegur {
private int valor = 0;
public synchronized void incrementa() {
valor++;
}
public synchronized int getValor() {
return valor;
}
}Sincronitzant només el bloc necessari (útil quan el mètode fa altres coses que no cal protegir):
public class ComptadorSegur {
private int valor = 0;
private final Object bloqueig = new Object();
public void incrementa() {
synchronized (bloqueig) {
valor++;
}
}
public int getValor() {
synchronized (bloqueig) {
return valor;
}
}
}La sincronització té un cost de rendiment (els fils es poden arribar a bloquejar entre ells) i, si s’abusa d’objectes de sincronització diferents que es necessiten mútuament, pot provocar interbloquejos (deadlocks). La recomanació general és: sincronitza el mínim de codi possible i, sempre que puguis, prefereix sincronitzar mètodes sencers curts abans que blocs dispersos.
Imaginem un pàrquing amb un nombre limitat de places, i diversos fils que representen cotxes intentant reservar-ne una alhora. Sense sincronització, dos cotxes podrien comprovar que encara queda una plaça abans que l’altre l’hagi ocupat, i acabar tots dos “aparcant” a la mateixa plaça inexistent:
public class Parquing {
private int placesLliures;
public Parquing(int totalPlaces) {
this.placesLliures = totalPlaces;
}
public synchronized boolean reservarPlaça(String matricula) {
if (placesLliures > 0) {
System.out.println(matricula + " veu " + placesLliures + " places lliures");
try { Thread.sleep(50); } catch (InterruptedException e) {}
placesLliures--;
System.out.println(matricula + " aparca -> queden " + placesLliures);
return true;
}
System.out.println(matricula + " no troba plaça lliure");
return false;
}
}El Thread.sleep() que apareix dins reservarPlaça() és deliberat: simula el temps que tarda un
cotxe a maniobrar, per fer visible la finestra de temps en què podria
aparèixer una condició de carrera si el mètode no fos synchronized. En codi real, però, mai no s’ha de cridar sleep() ni cap operació llarga (I/O, xarxa, càlcul
intensiu) dins d’un bloc o mètode synchronized: el fil dorm
però manté el monitor, de manera que tots els altres
fils que necessitin entrar a qualsevol mètode synchronized
del mateix objecte queden bloquejats durant tot el temps d’espera. La
regla pràctica és: dins d’un bloc synchronized només hi ha
d’haver la mínima feina imprescindible per mantenir
l’estat consistent (llegir, comparar i modificar les variables
compartides); tot el que sigui operació lenta ha d’anar fora.
Marcant reservarPlaça() com a synchronized,
quan un fil (cotxe) hi entra, els altres queden esperant fins que aquest
surt del mètode, i mai no es poden arribar a ocupar més places de les
que realment existeixen.
wait(), notify() i notifyAll()synchronized evita que dos fils entrin
alhora en una secció crítica, però de vegades necessitem alguna
cosa més: que un fil s’esperi fins que es doni una
determinada condició, i que un altre fil l’avisi quan
aquesta condició es compleixi. Per això Java ofereix tres mètodes,
definits a la classe Object (no a Thread):
| Mètode | Efecte |
|---|---|
wait() |
El fil que el crida queda suspès fins que un altre fil cridi notify()/notifyAll() sobre el mateix
objecte |
notify() |
Desperta un dels fils que esperen sobre l’objecte (l’elecció no està definida) |
notifyAll() |
Desperta tots els fils que esperen sobre l’objecte |
wait(), notify() i notifyAll()
només es poden cridar dins d’un bloc o mètode synchronized sobre el mateix objecte; en cas
contrari es llança IllegalMonitorStateException.
Un escenari clàssic de sincronització: un fil productor genera dades i les diposita en un magatzem compartit (una cua, un buffer…) i un fil consumidor les recull i les processa. Cal evitar dues coses:
Vegem un magatzem amb capacitat per a diversos elements alhora (no només un), cosa que obliga a controlar dues condicions diferents: que no estigui ple (per al productor) i que no estigui buit (per al consumidor):
import java.util.LinkedList;
public class Magatzem {
private final LinkedList<String> elements = new LinkedList<>();
private final int capacitat;
public Magatzem(int capacitat) {
this.capacitat = capacitat;
}
public synchronized void afegir(String element) throws InterruptedException {
while (elements.size() == capacitat) { // ple: el productor espera
wait();
}
elements.addLast(element);
notifyAll(); // avisa que hi ha un element nou
}
public synchronized String treure() throws InterruptedException {
while (elements.isEmpty()) { // buit: el consumidor espera
wait();
}
String element = elements.removeFirst();
notifyAll(); // avisa que ha quedat un lloc lliure
return element;
}
}public class Productor extends Thread {
private final Magatzem magatzem;
public Productor(Magatzem m) { this.magatzem = m; }
@Override
public void run() {
for (int i = 1; i <= 8; i++) {
try {
magatzem.afegir("peça-" + i);
System.out.println("Productor -> afegeix peça-" + i);
Thread.sleep(80);
} catch (InterruptedException e) { return; }
}
try {
magatzem.afegir(null); // Sentinella: indica que no hi ha més dades
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
public class Consumidor extends Thread {
private final Magatzem magatzem;
public Consumidor(Magatzem m) { this.magatzem = m; }
@Override
public void run() {
try {
while (true) {
String element = magatzem.treure();
if (element == null) break; // sentinella rebut: acabem
System.out.println("Consumidor <- retira " + element);
Thread.sleep(150);
}
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}Aquest exemple assumeix un sol productor i un sol consumidor amb el
mateix nombre d’elements. En un cas real amb múltiples productors o un
nombre d’elements indeterminat cal un mecanisme explícit de
finalització: el més habitual és que el productor afegeixi un element
sentinella (un valor especial, sovint null o una constant FI) que el consumidor reconeix com a senyal per deixar de
llegir. Sense aquest mecanisme, el consumidor quedaria bloquejat per
sempre en wait() un cop el magatzem s’hagués buidat
definitivament.
public class ProvaMagatzem {
public static void main(String[] args) {
Magatzem m = new Magatzem(3); // capacitat per a 3 elements
new Productor(m).start();
new Consumidor(m).start();
}
}Com que el magatzem admet fins a 3 elements pendents, el productor pot avançar-se al consumidor sense bloquejar-se immediatament (a diferència d’un magatzem d’una sola posició), però mai no podrà afegir-ne un quart mentre n’hi hagi 3 pendents, ni el consumidor podrà treure’n cap si el magatzem és buit.
Fixa’t que la condició d’espera sempre es comprova dins d’un
bucle while, mai amb un simple if. Això és una norma d’or de la programació amb wait()/notify(): quan un fil es desperta, ha
de tornar a comprovar la condició, perquè notifyAll()
desperta tots els fils en espera i no tots necessàriament
trobaran la condició que buscaven certa (algú altre podria haver-la
canviat abans que els toqués torn).
Cada fil de Java té una prioritat, un valor enter
entre Thread.MIN_PRIORITY (1) i Thread.MAX_PRIORITY (10). Per defecte, un fil hereta la
prioritat del fil que l’ha creat, normalment Thread.NORM_PRIORITY (5).
Thread t = new Thread(tasca);
t.setPriority(Thread.MAX_PRIORITY);
System.out.println("Prioritat actual: " + t.getPriority());La prioritat és només una pista que es dona al planificador del sistema operatiu: en igualtat de condicions, un fil amb més prioritat tindrà preferència per rebre temps de CPU, però el comportament exacte depèn de la plataforma (Windows, Linux…) i de la implementació concreta de la JVM. No hi ha cap garantia que un fil de prioritat màxima s’executi sempre abans que un de prioritat mínima, i en sistemes Linux sovint l’efecte és pràcticament imperceptible.
A la pràctica professional gairebé mai es toquen les prioritats a mà.
Si un disseny depèn de prioritats concretes per funcionar correctament,
sol ser un símptoma que caldria repensar la sincronització (per exemple,
amb estructures d’java.util.concurrent, com veurem al punt
8).
Thread: una mirada a java.util.concurrentTot el que hem vist fins ara (Thread, synchronized, wait()/notify())
forma part de l’API “clàssica” de Java, disponible des de les primeres
versions del llenguatge. Des de Java 5, el paquet
java.util.concurrent ofereix eines d’alt
nivell que resolen els mateixos problemes amb menys codi i menys risc
d’errors. No formen part del contingut bàsic d’aquesta unitat, però és
important que en coneguis l’existència:
ExecutorService: gestiona un grup
(“pool”) de fils reutilitzables, de manera que no cal crear i destruir
fils manualment per a cada tasca.
ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(() -> System.out.println("Tasca executada pel pool"));
executor.shutdown();ReentrantLock (paquet java.util.concurrent.locks): una alternativa a synchronized amb més flexibilitat (per exemple, permet
intentar adquirir el bloqueig amb un temps màxim d’espera).
Col·leccions concurrents, com ConcurrentHashMap o BlockingQueue, dissenyades
des de zero per ser accedides de manera segura per diversos fils sense
haver-nos de preocupar de sincronitzar-les manualment.
CountDownLatch i
CyclicBarrier: mecanismes per coordinar
que un grup de fils arribin a un mateix punt abans de continuar, sense
haver d’escriure wait()/notify() a
mà.
Un BlockingQueue (per exemple LinkedBlockingQueue sense límit de capacitat, o ArrayBlockingQueue si es vol un buffer de mida fixa com el Magatzem del punt 6) resol el problema productor-consumidor
del punt
6 amb molt menys codi: els mètodes put() i take() ja incorporen tota la lògica d’espera i notificació
que hem programat a mà a la classe Magatzem.
Els errors en programes multifil tenen una característica especialment incòmoda: no sempre es reprodueixen igual. Un programa amb una condició de carrera pot funcionar bé desenes de vegades i fallar només en condicions de càrrega concretes. Algunes recomanacions pràctiques:
setName();
els missatges de traça i els thread dumps són molt més
llegibles.System.out.println en
desenvolupament, un logger real en producció) indicant quin fil
executa cada acció.jstack <pid> des de línia
d’ordres, o des del mateix IDE), que mostra l’estat i la pila de crides
de cada fil.synchronized. És
l’error de disseny multifil més freqüent: sincronitzar sobre objectes
diferents pensant erròniament que protegeixen el mateix recurs.