Comunicazione tra Thread in Java

Concetti Chiave
  • Comunicazione tra Thread: Java fornisce i metodi wait(), notify(), e notifyAll() per la comunicazione tra thread, evitando il polling e migliorando l'efficienza.
  • Sincronizzazione: Questi metodi devono essere chiamati in un contesto synchronized, garantendo che solo un thread alla volta possa accedere a una risorsa condivisa.
  • Gestione dei Risvegli Spuri: È importante utilizzare un loop per verificare le condizioni dopo un risveglio, poiché i thread in attesa possono essere risvegliati in modo spurio, cioè senza che notify() o notifyAll() siano stati chiamati.

Comunicazione tra Thread

Gli esempi della lezione precedente hanno bloccato incondizionatamente altri thread dall'accesso asincrono a certi metodi.

Questo uso dei monitor impliciti negli oggetti Java è potente, ma è possibile ottenere un livello di controllo più sottile attraverso la comunicazione tra thread. Come vedremo, questo è particolarmente facile in Java.

Come discusso in precedenza, il multithreading sostituisce la programmazione basata su loop di eventi dividendo i compiti in unità discrete e logiche. I thread forniscono anche un beneficio secondario: eliminano il polling.

Il polling è solitamente implementato da un loop che viene utilizzato per controllare ripetutamente una condizione. Una volta che la condizione è vera, viene intrapresa l'azione appropriata. Questo spreca tempo di CPU. Ad esempio, consideriamo il classico problema di accodamento, dove un thread sta producendo alcuni dati e un altro li sta consumando. Per rendere il problema più interessante, supponiamo che il produttore debba aspettare finché il consumatore non ha finito prima di generare più dati. In un sistema di polling, il consumatore sprecherebbe molti cicli di CPU mentre aspetta che il produttore produca. Una volta che il produttore ha finito, inizierebbe il polling, sprecando più cicli di CPU aspettando che il consumatore finisca, e così via. Chiaramente, questa situazione è indesiderabile.

Per evitare il polling, Java include un elegante meccanismo di comunicazione tra processi tramite i metodi wait(), notify() e notifyAll(). Questi metodi sono implementati come metodi final in Object, quindi tutte le classi li hanno.

Tutti e tre i metodi possono essere chiamati solo da un contesto synchronized. Anche se concettualmente avanzate dal punto di vista informatico, le regole per utilizzare questi metodi sono in realtà abbastanza semplici:

  • wait() dice al thread chiamante di rinunciare al monitor e andare a dormire finché qualche altro thread entra nello stesso monitor e chiama notify() o notifyAll().
  • notify() risveglia un thread che ha chiamato wait() sullo stesso oggetto.
  • notifyAll() risveglia tutti i thread che hanno chiamato wait() sullo stesso oggetto. Uno dei thread otterrà l'accesso.

Questi metodi sono dichiarati all'interno di Object, come mostrato qui:

final void wait() throws InterruptedException
final void notify()
final void notifyAll()

Esistono forme aggiuntive di wait() che permettono di specificare un periodo di tempo da aspettare.

Prima di lavorare attraverso un esempio che illustra la comunicazione tra thread, deve essere chiarito un punto importante. Anche se wait() normalmente aspetta finché notify() o notifyAll() viene chiamato, c'è una possibilità che in casi molto rari il thread in attesa potrebbe essere risvegliato a causa di un risveglio spurio. In questo caso, un thread in attesa riprende senza che notify() o notifyAll() sia stato chiamato. In sostanza, il thread riprende senza una ragione apparente.

A causa di questa remota possibilità, la documentazione API di Java raccomanda che le chiamate a wait() dovrebbero avvenire all'interno di un loop che controlla la condizione su cui il thread sta aspettando. L'esempio che vedremo tra poco mostra questa tecnica.

Esempio di uso di wait e notify

Ora lavoriamo attraverso un esempio che usa wait() e notify().

Per iniziare, consideriamo il seguente programma di esempio che implementa incorrettamente una forma semplice del problema produttore/consumatore. È composto da quattro classi: C, la coda che stiamo cercando di sincronizzare; Produttore, l'oggetto thread che sta producendo voci della coda; Consumatore, l'oggetto thread che sta consumando voci della coda; e PC, la piccola classe che crea la singola C, Produttore e Consumatore, ossia la classe principale che avvia il programma.

