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é-schédulés pendant l'appel à la fonction println recodée dans la classe fournie et un thread peut commencer une écriture au milieu de celle d'un autre thread.
  • Cela ne peut jamais se produire avec la méthode IO.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 à 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é.

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é-schédulé" n'importe quand, même au milieu de son exécution.

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

Rappel : quand on a plusieurs threads, 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, niveaux de cache, ...) ;

  • ... mais aussi liés aux optimisations du code (par le JIT, en Java).

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();

        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 ?

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 1re
putfield Counter.value   // mettre ce résultat dans le champ value
                    

Ce qui se passe en mémoire...

... quand ce code est exécuté par un seul thread :

schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... quand ce code est exécuté par un seul thread :

schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... quand ce code est exécuté par un seul thread :

schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... quand ce code est exécuté par un seul thread :

schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... quand ce code est exécuté par un seul thread :

schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... quand ce code est exécuté par un seul thread :

schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... quand ce code est exécuté par un seul thread :

schéma simplifié de la mémoire avec 1 Thread et Counter

Ce qui se passe en mémoire...

... quand ce code est exécuté par deux threads :

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

Pour l'instant, c'est le thread rouge qui est schédulé

Ce qui se passe en mémoire...

... quand ce code est exécuté par deux threads :

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

C'est encore le thread rouge qui est schédulé.

Ce qui se passe en mémoire...

... quand ce code est exécuté par deux threads :

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

Le thread bleu prend la main.

Ce qui se passe en mémoire...

... quand ce code est exécuté par deux threads :

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

C'est encore le thread bleu qui s'exécute

Ce qui se passe en mémoire...

... quand ce code est exécuté par deux threads :

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

C'est toujours le thread bleu qui s'exécute

Ce qui se passe en mémoire...

... quand ce code est exécuté par deux threads :

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

Le thread rouge repart.

Ce qui se passe en mémoire...

... quand ce code est exécuté par deux threads :

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

Le thread rouge finit son tour de boucle.

La morale de l'histoire

  • On ne contrôle pas le scheduler : les threads peuvent être schédulés et dé-schédulés n'importe quand.

  • Même une instruction qui paraît 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).

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

  • 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é...

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

La morale de l'histoire

  • On ne contrôle pas l'état de la RAM.

  • Même si on pouvait être sûr que l'écriture par un thread A est faite avant la lecture par un autre thread B,
    • on ne peut pas garantir que l'écriture par A sera faite en RAM...
    • et donc on ne peut pas garantir que B lira la valeur écrite par A.
    • Et inversement.

  • Pour partager correctement la mémoire, il va falloir prendre des précautions : on aimerait bien avoir un peu plus de contrôle sur le "moment" de la mise à jour de la RAM.

Attention, il y a un troisième 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;
    }
    IO.println("Done");
}
                    

Comme a n'est jamais utilisé, ce code peut être réécrit en :

public void run(){
    IO.println("Done");
}
                    

En résumé

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