W3docs

Communication inter-thread en Java

Coordonnez les threads Java avec wait, notify et notifyAll sur des verrous de moniteur partagés — et quand préférer des primitives de plus haut niveau.

L'exclusion mutuelle vous garantit un état partagé sûr. Elle ne permet pas à un thread de signaler à un autre que l'état a changé. C'est le rôle du trio wait, notify et notifyAll de java.lang.Object. Ce sont les primitives de coordination de plus bas niveau qu'expose Java — chaque mécanisme de plus haut niveau (files bloquantes, verrous, sémaphores, Condition) est construit sur cette idée : un thread attend à l'intérieur d'un moniteur jusqu'à ce qu'un autre thread lui dise de se réveiller.

Le code moderne appelle rarement wait/notify directement. Vous utiliserez plutôt BlockingQueue, CountDownLatch ou Condition. Mais vous devez connaître le mécanisme sous-jacent, car (a) c'est toujours ce qu'utilisent ces classes en interne, (b) chaque bibliothèque que vous lirez l'utilise, et (c) lorsque quelque chose tourne mal dans du code de haut niveau, le diagnostic remonte souvent jusqu'à un notify manquant.

Le trio

Défini sur java.lang.Object, donc chaque objet les possède :

void wait() throws InterruptedException;
void wait(long timeoutMillis) throws InterruptedException;
void notify();
void notifyAll();

