Concurrence

Signaux


Correction du maximum thread-safe

Le problème du rendez-vous

(voir dernier exercice du TP précédent)

Supposons que deux threads veulent s'échanger des données...

  • Ces données vont devoir transiter par le tas (en RAM), via les champs des objets, par exemple.
  • Il va falloir une ou plusieurs sections critiques pour éviter les états incohérents...
  • ... mais ce n'est pas suffisant : il faut aussi que le thread qui veut recevoir des données attende que ces données soient arrivées.

Repartons d'un exemple simple

public class Holder {
	private int value;

	public static void main(String[] args) {
		var holder = new Holder();

		Thread.ofPlatform().start(() -> {
			holder.value = 42;
		});

		System.out.println(holder.value == 42 ? "OK" : "KO");
	}
}

Que va afficher ce programme ?

Parfois OK... et parfois KO.

Essayons de faire les choses dans l'ordre

public class Holder {
	private int value;

	public static void main(String[] args) {
		var holder = new Holder();
		
		Thread.ofPlatform().start(() -> {
			holder.value = 42;
		});

		// dans le main
		while (!holder.value == 0) {
			// ne rien faire !
		}

		System.out.println(holder.value == 42 ? "OK" : "KO");
	}
}

Qu'est-ce qui pose problème ici ?

On a toujours une data-race...

public class Holder {
	private int value;

	public static void main(String[] args) {
		var holder = new Holder();
		
		Thread.ofPlatform().start(() -> {
			holder.value = 42;
		});

		// le code peut être optimisé pour utiliser une variable locale...
		var tmp = holder.value 
		while (tmp == 0) {
			// ne rien faire !
		}

		System.out.println(holder.value == 42 ? "OK" : "KO");
	}
}

Qu'est-ce qui pose problème ici ?

Et on fait de l'attente active !

public class Holder {
	private int value;

	public static void main(String[] args) {
		var holder = new Holder();

		Thread.ofPlatform().start(() -> {
			holder.value = 42;
		});

		while (holder.value == 0) {  // là!!!
			// ne rien faire ! 
		}

		System.out.println(holder.value == 42 ? "OK" : "KO");
	}
}

Important : l'attente active consiste à vérifier périodiquement l'état d'un objet jusqu'à observer le changement d'état désiré. Cela produit une boucle qui consomme énormément de ressources CPU. Il faut absolument éviter ce comportement.

Commençons par éviter toute data-race...

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

	void init() {
		synchronized (lock) {
			value = 42;
		}
	}

	int hold() {
		for (;;) {
			synchronized (lock) {
				if (value != 0) {
					return value;
				}
			}
		}
	}

	public static void main(String[] args) {
		var holder = new Holder();
		Thread.ofPlatform().start(holder::init);
		var value = holder.hold()
		System.out.println(value == 42 ? "OK" : "KO");
	}
}

Ça ne résout pas le problème de l'attente active !

Comment empêcher l'attente active

On voudrait un mécanisme qui :

  • permet de mettre en pause un thread (on veut que le thread en attente soit dé-schédulé) ;
  • permet de réveiller un thread qui est en pause (et donc de le re-scéduler) ;
  • fonctionne correctement avec les sections critiques (sinon on va avoir les problèmes de data-race classiques).

En Java, c'est la paire de méthodes wait() et notify() du lock qui permet de faire ça.

On ne fait plus d'attente active !

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

	void init() {
		synchronized (lock) {
			value = 42;
			lock.notify(); // réveille un thread s'il est en pause (sinon, ne fait rien)
		}
	}

	int hold() throws InterruptedException {
		synchronized (lock) {
			while (value == 0) {  
				lock.wait(); // demande de mettre le thread en pause
			}
			retrun value;
		}
	}

	public static void main(String[] args) throws InterruptedException {
		var holder = new Holder();
		Thread.ofPlatform().start(holder::init);
		var value = holder.hold()
		System.out.println(value == 42 ? "OK" : "KO");
	}
}				

Pourquoi garder le test ? Pourquoi le tester dans une boucle ?

Ce que fait lock.wait()

