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, dans une classe thread-safe.
  • Il va utiliser 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;
	private boolean done;

	public static void main(String[] args) {
		var holder = new Holder();
		Thread.ofPlatform().start(() -> {
			holder.value = 42;
			holder.done = true;
		});
		while (!holder.done) {
			// 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;
	private boolean done;

	public static void main(String[] args) {
		var holder = new Holder();
		Thread.ofPlatform().start(() -> {
			holder.value = 42;
			holder.done = true;
		});
		// le code peut être optimisé pour utiliser une variable locale...
		var tmp = holder.done 
		while (!tmp) {
			// 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;
	private boolean done;

	public static void main(String[] args) {
		var holder = new Holder();
		Thread.ofPlatform().start(() -> { 
			 // les deux affectations peuvent être échangées...
			holder.done = true;
			holder.value = 42;
		});
		while (!holder.done) { 
			// 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;
	private boolean done;

	public static void main(String[] args) {
		var holder = new Holder();
		Thread.ofPlatform().start(() -> {
			holder.value = 42;
			holder.done = true;
		});
		while (!holder.done) {  // 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 boolean done;
	private final Object lock = new Object();

	public static void main(String[] args) {
		var holder = new Holder();
		Thread.ofPlatform().start(() -> {
			synchronized (holder.lock) {
				holder.value = 42;
				holder.done = true;
			}
		});
		
		for (;;) {
			synchronized (holder.lock) {
				if (holder.done) {
					break;
				}
			}
		}
		
		synchronized (holder.lock) {
			System.out.println(holder.value == 42 ? "OK" : "KO");
		}
	}
}

Pourquoi est-ce que le dernier bloc synchronized est nécessaire ?

Parce que, sans lui, on n'a aucune garantie que la variable value est relue en mémoire. Comme c'est un autre thread qui fait l'écriture, elle pourrait très bien être simplement relue dans le cache et pas dans la RAM.

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

Et les bonnes pratiques, alors !?

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

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

	private void display() {
		for (;;) { // c'est toujours de l'attente active...
			synchronized (lock) { 
				if (done) { 
					break; 
				} 
			}
		}
		synchronized (lock) { 
			System.out.println(value == 42 ? "OK" : "KO"); 
		}
	}

	public static void main(String[] args) {
		var holder = new Holder();
		Thread.ofPlatform().start(holder::init);
		holder.display();
	}
}				

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 boolean done; 
	private final Object lock = new Object();

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

	private void display() throws InterruptedException {
		synchronized (lock) {
			while (!done) {  
				lock.wait(); // demande de mettre le thread en pause
			}
			System.out.println(value == 42 ? "OK" : "KO"); 
		}
	}

	public static void main(String[] args) throws InterruptedException {
		var holder = new Holder();
		Thread.ofPlatform().start(holder::init);
		holder.display();
	}
}				

Pourquoi garder le booléen ? 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 ê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 du booléen ?

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

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

	private void display() throws InterruptedException {
		synchronized (lock) {
			while (!done) { 
				lock.wait();
			}
			System.out.println(value == 42 ? "OK" : "KO"); 
		}
	}

	public static void main(String[] args) throws InterruptedException {
		var holder = new Holder();
		Thread.ofPlatform().start(holder::init);
		holder.display();
	}
}	

Supposons qu'on enlève le booléen done.

Si l'autre thread exécute le 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 une attente infinie dans un wait() :

  • Il faut utiliser une condition (un champs booléen, par exemple) qui change au moment où l'on appelle un notify.
  • Ainsi, on peut utiliser 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 liés à la non atomicité des instructions).

Pourquoi un while et pas un if ?

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

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

	private void display() throws InterruptedException {
		synchronized (lock) {
			while (!done) { 
				lock.wait();
			}
			System.out.println(value == 42 ? "OK" : "KO"); 
		}
	}

	public static void main(String[] args) throws InterruptedException {
		var holder = new Holder();
		Thread.ofPlatform().start(holder::init);
		holder.display();
	}
}	

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