// Un'implementazione incorretta di un produttore e consumatore.

// Questa classe rappresenta una coda condivisa.
// Per semplicità, la coda contiene un singolo intero.
class C {

    // Numero da mettere nella coda.
    int n;

    // Metodo ottieni() per leggere (consumare) il valore dalla coda.
    synchronized int ottieni() {
        System.out.println("Ottenuto: " + n);
        return n;
    }

    // Metodo metti() per scrivere (produrre) un valore nella coda.
    synchronized void metti(int n) {
        this.n = n;
        System.out.println("Messo: " + n);
    }

}

// Classe Produttore che implementa Runnable per produrre dati.
class Produttore implements Runnable {

    // Riferimento alla coda condivisa.
    C c;

    // Thread per il produttore.
    Thread t;

    // Costruttore che accetta un oggetto C.
    Produttore(C c) {
        this.c = c;
        t = new Thread(this, "Produttore");
    }

    // Metodo run() che esegue il thread del produttore.
    public void run() {
        int i = 0;
        while (true) {
            c.metti(i++);
        }
    }
}

// Classe Consumatore che implementa Runnable per consumare dati.
class Consumatore implements Runnable {

    // Riferimento alla coda condivisa.
    C c;

    // Thread per il consumatore.
    Thread t;

    // Costruttore che accetta un oggetto C.
    Consumatore(C c) {
        this.c = c;
        t = new Thread(this, "Consumatore");
    }

    // Metodo run() che esegue il thread del consumatore.
    public void run() {
        while (true) {
            c.ottieni();
        }
    }
}

// Classe principale che avvia il programma.
// Questa classe contiene il metodo main.
// Nota: Il nome del file deve essere PC.java per compilare correttamente.
class PC {

    public static void main(String[] args) {

        // Crea un'istanza della coda condivisa.
        C c = new C();

        // Crea un produttore e un consumatore.
        Produttore p = new Produttore(c);
        Consumatore cons = new Consumatore(c);

        // Avvia i thread.
        p.t.start();
        cons.t.start();

        System.out.println("Premi Control-C per fermare.");
    }

}

Anche se i metodi metti() e ottieni() su C sono sincronizzati, niente impedisce al produttore di superare il consumatore, né niente impedirà al consumatore di consumare lo stesso valore della coda due volte.

Quindi, otteniamo l'output errato mostrato qui (l'output esatto varierà con la velocità del processore e il carico di lavoro):

Messo: 1
Ottenuto: 1
Ottenuto: 1
Ottenuto: 1
Ottenuto: 1
Ottenuto: 1
Messo: 2
Messo: 3
Messo: 4
Messo: 5
Messo: 6
Messo: 7
Ottenuto: 7

Come si può vedere, dopo che il produttore ha messo 1, il consumatore ha iniziato la propria esecuzione e ha ottenuto lo stesso 1 cinque volte di fila. Poi, il produttore ha ripreso e ha prodotto da 2 a 7 senza lasciare al consumatore una possibilità di consumarli.

Il modo corretto di scrivere questo programma in Java è usare wait() e notify() per segnalare in entrambe le direzioni, come mostrato qui:

// Un'implementazione corretta di un produttore e consumatore.

// Questa classe rappresenta una coda condivisa.
// Per semplicità, la coda contiene un singolo intero.
// Questa versione usa wait() e notify() per sincronizzare l'accesso.
class C {

    // Numero da mettere nella coda.
    int n;

    // Variabile per tenere traccia se un valore è stato impostato.
    // Questo è usato per evitare che il produttore metta
    // un valore quando il consumatore non ha ancora ottenuto
    // il valore precedente.
    // Inizialmente, non c'è nessun valore impostato.
    boolean valoreImpostato = false;

    // Metodo ottieni() per leggere (consumare) il valore dalla coda.
    synchronized int ottieni() {

        // Se non c'è un valore impostato, aspetta.
        // NB: Dopo che il thread è risvegliato, deve controllare di nuovo
        // se il valore è stato impostato,
        // poiché potrebbe essere stato risvegliato in modo spurio.
        // Questo è il motivo per cui c'è un loop while.
        while(!valoreImpostato) {
            try {
                wait();
            } catch(InterruptedException e) {
                System.out.println("InterruptedException catturata");
            }
        }

        // Ora abbiamo un valore impostato, quindi possiamo ottenerlo.
        System.out.println("Ottenuto: " + n);

        // Resetta il flag valoreImpostato per indicare
        // che il consumatore ha ottenuto il valore.
        // Questo permette al produttore di mettere un nuovo valore.
        valoreImpostato = false;

        // Notifica il produttore che può mettere un nuovo valore.
        // Questo risveglia il thread del produttore se è in attesa.
        // Se il produttore non è in attesa, non succede nulla.
        notify();

        // Restituisce il valore ottenuto.
        return n;
    }

