Concurrence

Volatile et Atomic


Écriture et utilisation de classes thread-safe

Rappels :

  • Besoin d'exclusion mutuelle pour créer un forme d'atomicité :
    • soit en utilisant des blocs synchronized,
    • soit en utilisant des ReentrantLock.
  • Ils permettent également de créer une barrière en mémoire.
  • Utilisation avancée : pattern Producteur-Consommateur.

Problèmes : possibilité d'inter-blocage et problèmes d'efficacité. En particulier, prendre/relâcher un verrou peut être plus long que le temps d'exécution du code à l'intérieur de la section critique.

Alternative : programmation lock-free

L'objectif :

  • algorithme non bloquant : l’échec ou la suspension d'un thread ne peut pas en bloquer un autre.
  • algorithme lock-free : à chaque étape, il y a au moins un thread qui peut travailler.

Solution :

  • avoir des champs qui provoquent une barrière mémoire.
  • disposer d'opérations atomiques "simples" (mais pas triviales) pour ces champs.

Champs volatile

  • L'écriture dans un champ volatile rend visible toutes les écritures précédentes.
  • La lecture dans un champ volatile oblige la première lecture suivante a être rechargées à partir de la RAM.
  • L'écriture d'une valeur 64 bits (long ou double) est vue comme atomique même sur une machine 32 bits.

Ce sont des effets globaux (sur tous les champs, même non volatile), qui empêchent aussi les réorganisations par le JIT / CPUs. On parle de barrière en lecture/écriture.

Exemple d'utilisation de volatile

	class A {
		private int value;
		private volatile boolean done;

		public void init(int value) {
			this.value = value;
			this.done = true; // écriture volatile, donc value est écrite en RAM
		}

		public static void main(String[] args) {
			A a = new A();
			Thread.ofPlatform().start(() -> {
				a.init(9);
			});
			if (a.done) { // lecture volatile, depuis la RAM (invalidation de cache)
				System.out.println(a.value); // doit être rechargée de la RAM donc 9
			}
		}
	}

Plus de problème de publication

	public class A {
		private volatile int value;
		private static A a;

		public A(int value) {
			this.value = value; // écriture volatile
		}

		public static void main(String[] args) {
			Thread.ofPlatform().start(() -> {
				if (a != null) {
					System.out.println(a.value); // lecture volatile
				}
				// affiche 7 ou rien
			});
			a = new A(7);
		}
	}

Il n'y a pas de problème de publication avec les volatile.

Opérations atomiques

Les instructions des langages de haut niveau comme C ou Java ne sont pas atomiques.

Néanmoins, les processeurs actuels fournissent des instructions assembleur atomiques.

Par exemple, pour intel , ladd rax 1, pour locked add, correspond à une incrémentation atomique (i++).

Opérations atomiques en Java

En Java, les opérations atomiques sont vues comme des méthodes que l'on peut exécuter sur un champ volatile.

Il y a deux façons de faire :

  • la classe java.util.concurrent.atomic.AtomicInteger (par exemple). Elle encapsule un champ volatile (ici un int) et fournit des méthodes atomiques.
  • la classe java.lang.invoke.VarHandle. Elle référence un champ volatile d'une classe externe et fournit des méthodes atomiques plus bas niveau (vois cours suivant).

L'exemple du compteur thread-safe (1)

Voici l'exemple d'un compteur thread-safe utilisant un bloc synchronized (on peut obtenir la même chose avec un ReentrantLock) :

public class ThreadSafeCounter { 
	private final Object lock = new Object(); 
	private int counter;

	public int nextValue() { // incrémente le compteur et renvoie la valeur précédente
		synchronized (lock) {
			return counter++; 
		}
	} 

	public int previousValue() {
		synchronized (lock) {
			return counter--;
		}
	}
}

Le code est thread-safe mais pas forcément très rapide.

L'exemple du compteur thread-safe (2)

Un AtomicInteger contient un champ volatile de type int :

public class ThreadSafeCounter { 
	private final AtomicInteger counter = new AtomicInteger();

	public int nextValue() {
		return counter.getAndIncrement();
	} 