Lorsque la méthode est appelée :

  • elle demande à mettre le thread courant en pause : il est dé-schedulé ;
  • elle redonne le jeton du verrou lock,

Lorsque le thread est réveillé (après l'appel à wait) :

  • ça demande la reprise de l’exécution du thread : celui-ci peut être re-schédulé.
  • le thread reprend le jeton du lock (quand il est disponible) ;

Les appels à lock.wait() doivent donc obligatoirement être faits à l’intérieur d'un bloc synchronized(lock) !

Ce que font notify() et notifyAll()

  • lock.notify() : demande le réveil d'un thread en attente sur le lock (à cause d'un appel lock.wait()).
    • S'il y en a plusieurs, le scheduler en choisit un arbitrairement.
    • Si aucun thread n'est en attente, le notify() est perdu et il ne se passe rien.
  • lock.notifyAll() : demande le réveil de touts les threads en attente sur le lock (à cause d'un appel à lock.wait()).
  • Important : ces méthodes ne provoquent pas la sortie immédiate du bloc synchronized.

Les appels à lock.notify() ou lock.notifyAll() doivent aussi être faits à l’intérieur d'un bloc synchronized(lock) (nous avons allons voir pourquoi rapidement).

Conditions de réveil

Comment un thread peut être réveillé d'un appel à lock.wait() ?

  • Si un autre thread appelle lock.notify() ou lock.notifyAll().
  • Si un autre thread interrompt le thread courant avec interrupt() (dans ce cas lock.wait() lève InterruptedException, on verra ça dans un prochain cours).
  • Sans raison apparente : on appelle celà un spurious wake-up.

Pourquoi est-ce qu'on garde le test ?

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

	void init() {
		synchronized (lock) {
			value = 42;
			lock.notify();
		}
	}

	int hold() throws InterruptedException {
		synchronized (lock) {
			while (value == 0) { 
				lock.wait();
			}
			retrun value;
		}
	}

	public static void main(String[] args) throws InterruptedException {
		var holder = new Holder();
		Thread.ofPlatform().start(holder::init);
		var value = holder.hold()
		System.out.println(value == 42 ? "OK" : "KO");
	}
}

Supposons qu'on enlève le test.

Si l'autre thread exécute notify() avant que le thread main soit dans le wait(), le notify() sera perdu... et le wait() attendra indéfiniment !

Donc l'appel à lock.notify() est fait à l’intérieur d'un bloc synchronized(lock) qui met à jour la condition associée au wait().

Notifications perdues

Pour éviter les notifications perdues... et donc l'attente infinie dans wait() :

  • Il faut tester la raison (condition) pour laquelle on a appelé notify.
  • On utilise cette condition pour savoir s'il faut faire le wait() ou pas.
  • Bien sûr, cette condition doit impérativement être modifiée dans le bloc synchronized contenant le notify associé (sinon, on retrouve tous les problèmes de data-race habituels).

Pourquoi un while et pas un if ?

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

	void init() {
		synchronized (lock) {
			value = 42;
			lock.notify();
		}
	}

	int hold() throws InterruptedException {
		synchronized (lock) {
			while (value == 0) { 
				lock.wait();
			}
			retrun value;
		}
	}

	public static void main(String[] args) throws InterruptedException {
		var holder = new Holder();
		Thread.ofPlatform().start(holder::init);
		var value = holder.hold()
		System.out.println(value == 42 ? "OK" : "KO");
	}
}	

Pour se protéger du spurious wake-up !

Lorsque l'on sort du wait(), on doit à nouveau tester la condition, pour être sûr que c'est bien la raison qui nous a fait sortir du wait().

Conclusion


Un wait() n'est jamais tout seul !

L'appel à wait() doit toujours :

  • être à l’intérieur d'un bloc synchronized ;
  • se trouver dans une boucle while ;
  • qui teste la condition qui a permis de faire le notify.

L'appel à notify() est toujours :

  • à l’intérieur d'un bloc synchronized
    (sur le même verrou que le bloc qui contient l'appel à wait()) ;
  • qui met à jour la condition associée au wait().