Rappels :
synchronized,
ReentrantLock.
Problèmes : possibilité d'inter-blocage et problèmes d'efficacité. En particulier, prendre/relâcher un verrou peut être plus long que le temps d'exécution du code à l'intérieur de la section critique.
L'objectif :
Solution :
volatilevolatile rend visible toutes
les écritures précédentes.
volatile oblige la
première lecture suivante a être rechargées à partir de la
RAM.
Ce sont des effets globaux (sur tous les champs, même non volatile),
qui empêchent aussi les réorganisations par le JIT / CPUs.
On parle de
barrière en lecture/écriture.
volatile
class A {
private int value;
private volatile boolean done;
public void init(int value) {
this.value = value;
this.done = true; // écriture volatile, donc value est écrite en RAM
}
public static void main(String[] args) {
A a = new A();
Thread.ofPlatform().start(() -> {
a.init(9);
});
if (a.done) { // lecture volatile, depuis la RAM (invalidation de cache)
System.out.println(a.value); // doit être rechargée de la RAM donc 9
}
}
}
public class A {
private volatile int value;
private static A a;
public A(int value) {
this.value = value; // écriture volatile
}
public static void main(String[] args) {
Thread.ofPlatform().start(() -> {
if (a != null) {
System.out.println(a.value); // lecture volatile
}
// affiche 7 ou rien
});
a = new A(7);
}
}
Il n'y a pas de problème de publication avec les volatile.
Les instructions des langages de haut niveau comme C ou Java ne sont pas atomiques.
Néanmoins, les processeurs actuels fournissent des instructions assembleur atomiques.
Par exemple, pour intel , ladd rax 1, pour locked add, correspond à une incrémentation atomique (i++).
En Java,
les opérations atomiques sont vues comme des méthodes que l'on peut exécuter sur un champ volatile.
Il y a deux façons de faire :
java.util.concurrent.atomic.AtomicInteger (par exemple). Elle encapsule un champ volatile (ici un int) et fournit des méthodes atomiques.java.lang.invoke.VarHandle. Elle référence un champ volatile d'une classe externe et fournit des méthodes atomiques plus bas niveau (vois cours suivant).Voici l'exemple d'un compteur thread-safe utilisant un bloc synchronized (on peut obtenir la même chose avec un ReentrantLock) :
public class ThreadSafeCounter {
private final Object lock = new Object();
private int counter;
public int nextValue() { // incrémente le compteur et renvoie la valeur précédente
synchronized (lock) {
return counter++;
}
}
public int previousValue() {
synchronized (lock) {
return counter--;
}
}
}
Le code est thread-safe mais pas forcément très rapide.
Un AtomicInteger contient un champ volatile de type int :
public class ThreadSafeCounter {
private final AtomicInteger counter = new AtomicInteger();
public int nextValue() {
return counter.getAndIncrement();
}
public int previousValue() {
return counter.getAndDecrement();
}
}
volatile.getAndIncrement() et getAndDecrement() sont vues comme des instructions atomiques à l’exécution.Atomic*Atomic* est une famille de classes qui se déclinent pour chaque type :
boolean ⟶ AtomicBooleanint (ou float) ⟶ AtomicIntegerlong (ou double) ⟶ AtomicLongT ⟶ AtomicReference<T> AtomicIntegerArrayT ⟶ AtomicReferenceArray<T> Pour les tableaux, les classes fournissent des opérations atomiques sur les cases (individuellement).
Avec des verrous, on a la garantie qu'une fois dans le bloc de synchronisation, on peut effectuer l'opération que l'on veut.
En lock-free, on réfléchit à l'inverse : on tente une opération en espérant que cela va fonctionner (c'est-à-dire qu'un autre thread ne va pas interférer), et sinon, on peut/doit ré-essayer.
compareAndSetAttention, toutes les opérations atomiques ne sont pas disponibles sur tous les processeurs... Mais il existe une primitive de base : compareAndSet (ou CAS) qui permet de modifier une variable uniquement si elle a la valeur que l'on souhaite, de façon atomique.
Les classes Atomic* possèdent une méthode d'instance compareAndSet qui prend en paramètre la valeur attendue et une nouvelle valeur et peuvent modifier leur champs volatile. Pour AtomicReference<V> :
public boolean compareAndSet(V expectedValue, V newValue)
Si la valeur du champ de type V contenu dans this est égale (au sens ==) à expectedValue, alors la valeur du champs devient newValue et la méthode renvoie true, sinon elle renvoie false.
La classe AtomicInteger offre une méthode compareAndSet :
public class ThreadSafeCounter {
private final AtomicInteger counter = new AtomicInteger();
public int nextValue() {
for(;;) {
var current = counter.get(); // lecture volatile
if (counter.compareAndSet(current, current + 1)) {
return current;
} // sinon, on ré-essaie
}
}
public int previousValue() {
...
}
}
Si le CAS réussi, c'est équivalent à une écriture volatile (c’est nécessaire pour avoir la barrière mémoire, l’atomicité ne suffit pas).
Attention, voici deux exemples d'utilisation incorrecte de compareAndSet !
public int nextValue() {
for(;;) {
var current = counter.get(); // lecture volatile
if (counter.compareAndSet(counter.get(), counter.get() + 1) { // non, non...
return counter.get(); // et non...
}
}
}
public int previousValue() {
var current = counter.get(); // lecture volatile
var check = counter.compareAndSet(current, current - 1) // et si ça échoue ?
return current;
}
Problème 1 : on fait plusieurs appels à get() entre lesquels n'importe quel thread peut être schédulé ou dé-schédulé...
Problème 2 : on ne ré-essaie pas...
Les Atomic* fournissent des méthodes pour faire des opérations atomiques (à base de CAS) plus sophistiquées
La méthode getAndUpdate(UnaryOperator) permet de faire un CAS dans une boucle en indiquant uniquement la fonction qui permet de mettre à jour la valeur :
public class ThreadSafeCounter {
private final AtomicInteger counter = new AtomicInteger();
public int nextValue() {
return counter.getAndUpdate(x -> x + 1);
}
}
volatile = barrière mémoire.
volatile + opération atomique (Atomic*) ;
compareAndSet;