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 du compteur partagé

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();
		IO.println(counter.value); // :(
	}
}

On veut que 2 threads différents ne puissent pas exécuter le code qui correspond à l'instruction value = value + 1 en même temps.

Et on veut 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).

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

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

Un seul thread à la fois peut exécuter le code d'une 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.

Et la RAM est mise à jour correctement !

Sections critiques en Java : blocs synchronized et lock

public class Counter {
	private int value;
	private final Object lock;

	public Counter() {
		lock = new Object();
		super();
	}

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

L'objet qui sert de lock (on dit aussi moniteur) doit être 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).

Ça impose un certain ordre sur les instructions :

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

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.
    • Donc pas de String, de ".class" ou de wrapper, par exemple;

  • On ne veut pas que n'importe qui puisse l'utiliser comme lock
    • Sinon on 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 :

  • dans ce cours, un objet qui sert de lock est nécessairement déclaré comme un champ d'une classe;
  • il doit absolument être private ;
  • l'objet doit être créé dans le constructeur de la classe ;
  • on ne fournit pas d'accesseur sur ce champ ;

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

En particulier, on ne doit pas prendre un objet qui est peut déjà être utilisé 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;

	public Foo() {
		lock = new Object();
		super();
	}

	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;

	public Foo() {
		lock = new Object();
		super();
	}

	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();
			}
		}
		IO.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;
	private final Object lock;

	public ThreadSafeList() {
		list = new ArrayList<>();
		lock = new Object();
		super();
	}

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

		IO.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 {
	...
	
	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);
		}
	}