Tècniques de disseny
Hi ha bàsicament quatre tècniques per assegurar-nos que no tindrem problemes accedint a variables en memòria compartida. Són aquestes, per ordre de preferència:
- Confinament. No compartiu objectes entre fils.
- Immutabilitat. Feu que les dades compartides siguin immutables. Tots els camps de la classe han de ser finals.
- Utilitzar 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 les dades 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).
A continuació veurem un exemple de race condition, el problema més representatiu de l'estat compartit entre fils. Dos fils intenten incrementar un comptador a un objecte compartit. El resultat final no és el que esperem:
public class RaceThread {
static class SharedObject {
int counter;
}
static class MyRunnable implements Runnable {
SharedObject so;
MyRunnable(SharedObject so) {
this.so = so;
}
void increment() {
so.counter ++;
}
@Override
public void run() {
for (int i=0; i<1_000_000; i++) {
increment();
}
}
}
public static void main(String[] args) throws InterruptedException {
SharedObject so = new SharedObject();
Thread t1 = new Thread(new MyRunnable(so));
Thread t2 = new Thread(new MyRunnable(so));
t1.start();
t2.start();
t1.join();
t2.join();
log("counter is " + so.counter);
}
}
En aquest exemple, el mètode increment()
no és thread-safe. El resultat és que el valor de counter
no és el que esperàvem. Això és degut a que el mètode increment()
no és atòmic. Això vol dir que no és una única operació, sinó que es descompon en diverses operacions més petites. És el que es diu una operació composta. El problema és que cada petita operació pot intercalar-se entre diversos fils i generar un problema.
En el nostre cas, el compilador descompon el mètode increment()
en tres operacions anomenades Read-Modify-Write:
- Llegir el valor de
counter
de memòria. - Incrementar el valor llegit.
- Escriure el valor incrementat a memòria.
Aquestes són algunes operacions compostes habituals:
- Read-Modify-Write (l'exemple)
- Check-Then-Act: per exemple, inicialitzar si cal (singleton)
- Put-If-Absent: afegir un element a una col·lecció si no existeix
Per a sincronitzar, necessitem definit el concepte de monitor (lock): un monitor és un objecte que només pot ser accedit per un únic fil al mateix temps. En el nostre problema, el monitor seria l'objecte so
. Cada fil ha d'adquirir el monitor abans d'accedir a la variable counter
. Això es fa amb la paraula clau synchronized.
Una primera solució seria no permetre l'accés directe al camp counter
de l'objecte so
. En aquest cas, el camp counter
seria private
i només es podria accedir a ell a través de mètodes. Aquests mètodes serien sincronitzats:
static class SharedObject {
private int counter;
synchronized void increment() {
counter ++;
}
synchronized int getCounter() {
return counter;
}
}
I canviar el mètode increment
Una segona solució seria fer que dos fils no poguessin accedir a la variable counter
al mateix temps. Això a Java es fa utilitzant un monitor (lock). El mètode increment()
quedaria així:
void increment() {
synchronized (so) {
so.counter ++;
}
}
Quan creem zones crítiques de codi amb els mecanismes de Java podem ordenar els esdeveniments que es produeixen. Les regles d'ordenació s'expliquen amb el concepte de "happen-before" (passa-abans). Resumint, es tracta de que si un esdeveniment A passa abans que un esdeveniment B, aleshores B no pot passar abans que A. Això ens permet assegurar-nos que els esdeveniments es produeixen en l'ordre que volem.
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ó.
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 i documentar-ho.