La règle absolue : vous ne pouvez appeler ces méthodes que si vous détenez le moniteur de l'objet sur lequel vous les appelez. C'est-à-dire que vous devez être à l'intérieur d'un bloc synchronized (obj) { ... } (ou d'une méthode synchronized qui verrouille le même obj). Appeler obj.wait() sans détenir le moniteur de obj lève immédiatement une IllegalMonitorStateException.

synchronized (lock) {
  lock.wait();                                  // ok — we hold lock
  lock.notify();                                // ok — same
}
lock.wait();                                    // IllegalMonitorStateException

Cette règle est ce qui fait fonctionner l'API : le wait et le notify sont garantis de se produire avec le verrou détenu, donc l'état dont ils parlent est cohérent.

Ce que fait réellement wait()

wait() n'est pas « dormir ». Il effectue atomiquement trois choses :

  1. Libère le moniteur de l'objet sur lequel vous l'avez appelé.
  2. Gare le thread courant dans l'ensemble d'attente de ce moniteur.
  3. Lorsqu'il est réveillé (par notify/notifyAll/interrupt/timeout), il ré-acquiert le moniteur avant de retourner.

La partie « libère et gare atomiquement » est ce qui rend wait sûr : un notify qui arrive entre « nous avons décidé d'attendre » et « nous avons réellement commencé à attendre » serait sinon perdu. Avec wait, cet intervalle n'existe pas.

Après le retour de wait(), vous êtes de nouveau à l'intérieur du bloc synchronized avec le verrou détenu — c'est pourquoi votre code après wait() peut lire l'état partagé en toute sécurité.

Ce que font notify() et notifyAll()

notify() choisit un thread (lequel est défini par la JVM — généralement pas FIFO) dans l'ensemble d'attente et le fait passer de WAITING/TIMED_WAITING à BLOCKED. Le thread notifié attend encore le moniteur ; le notifiant détient toujours le moniteur. Le thread notifié ne peut ré-acquérir que lorsque le notifiant quitte le bloc synchronized.

notifyAll() réveille tous les threads de l'ensemble d'attente de la même façon. Ils passent tous à BLOCKED ; ils s'alignent tous pour le verrou ; ils ré-acquièrent un à un à mesure que le verrou devient disponible.

notify est plus rapide (un seul thread réveillé) mais dangereux : si vous réveillez le mauvais thread (dont la condition n'est pas réellement satisfaite), il retourne à wait() et rien d'utile ne se produit. notifyAll est plus sûr (un waiter qui peut progresser le fera) mais plus coûteux. Utilisez notifyAll par défaut ; passez à notify uniquement lorsque vous pouvez prouver que tous les waiters sont interchangeables.

Le pattern obligatoire avec la boucle while

La règle la plus importante concernant wait :

Appelez toujours wait() à l'intérieur d'une boucle while qui re-vérifie la condition.

synchronized (lock) {
  while (!conditionHolds()) {
    lock.wait();
  }
  // now condition holds AND we own the lock
}

Trois raisons d'utiliser une boucle plutôt qu'un if :

  1. Réveils spurieux. La JVM est autorisée à réveiller un wait sans raison du tout. La boucle les intercepte.
  2. notifyAll réveille plus d'un thread. Quand ils rivalisent tous pour le verrou, celui qui gagne peut n'avoir rien d'utile à faire — quelqu'un d'autre a déjà consommé la ressource. La boucle le renvoie à wait.
  3. L'état peut changer. Entre le notify et le moment où vous ré-acquérez le verrou, quelqu'un d'autre avec le verrou peut avoir annulé ce que vous attendiez. La boucle re-vérifie.

if (!condition) wait() est le bug le plus courant dans le code wait/notify. Ça fonctionne dans les tests ; ça plante en production à 3h du matin.

Le classique producteur–consommateur

Le cas d'usage canonique pour wait/notify est un tampon borné :

class Buffer<T> {
  private final Object lock = new Object();
  private final Object[] data;
  private int count, head, tail;

  Buffer(int capacity) { data = new Object[capacity]; }

  void put(T item) throws InterruptedException {
    synchronized (lock) {
      while (count == data.length) lock.wait();             // wait for room
      data[tail] = item;
      tail = (tail + 1) % data.length;
      count++;
      lock.notifyAll();                                      // wake any consumer
    }
  }

  @SuppressWarnings("unchecked")
  T take() throws InterruptedException {
    synchronized (lock) {
      while (count == 0) lock.wait();                        // wait for an item
      T item = (T) data[head];
      data[head] = null;
      head = (head + 1) % data.length;
      count--;
      lock.notifyAll();                                      // wake any producer
      return item;
    }
  }
}

Quelques points corrects dans cette implémentation :

  • Même verrou pour les deux méthodes (lock). Un seul moniteur protège tout l'état.
  • Les deux attentes sont à l'intérieur de boucles while.
  • notifyAll des deux côtés — car producteurs et consommateurs attendent sur le même moniteur et réveiller un seul pourrait être du mauvais type.
  • Verrou détenu pendant le wait (le wait le libère en interne, puis le ré-acquiert avant de retourner).

En production, vous utiliseriez BlockingQueue plutôt que d'écrire ceci à la main. Mais le pattern est ce que BlockingQueue fait en interne.

Pourquoi notifyAll est le choix plus sûr par défaut

Si vous remplaciez notifyAll par notify dans le tampon ci-dessus, vous auriez un bug subtil. Deux consommateurs et un producteur attendent sur le même moniteur. Le producteur appelle notify ; la JVM choisit un thread ; si elle choisit un consommateur alors que le réveil était destiné à « la file a de la place » (sans intérêt pour les consommateurs), le consommateur re-vérifie sa condition (la file est peut-être encore vide), retourne à wait, et le producteur qui était censé se réveiller ne le fait jamais. File bloquée, aucune exception.

Pour utiliser notify en toute sécurité, vous avez besoin que : tous les waiters attendent la même condition, sont tous interchangeables, et le protocole garantit la progression. C'est une barre stricte. Utilisez notifyAll par défaut ; utilisez notify lorsque le gain de performance est important et que vous pouvez prouver l'invariant.

Les alternatives dépréciées

Il existe du vieux code qui utilise Thread.suspend() et Thread.resume(). N'utilisez pas ces méthodes. Elles ont été dépréciées dans Java 1.2 car elles laissent des verrous détenus et brisent les invariants. Le mécanisme wait/notify est la seule façon sûre de faire attendre un thread pour un autre en utilisant uniquement des méthodes de Object.

Il y a aussi Thread.sleep — mais sleep ne libère pas les verrous. Un thread qui dort à l'intérieur d'un bloc synchronized bloque tout autre thread voulant le même verrou jusqu'à son réveil. Utilisez wait (qui libère) pour tout scénario « attendre qu'une chose se produise » ; réservez sleep pour « attendre un temps fixe, sans rien détenir d'important ».

Ce qu'utiliser à la place en production

wait/notify sont corrects mais sujets aux erreurs. Le code moderne préfère les blocs de construction de plus haut niveau :

BesoinUtiliser
Producteur–consommateur bornéArrayBlockingQueue, LinkedBlockingQueue
Attendre que N choses se terminentCountDownLatch
Attendre que N parties se rejoignentCyclicBarrier, Phaser
Plusieurs variables de condition sur un verrouCondition (de ReentrantLock.newCondition())
Permis de ressourceSemaphore
Résultat futur en un seul coupCompletableFuture

Chacun d'eux intègre la bonne boucle while, la bonne sémantique notifyAll/signalAll, et la bonne gestion des interruptions. Nous les rencontrerons tous dans cette partie du livre.

Un exemple concret : producteur–consommateur avec wait et notifyAll

Le programme ci-dessous exécute deux producteurs et trois consommateurs sur le tampon borné ci-dessus. Les producteurs mettent chacun 1000 éléments ; les consommateurs s'exécutent jusqu'à ce qu'ils aient collectivement prélevé 2000 éléments.

java— editable, runs on the server

Ce qu'on retient de l'exécution :

  • Les sommes correspondent. Chaque élément mis par un producteur a été prélevé par exactement un consommateur ; rien n'a été dupliqué, rien n'a été perdu. C'est la propriété de correction du producteur–consommateur, atteinte avec un seul moniteur et la paire wait/notifyAll.
  • Le tampon n'avait que 4 emplacements, donc les producteurs le remplissaient régulièrement et les consommateurs le vidaient régulièrement. Les boucles while leur permettaient de se garer et se re-garer au fil du cycle de la file. Sans wait, les producteurs auraient tourné en boucle sur count == capacity, gaspillant le CPU ; avec wait, ils dorment jusqu'à ce que le consommateur signale.
  • Le notifyAll était appelé sur le même verrou que détenaient producteurs et consommateurs. C'est tout le mécanisme de coordination : un moniteur, exclusion mutuelle et signalisation, avec la boucle while qui intercepte tout réveil non pertinent.
  • Le wait final en dehors de synchronized a levé une IllegalMonitorStateException immédiatement. C'est l'application de la règle par la JVM : vous ne pouvez attendre/notifier que sur un moniteur que vous détenez actuellement. Si vous voyez cette exception, le chemin de code a atteint wait sans passer par synchronized d'abord.
  • La même forme — tampon borné, exclusion mutuelle, signal à chaque changement d'état — est ce que fait ArrayBlockingQueue en interne, sauf qu'il utilise deux Conditions (une pour « pas plein », une pour « pas vide ») au lieu d'un grand notifyAll. C'est la bonne façon d'écrire cela en production ; la version wait/notifyAll est le mécanisme sous-jacent sur lequel chaque classe de plus haut niveau est construite.

Et ensuite

Le chapitre suivant, Java Deadlock, examine le mode d'échec qui rend le verrouillage subtil en premier lieu — deux threads détenant chacun ce que l'autre veut — et les stratégies pour le prévenir.

Pratique

Pratique
Pourquoi `obj.wait()` doit-il toujours être appelé depuis un bloc `synchronized (obj)` (ou une méthode `synchronized` qui verrouille sur `obj`) ?
Pourquoi `obj.wait()` doit-il toujours être appelé depuis un bloc `synchronized (obj)` (ou une méthode `synchronized` qui verrouille sur `obj`) ?
Was this page helpful?