Concurrence

Synchronisation et classes thread-safe


Avant de commencer : retour sur le TP2, exo 3

addALot

Retour sur le TP2 : exo 3, le code de add

Dans la classe ArrayList :

  private Object[] elementData; 
  private int size;
  ...
  private void add(E e, Object[] elementData, int s) {
      if (s == elementData.length)
          elementData = grow(); // renvoie un tab. plus grand
      elementData[s] = e;
      size = s + 1;
  }
  
  public boolean add(E e) {
      modCount++;
      add(e, elementData, size);
      return true;
  }

Dans le cas où la capacité initiale est fixée et assez grande, grow() n'est jamais appelée.

À partir de là, on peut donner un scénario pour que la taille ne soit pas 20 000.

Synchronisation

Le problème de l'atomicité des instructions

public class Counter {
	private int value;

	public void addALot() {
		for (var i = 0; i < 10_000; i++) {
			value = value + 1; // ce n'est pas une opération atomique!
		}
	}

	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); // :(
	}
}

Section critique : "fabriquer" de l'atomicité

On voudrait s'assurer que 2 threads différents ne puissent pas être en train d’exécuter le code qui correspond à l'instruction value = value + 1 en même temps.

Et on voudrait aussi garantir qu'on ne va pas essayer de lire la valeur de value avant que l'écriture précédente soit finie (c'est à dire répercutée en RAM et pas juste gardée dans une variable locale ou en cache).


C'est ce à quoi sert une section critique.

Section critique

public class Counter {
	private int value;

	public void addALot() {
		for (var i = 0; i < 10_000; i++) {
			// début section critique
			value = value + 1;
			// fin section critique
		}
	}
}

Un seul thread à la fois peut être en train d’exécuter le code de la section critique !

Attention, ça ne veut PAS dire que le thread ne peut pas être de-schédulé s'il est dans la section critique.

Est-ce que c'est une garantie suffisante?
Non... il faut aussi que ça assure que la RAM est mise à jour correctement!

Section critique

On veut pouvoir "protéger" plusieurs portions de code différentes :

public class Counter {
	private int value;

	public void addALot() {
		for (var i = 0; i < 10_000; i++) {
			// début section critique
			value = value + 1;
			// fin section critique
		}
	}

	public void add42() {
		// début section critique
		if (value != 13){
			value = value + 42;
		}
		// fin section critique
	}
}

Sections critiques en Java : blocs synchronized et lock

public class Counter {
	private int value;
	private final Object lock = new Object();

	public void addALot() {
		for (var i = 0; i < 10_000; i++) {
			synchronized(lock){ 
				value = value + 1;
			} 
		}
	}
}

C'est important que l'objet qui sert de lock (on dit aussi moniteur) soit privé et final, on verra bientôt pourquoi.

Les propriétés du bloc synchronized

C'est un verrou : quand un thread entre dans un bloc synchronized, il prend un jeton associé à l'objet lock et lorsqu'il sort du bloc synchronized, il rend le jeton.

  • Un seul thread peut avoir le jeton (associé au même lock) à la fois !

  • Un thread peut être dé-schédulé alors qu'il a le jeton. Mais dans ce cas, il le garde et aucun autre thread ne peut le prendre (et donc entrer dans un bloc synchronisé utilisant le même lock).

Les propriétés du bloc synchronized

C'est une barrière en mémoire entre le blocs synchronisés sur le même lock :

  • L'acquisition (acquire) d'un verrou force la relecture des champs depuis la RAM (valeurs en cache invalidées).
  • Le relâchement (release) d'un verrou force l'écriture des champs modifiés vers la RAM.
  • Attention, ça ne garantit rien pour l'accès à ces champs en dehors d'un bloc synchronized (sur le même verrou).

Les propriétés du bloc synchronized (précisions)

  • L'entrée ou la sortie d'un bloc synchronized impose que l'optimiseur de code/JIT ne déplace pas des instructions hors du bloc.
    • Mais, à l'intérieur d'un bloc synchronized les instructions peuvent être ré-organisées sans problème.

  • L'entrée d'un bloc synchronized invalide les registres ; sa sortie oblige la ré-écriture des registres vers la RAM ;
    • Mais un bloc synchronized n’empêche pas la RAM d'être dans un état incohérent au milieu du bloc...
    • ... par contre, cela empêche un autre thread de voir ces états incohérents (à condition qu'il utilise aussi le verrou, bien sûr).

Les propriétés d'un lock

  • C'est un objet : ça ne peut être ni un primitif, ni null;

  • C'est un objet Java qui n'utilise pas d'interning ;

  • On ne veut pas que n'importe qui puisse l'utiliser comme lock (au risque de créer des blocages).

  • Cet objet sert de référence commune, de point de rendez-vous !

On choisit comme lock... un objet Java qui n'utilise pas d'interning

L'interning est une technique utilisée pour éviter d'allouer plusieurs fois un objet représentant la même chose. Il y en a notamment dans :

  • Les chaînes constantes (String literals) comme "foo".
  • Les java.lang.Integer avec Integer.valueOf ou auto-boxing. Par exemple :
    Object o = 3; // appelle implicitement Integer.valueOf(3)
    
  • Les classes java.lang.Class avec ".class" ou Class.forName()

On n'utilise pas ces objets en tant que lock pour éviter que le même objet serve de verrou pour des besoins différents.

On choisit comme lock... un objet qui n'est pas accessible par n'importe qui.

On empêche que les locks soient visibles hors du code nécessaire et on on utilise le principe d'encapsulation de la POO :

  • les objets qui servent de lock sont déclarés comme des champs private ;
  • l'objet doit être créé dans le constructeur de la classe.
  • on ne fournit pas d'accesseur sur ces champs ;

Ces objets ne doivent pas pouvoir servir de verrou en dehors de votre code !

Enfin, on évite de prendre un objet qui est peut déjà être utlisé comme lock (comme System.out, par exemple)

On choisit comme lock... un objet final.

L'objet qui sert de lock doit être final car on ne veut pas que la référence de l'objet puisse changer.

Ça ne suffit pas ! Il faut qu'il soit déclaré final. Sinon, il est possible pour un thread de le voir alors qu'il n'est pas encore initialisé (c'est lié au problème de publication que nous verrons plus tard) et dans ce cas l'objet est null.

public class Foo {
	private final Object lock = new Object();
	...
	public void bar() {
		synchronized(lock){
			...
		}
	}
}

En java, les verrous sont ré-entrants

Un même thread peut prendre plusieurs fois le même lock :

public class Foo {
	private int value;
	private final Object lock = new Object();

	public void setValue(int value) {
		synchronized (lock) {
			this.value = value;
		}
	}

	public void reset() {
		synchronized (lock) {
			setValue(0);
		}
	}
}

Donc on peut corriger le TP... ?

public class HelloListFixed {
	public static void main(String[] args) {
		var nbThreads = 4;
		var threads = new Thread[nbThreads];
		var list = new ArrayList<Integer>();
		var lock = new Object();

		IntStream.range(0, nbThreads).forEach(j -> {
		  threads[j] = Thread.ofPlatform().start(() -> {
			  for (var i = 0; i < 5000; i++) {
			    synchronized(lock){
			      list.add(i);
			    }
			  }
		  });
		});		
		for (var thread : threads) {
			try {
				thread.join();
			} catch (InterruptedException e) {
				throw new AssertionError();
			}
		}
		System.out.println("taille de la liste : " + list.size());
	}
}
NON ! Qu'est-ce qui ne va pas dans ce code ?

Classe thread-safe

On appelle thread-safe une classe qui peut être utilisée par plusieurs threads sans avoir d'états incohérents.

Cette définition est nécessairement un peu floue car cela dépend du contrat spécifié par la classe.

Remarque : Un record ou une classe non-mutable est par définition thread-safe (par exemple: java.lang.String).

En ce qui concerne l'API Java :

  • Par défaut, une classe n'est pas thread-safe (par exemple : java.util.HashMap), sauf si c'est explicitement spécifié dans la javadoc (par exemple : java.util.Random).
  • Les classes de java.util.concurrent sont toutes thread-safe.

Liste thread-safe

public class ThreadSafeList {
	private final ArrayList<Integer> list = new ArrayList<>();
	private final Object lock = new Object();

	public void add(int value) {
		synchronized (lock) {
			list.add(value);
		}
	}

	public int size() {
		synchronized (lock) {
			return list.size();
		}
	}
}

Attention à ne pas oublier le bloc synchronized dans size(). Par exemple, il est nécessaire pour que les modifications du add soient bien relues en RAM.

Utilisation de la liste thread-safe

public class HelloListBetter {
	public static void main(String[] args){
		var nbThreads = 4;
		var threads = new Thread[nbThreads];
		var list = new ThreadSafeList();

		IntStream.range(0, nbThreads).forEach(j -> {
			threads[j] = Thread.ofPlatform().start(() -> {
				for (var i = 0; i < 5000; i++) {
					list.add(i);
				}
			});
		});

		for (var thread : threads) {
			try {
				thread.join();
			} catch (InterruptedException e) {
				throw new AssertionError();
			}
		}

		System.out.println("taille de la liste : " + list.size());
	}
}

Utilisation d'une classe thread-safe

Attention, une classe thread-safe, c'est un contrat qui vous garantit le comportement des méthodes de cette classe ....

... par contre, l'utilisation d'une classe thread-safe ne garantit pas que le code qui l'utilise est correct en termes de concurrence !

Quel est le problème ?

public class ThreadSafeList {
	private final ArrayList<Integer> list = new ArrayList<>();
	private final Object lock = new Object();

	...
	public void removeFirst() {
		synchronized (lock) {
			list.removeFirst();
		}
	}
}
public static void main(String[] args) {
		var list = new ThreadSafeList();

		for (int j = 0; j < 2; j++) {
			Thread.ofPlatform().start(() -> {
				for (var i = 0; i < 100; i++) {
					if (list.size() != 0) {
						list.removeFirst();
					}
				}
			});
		}
		for (var i = 0; i < 200; i++) {
			list.add(i);
		}
	}