	public int previousValue() {
		return counter.getAndDecrement();
	}
}
  • La barrière en mémoire est obtenue grâce au champs volatile.
  • Les méthodes getAndIncrement() et getAndDecrement() sont vues comme des instructions atomiques à l’exécution.

Atomic*

Atomic* est une famille de classes qui se déclinent pour chaque type :

  • boolean  ⟶  AtomicBoolean
  • int (ou float)  ⟶  AtomicInteger
  • long (ou double)  ⟶  AtomicLong
  • référence à un objet de type T  ⟶  AtomicReference<T>
  • tableaux d'entiers  ⟶  AtomicIntegerArray
  • ...
  • tableaux d'objets de type T  ⟶  AtomicReferenceArray<T>

Pour les tableaux, les classes fournissent des opérations atomiques sur les cases (individuellement).

Algorithme lock-free : principe général

Avec des verrous, on a la garantie qu'une fois dans le bloc de synchronisation, on peut effectuer l'opération que l'on veut.

En lock-free, on réfléchit à l'inverse : on tente une opération en espérant que cela va fonctionner (c'est-à-dire qu'un autre thread ne va pas interférer), et sinon, on peut/doit ré-essayer.

compareAndSet

Attention, toutes les opérations atomiques ne sont pas disponibles sur tous les processeurs... Mais il existe une primitive de base : compareAndSet (ou CAS) qui permet de modifier une variable uniquement si elle a la valeur que l'on souhaite, de façon atomique.

Les classes Atomic* possèdent une méthode d'instance compareAndSet qui prend en paramètre la valeur attendue et une nouvelle valeur et peuvent modifier leur champs volatile. Pour AtomicReference<V> :

public boolean compareAndSet(V expectedValue, V newValue)

Si la valeur du champ de type V contenu dans this est égale (au sens ==) à expectedValue, alors la valeur du champs devient newValue et la méthode renvoie true, sinon elle renvoie false.

L'exemple du compteur thread-safe (3)

La classe AtomicInteger offre une méthode compareAndSet :

public class ThreadSafeCounter {
	private final AtomicInteger counter = new AtomicInteger();

	public int nextValue() { 
		for(;;) {
			var current = counter.get(); // lecture volatile
			if (counter.compareAndSet(current, current + 1)) {
				return current;
			} // sinon, on ré-essaie
		} 
	}

	public int previousValue() {
		...
	}
}

Si le CAS réussi, c'est équivalent à une écriture volatile (c’est nécessaire pour avoir la barrière mémoire, l’atomicité ne suffit pas).

L'exemple du compteur thread-safe (faux!)

Attention, voici deux exemples d'utilisation incorrecte de compareAndSet !

	public int nextValue() {
		for(;;) {
			var current = counter.get(); // lecture volatile
			if (counter.compareAndSet(counter.get(), counter.get() + 1) { // non, non...
				return counter.get(); // et non...
			}
		}
	}

	public int previousValue() {
			var current = counter.get(); // lecture volatile
			var check = counter.compareAndSet(current, current - 1) // et si ça échoue ?
			return current; 
	}

Problème 1 : on fait plusieurs appels à get() entre lesquels n'importe quel thread peut être schédulé ou dé-schédulé...

Problème 2 : on ne ré-essaie pas...

Autre exemple d'opération atomique

Les Atomic* fournissent des méthodes pour faire des opérations atomiques (à base de CAS) plus sophistiquées

La méthode getAndUpdate(UnaryOperator) permet de faire un CAS dans une boucle en indiquant uniquement la fonction qui permet de mettre à jour la valeur :

	public class ThreadSafeCounter {
		private final AtomicInteger counter = new AtomicInteger();

		public int nextValue() {
			return counter.getAndUpdate(x -> x + 1);
		}
	}

Résumé

  • Écriture/lecture d'un champs volatile = barrière mémoire.
  • C'est une sémantique plus faible que les blocs de synchronisation.
  • Programmation lock-free :
    • on ne peut pas se retrouver avec tous les threads bloqués ;
    • volatile + opération atomique (Atomic*) ;
    • l'opération atomique de base est le compareAndSet;
    • les algorithmes sont souvent plus efficaces mais peuvent être plus difficiles à écrire...