La semaine dernière, en TP :
println
re-codée dans la classe fournie et un autre thread peut commencer une écriture au milieu de celle d'un autre thread.
System.out.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). System.out.println
, il ne lâche pas le verrou sur System.out
. Aucun autre thread ne peut entrer dans l'appel à System.out.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é-schedulé" n'importe quand, même au milieu de son exécution.
Rappel : lorsque plusieurs threads coexistent, 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...
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(); System.out.println(counter.value); } }
Ce code présente une data race : deux threads accèdent à une même zone mémoire (ici, le champs value
de l'objet counter
).
Exécutez le code sur vos machines !
Pour comprendre le comportement, il faut examiner deux choses : le byte code et 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 1ère putfield Counter.value // mettre ce résultat dans le champs value
... lorsque ce code est exécuté avec deux threads (pour l'instant, c'est le thread rouge qui est schédulé) :
... lorsque ce code est exécuté avec deux threads (c'est encore le thread rouge qui est schédulé) :
... lorsque ce code est exécuté avec deux threads (thread bleu prend la main) :
... lorsque ce code est exécuté avec deux threads (encore le thread bleu) :
... lorsque ce code est exécuté avec deux threads (toujours le thread bleu) :
... lorsque ce code est exécuté avec deux threads (le thread rouge repart) :
... lorsque ce code est exécuté avec deux threads (le thread rouge finit son tour de boucle) :
this.value++n'est probablement PAS atomique.
Attention, le scheduler n'est pas le seul 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; } System.out.println("Done"); }
Comme a
n'est jamais utilisé, ce code
peut être réécrit en :
public void run(){ System.out.println("Done"); }
Imaginons qu'un thread soit capable de faire la lecture-incrémentation-écriture sans être dé-schedulé... est-ce qu'il n'y a plus de problème ?
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