La semaine dernière, en TP :
println recodée dans la classe fournie et un thread peut commencer une écriture au milieu de celle d'un autre thread.
IO.println grâce à un mécanisme de synchronisation.
public void println(String x) {
...
synchronized (this) {
print(x);
newLine();
}
}
public void println(String x) {
...
synchronized (this) {
print(x);
newLine();
}
}
this (c'est-à-dire System.out). Elle le lâche uniquement après avoir affiché toute la chaîne (on expliquera ce mécanisme dans le cours suivant).IO.println, il ne lâche pas le verrou sur System.out. Aucun autre thread ne peut entrer dans l'appel à IO.println tant que le thread qui détient le verrou n'a pas terminé.
L'exemple précédent permet de constater qu'une instruction écrite en Java n'est pas atomique : le thread dans lequel elle s’exécute peut être "dé-schédulé" n'importe quand, même au milieu de son exécution.
Rappel : quand on a plusieurs threads, ils partagent la même mémoire (le tas).
L'utilisation d'opérations non atomiques, combinées au partage de la mémoire, entraîne un certain nombre de problèmes...
... liés aux accès (lecture ou écriture) entrelacés à la mémoire partagée ;
... liés à l'architecture du processeur (multi-cœurs, niveaux de cache, ...) ;
... mais aussi liés aux optimisations du code (par le JIT, en Java).
public class Counter {
private int value;
public void addALot() {
for (int i = 0; i < 10_000; i++) {
this.value++;
}
}
public static void main(String[] args) throws InterruptedException {
var counter = new Counter();
var thread1 = Thread.ofPlatform().start(counter::addALot);
var thread2 = Thread.ofPlatform().start(counter::addALot);
thread1.join();
thread2.join();
IO.println(counter.value);
}
}
Ce code présente une data race : deux threads accèdent à une même zone mémoire (ici, le champ value de l'objet counter).
Exécutez le code sur vos machines !
Pour comprendre le comportement, il faut comprendre deux choses :
Que fait réellement ce code ? On va donc examiner le byte code.
Et comment est gérée la mémoire ?
L'instruction Java extraite du code précédent :
this.value++;
peut se traduire en byte code ainsi :
aload 0 // mettre this sur la pile (pour l'écriture)
aload 0 // mettre this sur la pile (pour la lecture)
getfield Counter.value // stocker la valeur à la place de this
iconst 1 // stocker la valeur 1 sur la pile
iadd // additionner les deux valeurs au sommet de la pile
// et mettre le résultat à la place de la 1re
putfield Counter.value // mettre ce résultat dans le champ value
... quand ce code est exécuté par un seul thread :
... quand ce code est exécuté par un seul thread :
... quand ce code est exécuté par un seul thread :
... quand ce code est exécuté par un seul thread :
... quand ce code est exécuté par un seul thread :
... quand ce code est exécuté par un seul thread :
... quand ce code est exécuté par un seul thread :
... quand ce code est exécuté par deux threads :
Pour l'instant, c'est le thread rouge qui est schédulé
... quand ce code est exécuté par deux threads :
C'est encore le thread rouge qui est schédulé.
... quand ce code est exécuté par deux threads :
Le thread bleu prend la main.
... quand ce code est exécuté par deux threads :
C'est encore le thread bleu qui s'exécute
... quand ce code est exécuté par deux threads :
C'est toujours le thread bleu qui s'exécute
... quand ce code est exécuté par deux threads :
Le thread rouge repart.
... quand ce code est exécuté par deux threads :
Le thread rouge finit son tour de boucle.
On ne contrôle pas le scheduler : les threads peuvent être schédulés et dé-schédulés n'importe quand.
this.value++
n'est probablement PAS atomique.
Attention, même avec plusieurs CPUs, le tas est partagé...
... mais (peut-être) pas le cache.
Pour comprendre le problème, imaginons qu'un thread soit capable de faire la lecture-incrémentation-écriture sans être dé-schédulé...
On ne devrait plus être embêté par le scheduler...
Le moment où l'écriture est effective dans la RAM dépend de l'algorithme de mise à jour du cache...
... on n'a aucune garantie sur le moment où l'écriture en RAM est effectuée
Le thread bleu se termine
On a perdu une des deux incrémentations
On ne contrôle pas l'état de la RAM.
Et inversement.
Attention, il y a un troisième facteur de "perturbation"...
Le code que l'on écrit dans un langage de haut niveau (C, Java, Python, ...) est rarement celui qui est exécuté. Le code peut être optimisé (par un compilateur, par le JIT, ...), c'est-à-dire transformé pour qu'il soit plus efficace, tout en ayant le même effet.
Par exemple :
public void addALot() {
for (var i = 0; i < 10_000; i++) {
this.value++;
}
}
... peut devenir :
public void addALot() {
var localValue = this.value;
for (var i = 0; i < 10_000; i++) {
localValue++;
}
this.value = localValue;
}
La lecture-écriture en RAM n'est faite qu'une seule fois !
Plus généralement, l'optimisation peut consister à réécrire votre code en n'importe quel code équivalent.
var a = 1;
var b = a + 2;
var c = 3;
var d = a * a;
Ici, en faisant attention aux dépendances, ce code peut, par exemple, être réécrit en :
var a = 1;
var c = 3;
var d = a * a;
var b = a + 2;
public void run() {
var a = this.value;
for (var i = 0; i < 1000; i++) {
a = a + 1;
}
IO.println("Done");
}
Comme a n'est jamais utilisé, ce code
peut être réécrit en :
public void run(){
IO.println("Done");
}
Par défaut, une instruction ou un morceau de code ne s'exécute pas de façon atomique.
Si on a plusieurs threads, avec au moins une lecture/écriture sur le tas, on peut voir les effets de bord de cette non-atomicité... principalement parce qu'on ne contrôle ni le scheduler, ni l'état de la mémoire...
Enfin, il ne faut pas oublier que le code exécuté peut être très différent du code écrit en Java. La seule contrainte est que le résultat observable soit le même dans une exécution mono-thread.