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 :
volatile
volatile
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
⟶ AtomicBoolean
int
(ou float
) ⟶ AtomicInteger
long
(ou double
) ⟶ AtomicLong
T
⟶ AtomicReference<T>
AtomicIntegerArray
T
⟶ 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.
compareAndSet
Attention, 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
;