Concurrence

Problèmes liés à la concurrence


Problème d'affichage

La semaine dernière, en TP :

exercice avec les deux versions de println

Ce que vous avez observé ... (1/2)

  • Les threads peuvent être dé-schedulés pendant l'appel à la fonction println re-codée dans la classe fournie et un autre thread peut commencer une écriture au milieu de celle d'un autre thread.
  • Cela ne peut jamais se produire avec la méthode System.out.println grâce à un mécanisme de synchronisation.
    	public void println(String x) {
    	    ...
            synchronized (this) {
                print(x);
                newLine();
            }
        }
    

Ce que vous avez observé ... (2/2)

	public void println(String x) {
	    ...
        synchronized (this) {
            print(x);
            newLine();
        }
    }
  • La fonction prend un verrou sur 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).
  • Même si le thread peut être dé-schédulé pendant l'appel à 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é.

Le scheduler entrelace les threads

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.

Quand la mémoire s'en mêle...

Rappel : lorsque plusieurs threads coexistent, ils partagent la même mémoire (le tas).

schéma simplifié de la mémoire avec 2 Threads et un Runnable

... les problèmes sérieux commencent

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, utilisation des caches, ...),
  • ... liés aux optimisations du code par le JIT.

Le problème de la non atomicité des instructions

Un exemple qui paraît simple

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.

Le byte code

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

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec un seul thread : schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec un seul thread : schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec un seul thread : schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec un seul thread : schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec un seul thread : schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec un seul thread : schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec un seul thread : schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec deux threads (pour l'instant, c'est le thread rouge qui est schédulé) :

schéma simplifié de la mémoire avec 2 Threads et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec deux threads (c'est encore le thread rouge qui est schédulé) :

schéma simplifié de la mémoire avec 2 Threads et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec deux threads (thread bleu prend la main) :


schéma simplifié de la mémoire avec 2 Threads et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec deux threads (encore le thread bleu) :


schéma simplifié de la mémoire avec 2 Threads et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec deux threads (toujours le thread bleu) :


schéma simplifié de la mémoire avec 2 Threads et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec deux threads (le thread rouge repart) :


schéma simplifié de la mémoire avec 2 Threads et Counter

Ce qui se passe en mémoire...

... lorsque ce code est exécuté avec deux threads (le thread rouge finit son tour de boucle) :

schéma simplifié de la mémoire avec 2 Threads et Counter

La morale de l'histoire

  • Les thread peuvent être schedulés et dé-schedulés n'importe quand.
  • Même une instruction qui parait aussi simple que
    this.value++
    
    n'est probablement PAS atomique.

  • Pour partager correctement la mémoire, il va falloir prendre des précautions : on aimerait bien avoir un peu plus de contrôle sur les instructions (puisqu'on ne peut pas en avoir sur le scheduler).

  • Et attention : même avec plusieurs CPUs, le tas est partagé....

Attention, le scheduler n'est pas le seul facteur de "perturbation"...

Les optimisations du code (1/3)

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++; 
	}
}

Les optimisations du code (1/3)

... 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 !

Les optimisations du JIT (2/3)

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;

Les optimisations du code (3/3)

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");    
}

Les problèmes d'accès à la mémoire

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 ?

Ce qui se passe en mémoire... cache

On ne devrait plus être embêté par le scheduler...


schéma simplifié de la mémoire cache avec 2 Threads et Counter

Ce qui se passe en mémoire... cache

Le moment où l'écriture est effective dans la RAM dépend de l'algorithme de mise à jour du cache...

schéma simplifié de la mémoire cache avec 2 Threads et Counter

Ce qui se passe en mémoire... cache

... on n'a aucune garantie sur le moment où l'écriture en RAM est effectuée


schéma simplifié de la mémoire cache avec 2 Threads et Counter

Ce qui se passe en mémoire... cache

Le thread bleu se termine


schéma simplifié de la mémoire cache avec 2 Threads et Counter

Ce qui se passe en mémoire... cache

On a perdu une des deux incrémentations


schéma simplifié de la mémoire cache avec 2 Threads et Counter

En résumé

  • Par défaut, une instruction ou un morceau de code ne s'exécute pas de façon atomique.

  • Le code d'un thread s'exécute sans connaissance de l'état d'exécution des autres threads.

  • Si on a plusieurs threads, avec au moins une lecture/écriture sur le tas, on peut voir les effets de bords de cette non atomicité... pour tout un tas de raisons différentes.