Comunicazione inter-thread in Java
Coordina i thread Java con wait, notify e notifyAll sui monitor condivisi — e quando preferire primitive di livello superiore.
L'esclusione mutua garantisce uno stato condiviso sicuro. Non permette però a un thread di segnalare a un altro che lo stato è cambiato. È questo il compito del trio wait, notify e notifyAll su java.lang.Object. Sono la primitiva di coordinazione di livello più basso che Java espone — ogni meccanismo di livello superiore (code bloccanti, latch, semafori, Condition) è costruito su questa idea: un thread attende all'interno di un monitor finché un altro thread non gli dice di svegliarsi.
Il codice moderno chiama raramente wait/notify direttamente. Si preferisce usare BlockingQueue, CountDownLatch o Condition. Ma è necessario conoscere il meccanismo sottostante, perché (a) è comunque quello che queste classi usano internamente, (b) ogni libreria che leggerete lo utilizza, e (c) quando qualcosa va storto con il codice di alto livello, la diagnosi spesso risale fino a un notify mancante.
Il trio
Definiti su java.lang.Object, quindi ogni oggetto li possiede:
void wait() throws InterruptedException;
void wait(long timeoutMillis) throws InterruptedException;
void notify();
void notifyAll();La regola fondamentale: questi metodi possono essere chiamati solo mentre si possiede il monitor dell'oggetto su cui vengono chiamati. Cioè, bisogna trovarsi all'interno di un blocco synchronized (obj) { ... } (o un metodo synchronized che si blocca sullo stesso obj). Chiamare obj.wait() senza possedere il monitor di obj lancia immediatamente IllegalMonitorStateException.
synchronized (lock) {
lock.wait(); // ok — we hold lock
lock.notify(); // ok — same
}
lock.wait(); // IllegalMonitorStateExceptionQuesta regola è ciò che fa funzionare l'API: l'attesa e la notifica sono garantite avvenire con il lock acquisito, quindi lo stato di cui si parla è coerente.
Cosa fa davvero wait()
wait() non è "sleep". Fa atomicamente tre cose:
- Rilascia il monitor dell'oggetto su cui è stato chiamato.
- Sospende il thread corrente nel wait set di quel monitor.
- Quando viene svegliato (da
notify/notifyAll/interrupt/timeout) riacquisisce il monitor prima di restituire il controllo.
Il fatto che "rilascia atomicamente e sospende" è ciò che rende wait sicuro: un notify che arriva tra "abbiamo deciso di aspettare" e "abbiamo effettivamente iniziato ad aspettare" andrebbe altrimenti perso. Con wait, questo intervallo non esiste.
Dopo che wait() ritorna, ci si trova di nuovo all'interno del blocco synchronized con il lock acquisito — ecco perché il codice dopo wait() può leggere in sicurezza lo stato condiviso.
Cosa fanno notify() e notifyAll()
notify() sceglie un thread (quale lo decide la JVM — tipicamente non FIFO) dal wait set e lo sposta da WAITING/TIMED_WAITING a BLOCKED. Il thread notificato è ancora in attesa del monitor; il notificatore detiene ancora il monitor. Il thread notificato può riacquisirlo solo quando il notificatore esce dal blocco synchronized.
notifyAll() sveglia ogni thread nel wait set allo stesso modo. Tutti diventano BLOCKED; tutti si mettono in fila per il lock; lo riacquistano uno alla volta man mano che diventa disponibile.
notify è più veloce (viene svegliato un solo thread) ma pericoloso: se si sveglia il thread sbagliato (uno la cui condizione non è effettivamente soddisfatta), torna a wait() e non succede nulla di utile. notifyAll è più sicuro (qualche thread in attesa che può fare progressi lo farà) ma più costoso. Usare notifyAll come default; passare a notify solo quando si può dimostrare che tutti i thread in attesa sono intercambiabili.
Il pattern obbligatorio con il ciclo while
La regola più importante su wait:
Chiamare sempre
wait()all'interno di un ciclowhileche ri-verifica la condizione.
synchronized (lock) {
while (!conditionHolds()) {
lock.wait();
}
// now condition holds AND we own the lock
}Tre motivi per il ciclo invece di un if:
- Svegli spuri. La JVM è autorizzata a svegliare un
waitsenza alcun motivo. Il ciclo li intercetta. notifyAllsveglia più di uno. Quando tutti competono per il lock, chi vince potrebbe non avere nulla di utile da fare — qualcun altro ha già consumato la risorsa. Il ciclo lo rimanda await.- Lo stato può cambiare. Tra
notifye il momento in cui si riacquisisce il lock, qualcun altro con il lock potrebbe aver annullato ciò che si stava aspettando. Il ciclo ri-verifica.
if (!condition) wait() è il bug più comune nel codice con wait/notify. Funziona nei test; si rompe in produzione alle 3 di notte.
Il classico produttore–consumatore
Il caso d'uso canonico per wait/notify è un buffer limitato:
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;
}
}
}Alcune cose che questo fa correttamente:
- Lo stesso lock per entrambi i metodi (
lock). Un solo monitor protegge tutto lo stato. - Entrambe le attese sono all'interno di cicli
while. notifyAllsu entrambi i lati — perché sia i produttori che i consumatori attendono sullo stesso monitor e svegliare solo uno potrebbe essere il tipo sbagliato.- Il lock è acquisito mentre si è in
wait(ilwaitlo rilascia internamente, poi lo riacquisisce prima di tornare).
In produzione si userebbe BlockingQueue invece di scrivere questo a mano. Ma il pattern è ciò che BlockingQueue fa internamente.
Perché notifyAll è il default più sicuro
Se si sostituisse notifyAll con notify nel buffer sopra, si introduce un bug sottile. Due consumatori e un produttore attendono sullo stesso monitor. Il produttore chiama notify; la JVM sceglie un thread; se sceglie un consumatore quando il risveglio era inteso per "la coda ha spazio" (irrilevante per i consumatori), il consumatore ri-verifica la sua condizione (la coda è ancora possibilmente vuota), torna a wait, e il produttore che avrebbe dovuto svegliarsi non viene mai svegliato. Coda bloccata, nessuna eccezione.
Per usare notify in sicurezza bisogna che: tutti i thread in attesa attendano la stessa condizione, tutti siano intercambiabili, e il protocollo garantisca il progresso. Questo è un requisito stringente. Usare notifyAll come default; usare notify quando il guadagno in prestazioni è importante e si può dimostrare l'invariante.
Le alternative deprecate
Esiste del codice vecchio che usa Thread.suspend() e Thread.resume(). Non farlo. Sono stati deprecati in Java 1.2 perché lasciano i lock acquisiti e violano gli invarianti. Il meccanismo wait/notify è l'unico modo sicuro per far attendere un thread un altro usando solo i metodi di Object.
Esiste anche Thread.sleep — ma sleep non rilascia i lock. Un thread che dorme all'interno di un blocco synchronized blocca ogni altro thread che vuole lo stesso lock fino a quando non si sveglia. Usare wait (che rilascia) per qualsiasi scenario "aspetta che succeda qualcosa"; riservare sleep per "aspetta un tempo fisso, senza tenere nulla di importante."
Cosa usare invece in produzione
wait/notify sono corretti ma soggetti a errori. Il codice moderno preferisce i building block di livello superiore:
| Necessità | Usare |
|---|---|
| Produttore–consumatore limitato | ArrayBlockingQueue, LinkedBlockingQueue |
| Attendere il completamento di N cose | CountDownLatch |
| Attendere che tutti N i partecipanti si incontrino | CyclicBarrier, Phaser |
| Più variabili di condizione su un lock | Condition (da ReentrantLock.newCondition()) |
| Permessi per risorse | Semaphore |
| Risultato futuro one-shot | CompletableFuture |
Ognuno di questi ha il ciclo while corretto, la semantica corretta di notifyAll/signalAll, e la corretta gestione dell'interruzione integrata. Li vedremo tutti in questa parte del libro.
Un esempio pratico: produttore–consumatore con wait e notifyAll
Il programma seguente esegue due produttori e tre consumatori contro il buffer limitato sopra. I produttori inseriscono ciascuno 1000 elementi; i consumatori girano finché non ne hanno presi collettivamente 2000.
Cosa ricavare dall'esecuzione:
- Le somme corrispondono. Ogni elemento inserito da un produttore è stato prelevato da esattamente un consumatore; nulla è stato duplicato, nulla è andato perso. Questa è la proprietà di correttezza del produttore–consumatore, ottenuta con un solo monitor e la coppia
wait/notifyAll. - Il buffer era di soli 4 slot, quindi i produttori lo riempivano costantemente e i consumatori lo svuotavano costantemente. I cicli
whilepermettono loro di sospendersi e ri-sospendersi mentre la coda scorreva. Senzawait, i produttori avrebbero girato sucount == capacity, consumando CPU; con esso, dormono finché il consumatore non segnala. - Il
notifyAllè stato chiamato sullo stesso lock tenuto sia dai produttori che dai consumatori. Questo è l'intero meccanismo di coordinazione: un monitor, esclusione mutua e segnalazione, con il ciclowhileche intercetta qualsiasi sveglio non pertinente. - Il
waitfinale fuori dasynchronizedha lanciatoIllegalMonitorStateExceptionimmediatamente. Questa è l'applicazione della regola da parte della JVM: si può attendere/notificare solo su un monitor che si possiede attualmente. Se si vede questa eccezione, il percorso del codice è arrivato awaitsenza passare prima persynchronized. - La stessa struttura — buffer limitato, esclusione mutua, segnale ad ogni cambio di stato — è ciò che
ArrayBlockingQueuefa internamente, tranne che usa dueCondition(una per "non pieno," una per "non vuoto") invece di un uniconotifyAll. Questo è il modo corretto di scrivere questo in produzione; la versionewait/notifyAllè il meccanismo sottostante su cui ogni classe di livello superiore è costruita.
Cosa c'è dopo
Il prossimo capitolo, Java Deadlock, esamina la modalità di fallimento che rende il locking delicato in primo luogo — due thread che ognuno detiene ciò che l'altro vuole — e le strategie per prevenirlo.