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(); // IllegalMonitorStateExceptionCette 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 :
- Libère le moniteur de l'objet sur lequel vous l'avez appelé.
- Gare le thread courant dans l'ensemble d'attente de ce moniteur.
- 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 bouclewhilequi 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 :
- Réveils spurieux. La JVM est autorisée à réveiller un
waitsans raison du tout. La boucle les intercepte. notifyAllré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.- L'état peut changer. Entre le
notifyet 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. notifyAlldes 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(lewaitle 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 :
| Besoin | Utiliser |
|---|---|
| Producteur–consommateur borné | ArrayBlockingQueue, LinkedBlockingQueue |
| Attendre que N choses se terminent | CountDownLatch |
| Attendre que N parties se rejoignent | CyclicBarrier, Phaser |
| Plusieurs variables de condition sur un verrou | Condition (de ReentrantLock.newCondition()) |
| Permis de ressource | Semaphore |
| Résultat futur en un seul coup | CompletableFuture |
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.
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
whileleur permettaient de se garer et se re-garer au fil du cycle de la file. Sanswait, les producteurs auraient tourné en boucle surcount == capacity, gaspillant le CPU ; avecwait, 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 bouclewhilequi intercepte tout réveil non pertinent. - Le
waitfinal en dehors desynchronizeda levé uneIllegalMonitorStateExceptionimmé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 atteintwaitsans passer parsynchronizedd'abord. - La même forme — tampon borné, exclusion mutuelle, signal à chaque changement d'état — est ce que fait
ArrayBlockingQueueen interne, sauf qu'il utilise deuxConditions (une pour « pas plein », une pour « pas vide ») au lieu d'un grandnotifyAll. C'est la bonne façon d'écrire cela en production ; la versionwait/notifyAllest 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.