    // Metodo metti() per scrivere (produrre) un valore nella coda.
    synchronized void metti(int n) {

        // Se c'è già un valore impostato, aspetta.
        // Anche qui, abbiamo adoperato un loop while
        // per gestire i risvegli spurii.
        while(valoreImpostato) {
            try {
                wait();
            } catch(InterruptedException e) {
                System.out.println("InterruptedException catturata");
            }
        }

        // Ora possiamo mettere un nuovo valore nella coda.
        this.n = n;

        // Imposta il flag valoreImpostato a true
        // per indicare che c'è un nuovo valore pronto.
        valoreImpostato = true;

        // Stampa il valore messo nella coda.
        System.out.println("Messo: " + n);

        // Notifica il consumatore che c'è un nuovo valore pronto.
        // Questo risveglia il thread del consumatore se è in attesa.
        // Se il consumatore non è in attesa, non succede nulla.
        notify();
    }

}

// Classe Produttore che implementa Runnable per produrre dati.
class Produttore implements Runnable {

    // Riferimento alla coda condivisa.
    C c;

    // Thread per il produttore.
    Thread t;

    // Costruttore che accetta un oggetto C.
    Produttore(C c) {
        this.c = c;
        t = new Thread(this, "Produttore");
    }

    // Metodo run() che esegue il thread del produttore.
    public void run() {
        int i = 0;
        while(true) {
            c.metti(i++);
        }
    }

}

// Classe Consumatore che implementa Runnable per consumare dati.
class Consumatore implements Runnable {

    // Riferimento alla coda condivisa.
    C c;

    // Thread per il consumatore.
    Thread t;

    // Costruttore che accetta un oggetto C.
    Consumatore(C c) {
        this.c = c;
        t = new Thread(this, "Consumatore");
    }

    // Metodo run() che esegue il thread del consumatore.
    public void run() {
        while(true) {
            c.ottieni();
        }
    }

}

// Classe principale che avvia il programma.
// Questa classe contiene il metodo main.
// Nota: Il nome del file deve essere PCCorretto.java per compilare correttamente
class PCCorretto {

    public static void main(String[] args) {
        // Crea un'istanza della coda condivisa.
        C c = new C();

        // Crea un produttore e un consumatore.
        Produttore p = new Produttore(c);
        Consumatore cons = new Consumatore(c);

        // Avvia i thread.
        p.t.start();
        cons.t.start();
        System.out.println("Premi Control-C per fermare.");
    }

}

All'interno di ottieni(), viene chiamato wait().

Questo causa la sospensione della sua esecuzione finché Produttore non notifica che alcuni dati sono pronti. Quando questo accade, l'esecuzione all'interno di ottieni() riprende.

Dopo che i dati sono stati ottenuti, ottieni() chiama notify(). Questo dice a Produttore che è ok mettere più dati nella coda.

Allo stesso modo, all'interno di metti(), wait() sospende l'esecuzione finché Consumatore non ha rimosso l'elemento dalla coda. Quando l'esecuzione riprende, il prossimo elemento di dati viene messo nella coda, e notify() viene chiamato. Questo dice a Consumatore che ora dovrebbe rimuoverlo.

Ecco un po' di output da questo programma, che mostra il comportamento sincrono pulito:

Messo: 1
Ottenuto: 1
Messo: 2
Ottenuto: 2
Messo: 3
Ottenuto: 3
Messo: 4
Ottenuto: 4
Messo: 5
Ottenuto: 5

Come si può vedere, il produttore e il consumatore lavorano insieme in modo sincrono. Il produttore mette un elemento nella coda e poi aspetta che il consumatore lo ottenga prima di mettere il prossimo elemento. Il consumatore ottiene un elemento dalla coda e poi aspetta che il produttore ne metta un altro prima di continuare.