Articolo originale

Concorrenza

Gli utenti di computer danno per scontato che i loro sistemi possono fare più di una cosa alla volta. Essi assumono che essi possono continuare a lavorare in un word processor, mentre altre applicazioni scaricano i file, gestiscono la coda di stampa e un flusso audio. Anche una singola applicazione potrebbe fare più di una cosa alla volta. Ad esempio, l'applicazione che gestisce lo streaming audio deve leggere contemporaneamente i pacchetti ricevuti dalla rete, decomprimere il contenuto, gestire la riproduzione, e aggiornare il suo display. Anche il word processor deve essere sempre pronto a rispondere a eventi generati dalla tastiera e dal mouse, non importa quanto sia occupato a riformattare il testo o aggiornare la visualizzazione. Il Software che può fare queste cose è detto software concorrente.

Thread Iniziali

Ogni programma ha una serie di Thread da cui inizia la logica dell'applicazione. In un programma standard, c'è solo un thread: il thread che richiama il metodo main della classe. Nelle applet i thread iniziali sono quelli che costruiscono l'oggetto applet e richiamano i suoi metodi init e start; queste azioni possono verificarsi in un unico thread, o su due o tre thread diversi, a seconda dell'implementazione. Questi thread sono detti Iniziali.

Nelle applicazioni basate sulla libreria Swing, i thread iniziali non hanno molto da fare. Il loro lavoro essenziale consiste nel creare un oggetto Runnable che inizializza l'interfaccia grafica e schedulare l'oggetto per l'esecuzione sull'event dispatch thread. Dopo aver creato la GUI, l'esecuzione del programma è determinata principalmente dagli eventi sulla GUI, ciascuno dei quali provoca l'esecuzione di un breve compito sull'event dispatch thread. Il codice dell'applicazione può schedulare ulteriori processi sull'event dispatch thread (se si completa rapidamente, in modo da non interferire con l'elaborazione dell'evento) o un thread di lavoro (per le attività di lunga durata).

Un thread Iniziale schedula il processo di creazione della GUI invocando javax.swing.SwingUtilities.invokeLater o javax.swing.SwingUtilities.invokeAndWait. Entrambi questi metodi prendono un solo argomento: il Runnable che definisce la nuova attività. La loro unica differenza è indicata dai loro nomi: invokeLater semplicemente schedula il processo e ritorna; invokeAndWait attende che il processo termini prima di tornare.

Esempio:

SwingUtilities.invokeLater(new Runnable() {
    public void run() {
        createAndShowGUI();
    }
});

In una applet, il processo di creazione della GUI deve essere lanciato dal metodo init usando invokeAndWait; altrimenti init potrebbe ritornare prima che la GUI venga creata, creando errori nel browser che ospita l'applet. In altri tipi di programmi, il processo di scheduling per la creazione della GUI è l'ultima operazione che il thread Iniziale deve fare, quindi non ha importanza se si richiama invokeLater o invokeAndWait.

Perché il thread iniziale noncrea direttamente l'interfaccia grafica? Perché quasi tutto il codice che crea o interagisce con componenti Swing deve essere eseguito sull'event dispatch thread.

Processi e Thread

Nella programmazione concorrente, ci sono due unità base di esecuzione: processi e thread. Nel linguaggio di programmazione Java, la programmazione concorrente è in gran parte sviluppata con i thread.

Un sistema di elaborazione ha normalmente molti processi e thread attivi. Questo è vero anche in sistemi che hanno solo un singolo core di esecuzione, e quindi in qualsiasi istante hanno un solo thread da eseguire. Il tempo di elaborazione per un singolo core è condiviso tra processi e thread attraverso una funzione del sistema operativo denominata time slicing.

Sta diventando sempre più comune per i sistemi di elaborazione avere più processori oppure un singolo processore con più core di esecuzione. Questo migliora notevolmente la capacità di un sistema di eseguire simultaneamente più processi e più thread - ma la concorrenza è possibile anche su sistemi dotati di un singolo processore.

Processi

Un processo ha un ambiente di esecuzione autonomo. Un processo ha insieme privato di risorse durante la sua esecuzione; in particolare, ogni processo ha un proprio spazio di memoria.

I processi sono spesso visti come sinonimo di programmi o applicazioni. Tuttavia, ciò che l'utente vede come una singola applicazione può infatti essere una serie di processi cooperanti. Per facilitare la comunicazione tra i processi, la maggior parte dei sistemi operativi supportano il modello Inter Process Communication (IPC) di risorse, come canali e socket. IPC è utilizzato non solo per la comunicazione tra processi sullo stesso sistema, ma anche per processi su sistemi diversi.

La maggior parte delle implementazioni della macchina virtuale Java vengono eseguite come un unico processo. Un'applicazione Java può creare processi aggiuntivi utilizzando un oggetto ProcessBuilder. Applicazioni multiprocesso sono troppo avanzate per essere discusse in questa sezione.

Thread

I Thread sono considerati processi leggeri. Sia i processi che i thread forniscono un ambiente di esecuzione, ma la creazione di un nuovo thread richiede meno risorse rispetto alla creazione di un nuovo processo.

I thread esistono all'interno di un processo - ogni processo ne ha almeno uno. I Thread condividono le risorse del processo, inclusa la memoria e i file aperti. Questo rende efficiente, ma potenzialmente problematica, la comunicazione.

L'esecuzione Multi-thread è una caratteristica essenziale della piattaforma Java. Ogni applicazione ha almeno un thread - o più, se si contano i thread di sistema" che si occupano ad esempio, della gestione della memoria e della gestione della comunicazione. Ma dal punto di vista del programmatore dell'applicazione, si inizia con un solo thread, chiamato il thread principale. Questo thread è in grado di creare thread aggiuntivi.

Oggetti Thread

Ogni thread è associato ad una istanza della classe Thread. Esistono due strategie per usare gli oggetti Thread e creare una applicazione concorrente.


Definire ed avviare un Thread

Un'applicazione che crea un'istanza di un Thread deve fornire il codice che verrà eseguito in questo thread. Ci sono due modi per farlo:

  1. Fornire un oggetto Runnable. L'interfaccia Runnable definisce il solo metodo run, il cui scopo è di contenere il codice da eseguire nel thread. L'oggetto Runnable viene passato al costruttore del Thread, come illustrato nel seguente esempio:

    Creare un nuovo progetto Java Application. Aggiungere un Form. Collocare un pulsante sul form.

    Tasto destro sul nome del progetto e scegliere la voce new → Java Class …. Dare il nome "Saluto" alla classe e inserirla nello stesso package.

    Completare la dichiarazione della classe:

    public class Saluto implements Runnable {

      public void run() {

        System.out.println("Saluti dal thread!");

      }

    }

    Creare un gestore di evento associato al pulsante e completarlo come segue:

      (new Thread(new Saluto())).start();

    Dopo aver lanciato l'applicazione, premendo il pulsante, si legge il messaggio nella finestra di output.

    Per mostrare l'output dell'applicazione in un Message Box, modificare il metodo run come indicato di seguito:

    public class Saluto implements Runnable {

      public void run() {

        JOptionPane.showMessageDialog(null, "Grazie per avermi chiamato, Ciao!");

      }

    }

    Mediante la voce "Fix Imports" del menu contestuale aperto con il tasto destro su "JOptioPane" viene corretto l'errore "Identificatore sconosciuto".

  2. Derivare una classe dalla classe Thread. La classe Thread implementa Runnable, ma il suo metodo run è vuoto. Lo sviluppatore dell'applicazione deve ridefinire il metodo run, come nel seguente esempio:

    public class Saluto extends Thread {

      public void run() {

        System.out.println("Saluti dal thread!");

      }

    }

In entrambi gli esempi viene richiamato il metodo Thread.start allo scopo di avviare il nuovo thread.

Quale di questi metodi si dovrebbe utilizzare? Il primo metodo, che impiega un oggetto Runnable, è più generale, perchè l'oggetto Runnable può ereditare anche una classe diversa da Thread. Il secondo metodo è più facile da usare in applicazioni semplici, ma è limitato dal fatto che la classe deve essere derivata da Thread.

La classe Thread definisce alcuni metodi utili per la gestione del thread. Questi includono metodi statici, che forniscono informazioni o influenzano lo stato del thread che sta richiamando il metodo. Gli altri metodi sono richiamabili da altri thread coinvolti nella gestione del thread e dell'oggetto Thread.


Scheduling dei Thread

Il seguente esempio mostra come sia imprevedibile stabilire l'ordine di esecuzione delle operazioni dei thread e quindi come, in una elaborazione concorrente, i risultati potrebbero essere diversi ad ogni esecuzione del programma.

Sul frame del progetto inserire un pulsante ed una text area.

Associare un gestore di evento al pulsante. Prima di scrivere le operazioni del gestore, inserire la seguente dichiarazione di classe prima del gestore di evento:

public class ioConto extends Thread {

  private int inizio;

  private int fine;

  public ioConto(int da, int a) {

    inizio=da;

    fine=a;

  }

  public void run(){

    jTextArea1.append("\n"+this.getName() + " avviato ... ");

    for (int i=inizio; i<=fine; i++) {

      jTextArea1.append(i+" ");

    }

    jTextArea1.append("\n"+this.getName() + " finito.");

  }

}

La classe ioConto, derivata dalla classe Thread, dichiara due campi membro: inizio e fine.

Il costruttore della classe assegna il valore iniziale a questi campi.

Il metodo run scrive nella Text Area il nome del Thread (in questo caso un nome di default perchè non è stato assegnato nessun valore alla proprietà name del Thread). Poi, in un ciclo, incrementa una variabile locale e ne scrive il valore nella Text Area

Scrivere il gestore dell'evento associato al pulsante:

ioConto Th1 = new ioConto(1, 100);

ioConto Th2 = new ioConto(200, 300);

Th1.start();

Th2.start();

Vengono create due istanze della classe ioConto e vengono avviate.

Quando si esegue l'applicazione, dopo aver premuto il pulsante, nella Text Area, si leggono i valori stampati dai due thread. Si nota che i risultati di uno sono intercalati tra i risultati dell'altro e, ripetendo l'esecuzione, l'ordine di esecuzione dei due thread risulta diverso dal precedente.


Mettere in pausa il Thread con Sleep

Il metodo Thread.sleep causa la sospensione dell'esecuzione del thread corrente per un certo intervallo di tempo. In questo modo il tempo della CPU viene reso disponibile agli altri thread dell'applicazione o ad altre applicazioni in esecuzione sul computer. Il metodo sleep può anche essere usato per sincronizzarsi con un altro thread la cui esecuzione avviene rispettando una ben precisa temporizzazione.

Esistono due versioni del metodo sleep: uno specifica la durata della pausa in millsecondi, l'altro la specifica in nanosecondi. Non si garantisce la precisione di questi tempi, perchè dipendono anche dalle operazioni svolte dal sistema operativo durante la pausa. Inoltre, la durata della pausa può essere terminata da una interruzione.

Il seguente esempio usa il metodo sleep per generare messaggi a intervalli di quattro secondi:

Aggiungere un pulsante al form. Creare una nuova classe. Denominarla Pausa ed inserirla nello stesso package dell'applicazione. Completare la dichiarazione della classe:

public class Pausa {

  public static void Pausa() throws InterruptedException { /* il costruttore è vuoto */ }

  public void saggezza() throws InterruptedException{

    String proverbi[];

    proverbi = new String[4];

    proverbi[0]= "Tanto va la gatta al lardo che ci lascia lo zampino";

    proverbi[1] = "meglio un uovo oggi che la gallina domani";

    proverbi[2] = "a caval donato non si guarda in bocca";

    proverbi[3] = "Scherza con i fanti ma lascia stare i santi";

    for (int i = 0; i < proverbi.length; i++) {

      Thread.sleep(4000); //Pausa di 4 secondi

      System.out.println(proverbi[i]);

    }

  }

}

Associare un gestore di evento al pulsante:

  Pausa p = new Pausa();

  p.saggezza();

Viene segnalato un errore in corrispondenza della linea p.saggezza. Fare clic sul simbolo rosso e scegliere la voce: "Surround statement with try … catch".

Si noti che il metodo saggezza() dichiara InterruptedException. Questa è un'eccezione che il metodo sleep segnala quando un altro thread interrompe il thread corrente, durante la fase di sleep. Dal momento che questa applicazione non ha definito un altro thread per provocare l'interruzione, non ci si preoccupa di catturare InterruptedException.


Interruzioni

Una interruzione indica al thread di sospendere le sue operazioni ed eseguire altre operazioni. È compito del programmatore decidere esattamente come un thread risponde a un interruzione.

Un thread invia una interruzione richiamando il metodo interrupt() dell'oggetto thread deve essere sospeso. Affinchè il meccanismo di interruzione possa funzionare correttamente, il thread interrotto deve gestire la propria interruzione.

Gestire l'interruzione.

Come fa un thread a gestire la propria interruzione? Dipende da quello che sta facendo attualmente. Se il thread richiama frequentemente metodi che possono generano InterruptedException, esce semplicemente dal metodo run dopo aver catturato l'eccezione. Ad esempio, si supponga che il ciclo di messaggi centrale nell'esempio "Pausa" si trovi nel metodo run dell'oggetto Runnable di un thread. Quindi potrebbe essere modificato come segue per supportare gli interrupt:

public class Pausa implements Runnable {

  public void Pausa() throws InterruptedException { /* il costruttore è vuoto */ }

  public void saggezza() throws InterruptedException{

    String proverbi[];

    proverbi = new String[4];

    proverbi[0]= "Tanto va la gatta al lardo che ci lascia lo zampino";

    proverbi[1] = "meglio un uovo oggi che la gallina domani";

    proverbi[2] = "a caval donato non si guarda in bocca";

    proverbi[3] = "Scherza con i fanti ma lascia stare i santi";

    for (int i = 0; i < proverbi.length; i++) {

      try {

        Thread.sleep(4000);      //Pausa di 4 secondi

      } catch (InterruptedException e) {

        System.out.println("interrotto");

        return;

      }

    System.out.println(proverbi[i]);

    }

  }

  public void run() {

    try {

      saggezza();

    } catch (InterruptedException ex) {

      Logger.getLogger(Pausa.class.getName()).log(Level.SEVERE, null, ex);

    }

  }

}

Nota: Le linee try … catch possono essere inserite automaticamente scrivendo solo la linea contenute all'interno della sezione try (nell'esempio saggezza()) poi, facendo clic sulla nota di errore a margine della riga, scegliere "surround statement with try … catch

Modificare il gestore di evento associato al pulsante che crea ed avvia il thread:

  Thread p= new Thread(new Pausa());

  p.start();

  try {

    Thread.sleep(8000);

  } catch (InterruptedException ex) {

    Logger.getLogger(Scadenza.class.getName()).log(Level.SEVERE, null, ex);

  }

  p.interrupt();

Per generare l'interruzione, si suppone che il gestore dell'evento associato al pulsante, che ha avviato il thread "Pausa", compia altre operazioni e, al verificarsi di un evento, poi genera una interruzione verso il thread "Pausa". L'evento viene simulato, mettendo il gestore di evento associato al pulsante in pausa per 8 secondi, in modo che il thread "Pausa" scriva almeno due messaggi e poi riceve l'interruzione.

Molti metodi che gestiscono InterruptedException, come sleep, devono solo annullare la loro operazione corrente e terminare immediatamente la loro esecuzione appena ricevono una interruzione.

Cosa succede se un thread resta per un lungo periodo di tempo senza invocare un metodo che gestisce InterruptedException? Deve interrogare periodicamente Thread.interrupted, che restituisce true se un interrupt è stato ricevuto. Per esempio:

Esempio da non inserire nell'applicazione

for (int i = 0; i < inputs.length; i++) {

  elabora(inputs[i]);

  if (Thread.interrupted()) {

    // Interrotto: nient'altro da elaborare.

    return;

  }

}

In questo semplice esempio, il codice interroga la flag di interrupt ricevuto ed esce se questa indica il valore true. In applicazioni più complesse sicuramente bisogna rispondere all'interruzione:

Esempio da non inserire nell'applicazione

if (Thread.interrupted()) {

  throw new InterruptedException();

}

In questo modo il codice di gestione dell'interruzione può essere posto nella sezione catch.

La flag di stato dell'interruzione

Il meccanismo di interruzione viene implementato utilizzando una flag interna nota come lo stato di interrupt. Richiamando Thread.interrupt si imposta questa flag. Quando un thread verifica la presenza di una interruzione, richiamando il metodo statico Thread.interrupted, lo stato della flag di interruzione viene azzerato. Il metodo non statico isInterrupted, che viene utilizzato da un thread per interrogare lo stato della flag di interruzione di un altro, non cambia lo stato della flag di interrupt.

Di regola, ogi metodo che esce gestendo una InterruptedException azzera la flag di interrupt. Può sempre succedere, però, che la flag venga nuovamente portata allo stato true da un altro thread che genera interrupt.


il metodo join

Il metodo join permette ad un thread di aspettare che un altro thread termini. Nel seguente esempio, un thread avvia il thread p poi, con p.join(), resta in attesa che il thread p termini, prima di proseguire.

Modificare il gestore di evento associato al pulsante:

  Thread p= new Thread(new Pausa());

  p.start();

  System.out.println("ho avviato un thread e aspetto che termini prima di proseguire");

  p.join();

  System.out.println("il thread che ho avviato è terminato, proseguo");

Viene segnalato un errore sulla linea join. Clic sul simbolo e scegliere "Surround statement with try … catch

Avviare l'applicazione ed osservare i messaggi nella finestra di output.

il metodo join permette di specificare anche un tempo di attesa massimo. Esempio: p.join(1000);


Processi Concorrenti e Sincronizzazione.

I thread comunicano principalmente condividendo l'accesso ai campi. Questa forma di comunicazione è estremamente efficiente, ma si possono verificare due possibili errori: interferenza tra i thread ed incoerenza dei dati. Per evitare questi errori si ricorre alla sincronizzazione.

Tuttavia, la sincronizzazione potrebbe provocare lo stallo.

Nel seguente esempio si simulano le operazioni in un super mercato: due cassiere vendono contemporaneamente 5 articoli dello stesso prodotto. Ci si aspetta che la quantità di articoli rimasti in deposito sia diminuita di 10 unità.

L'operazione di aggiornamento è volutamente ampliata:

  1. Un processo legge la quantità di scorta, che è una variabile condivisa tra i processi;

  2. Sottrae la quantità venduta ed ottiene la nuova quantità di scorta;

  3. La nuova quantità di scorta viene salvata nella variabile condivisa.

C'è comunque da considerare l'eventualità che l'operazione di aggiornamento della quantità di scorta venga interrotta. Ad esempio, se dopo l'operazione numero 1 arriva un'interruzione Hardware, i due processi vengono interrotti e mantengono nella loro variabile temporanea lo stesso valore di quantità di scorta. Quando si sarà completato il servizio dell'interruzione, entrambi i processi riprendono l'esecuzione, ognuno sottrae una quantità di articoli venduti e poi, ne salva il risultato.

Poichè la variabile è condivisa verrà aggiornata prima da un processo e poi da un altro processo, che quindi sovrascriverà il risultato del primo processo.

Anche se non avviene una interruzione che ritarda un processo, l'ordine con cui lo scheduler esegue le operazioni è imprevedibile. Potrebbe essere il seguente:

  1. Thread A: Legge il totale, ad esempio 10 e lo trasferisce in una sua variabile locale.

  2. Thread B: Legge il totale, ancora uguale a 10 e lo trasferisce in una sua variabile locale.

  3. Thread A: Incrementa il valore della sua copia del totale di 5, quindi la sua copia contiene 15.

  4. Thread B: Decrementa il valore della sua copia del totale di 5, che quindi diventa 5.

  5. Thread A: Memorizza la sua variabile in totale, che quindi adesso vale 15.

  6. Thread B: Memorizza la sua variabile in totale, che quindi adesso vale 5.

Il Thread B ha sovrascritto il valore che aveva scritto il Thread A.

Per evitare il problema, le tre operazioni devono essere eseguite in forma indivisibile, cioè se un processo ha iniziato l'operazione di aggiornamento, un altro processo che intende eseguire la stessa operazione deve aspettare il termine dell'altro processo.

Nello stesso progetto NetBeans incorporare le classi seguenti prima del gestore di evento associato al pulsante.

Inserire la seguente dichiarazione della classe Deposito:

public class Deposito {

    private int totale;

    Deposito(int valoreIniziale) {

      totale = valoreIniziale;

    }

     /* synchronized */ void togli(int quantita) {

      int temp = totale;

      jTextArea1.append("\nArticoli in deposito: " + totale + " Articoli prelevati " + quantita);

      /* Simulazione.interruptHW(); */

      totale = temp - quantita;

      jTextArea1.append("\nArticoli di scorta in deposito: " + totale);

    }

}

Linea 2: la proprietà totale mantiene la quantità di articoli disponibili in magazzino;

Linee 3÷5: il costruttore assegna il valore iniziale alla proprietà della classe;

Linea 6: il metodo togli riceve come parametro il numero di articoli venduti che si devono sottrarre dal numero totale di articoli disponibili;

Linea 7: all'interno del metodo togli si legge, in una variabile temporanea, il numero degli articoli disponibili;

Linea 8: si simula un'interruzione delle operazioni;

Linea 9: si calcola il nuovo totale e lo si memorizza.

La parola synchronized e la linea 8 devono restare racchiuse tra i segni di commento, allo scopo di verificare che l'aggiornamento appare corretto, poi, come secondo esperimento si toglie il commento alla linea 8 per simulare un'interruzione che porta in stato di attesa due processi, iniziati contemporaneamente. Infine, per correggere questo caso, si toglie il commento alla parola synchronized, che ha lo scopo di impedire ad un processo di richiamare il metodo togli se è in esecuzione per un altro processo.

Incorporare nella classe del Frame una seconda classe e chiamarla Cassiere.

Dichiarazione della classe Cassiere:

public class Cassiere extends Thread {

    private String nome;

    private Deposito scorta;

    private static final int NUMOPERAZIONI = 1;

    private static final int QTAARTICOLI = 5;

    Cassiere(String nm, Deposito qta) {

      nome = nm;

      scorta = qta;

    }

    public void run() {

      jTextArea1.append(

              "\nCassiere " + nome + " sta per vendere " + QTAARTICOLI + " matite"

      );

      scorta.togli(QTAARTICOLI);

      jTextArea1.append(

              "Cassiere " + nome + " ha venduto " + QTAARTICOLI + " matite"

      );

      jTextArea1.append(

              "\nCassiere " + nome + " ha venduto in totale " + NUMOPERAZIONI*QTAARTICOLI + " matite"

      );

    }

}

Linea 1: la classe è derivata dalla classe Thread;

Linee 2 e 3: il nome del cassiere e il riferimento al deposito sono proprietà della classe;

Linee 4 e 5: vengono definite due costanti;

Linee 6÷9: il costruttore inizializza i campi membro della classe;

Linee 10÷15: il metodo run verrà richiamato quando il thread verrà avviato.

Linea 12: tra le operazioni di stampa del metodo run viene richiamato il metodo togli dell'oggetto scorta.

Completare il gestore dell'evento richiamato dal pulsante:

// TODO code application logic here

Deposito dp = new Deposito(10);

Cassiere c1 = new Cassiere("Wanda", dp);

Cassiere c2 = new Cassiere("Vanessa", dp);

c1.start();

c2.start();

}

Linea 4: la classe contiene il riferimento ad un oggetto di classe Deposito, inizializzato con 10 articoli di scorta.

Linee 5 e 6: la classe contiene anche i riferimenti a due oggetti di classe Cassiere, inizializzati con il nome dell'impiegato e con il riferimento all'oggetto di classe Deposito, che quindi è condiviso.

Linee 7 e 8: i thread associati ai due cassieri vengono avviati, cioè viene richiamato il metodo run.

Creare un nuova classe e denominarla Simulazione:

public class Simulazione {

  public static void interruptHW() {

    if (Math.random() < 0.5)

      try {

        Thread.sleep(200);

      } catch (InterruptedException e) {

    }

  }

}

La classe Simulazione contiene solo il metodo interruptHW che, con probabilità 50%, introduce una pausa di 200 ms nell'esecuzione.


Modalità di verifica del programma:

  1. Avviare l'applicazione e premere il pulsante.

  2. Osservare i risultati, ripetere la pressione del pulsante e confrontare i risultati delle due esecuzioni. I risultati potrebbero non essere corretti. Si tolgano i caratteri di commento intorno all'istruzione di chiamata del metodo interruptHW della classe Simulazione. Si deve notare che l'aggiornamento del numero di articoli di scorta risulta sbagliato.

  3. Togliere il commento alla parola synchronized ed osservare che i risultati sono corretti.

Il problema del Produttore e del Consumatore

Comunicazione tra Processi

In Java i thread possono comunicare tra loro in modo da indicare quando un certo evento o una certa condizione si è verificata.

Nella programmazione tradizionale, il controllo che una certa condizione sia vera o falsa viene effettuato con la tecnica del polling: per esempio un programma resta impegnato a controllare ripetutamente il valore di una certa variabile booleana fino a che questa assume il valore true, e solo a quel punto prosegue nel proprio lavoro.

Un tipico esempio è rappresentato dalle applicazioni basate sul modello Produttore Consumatore, dove un programma genera dei dati, e un altro legge e utilizza i dati prodotti dal primo: per esempio un thread riceve pacchetti di dati dalla rete e un altro li visualizza all'interno della finestra di un browser.

Con la tecnica del polling, il consumatore spenderebbe il proprio tempo con un ciclo while in attesa che il produttore metta a disposizione nuovi dati da elaborare. Il produttore, una volta consegnati i dati al consumatore, a sua volta potrebbe essere costretto a rimanere in attesa che il consumatore finisca di elaborare i dati ricevuti. Tutto ciò provoca un inutile spreco di tempo di CPU.

Un metodo più efficiente consiste nel permettere a un thread di segnalare ad un altro quando un certo evento si è verificato o una certa condizione è diventata vera. In questo modo, il thread in attesa può sospendersi, senza sprecare tempo di CPU.

Java realizza un meccanismo di inter-process communication basato su tre metodi: wait, notify e notifyAll.

Questi metodi possono essere chiamati solo dall'interno di metodi sincronizzati, e possono essere invocati su qualunque oggetto. I tre metodi svolgono le seguenti funzioni:

Molti problemi di programmazione di sistema possono essere affrontati facendo riferimento al modello Produttore-Consumatore per esempio, un programma legge i dati di un conto corrente dal disco affinchè un'applicazione bancaria ne calcoli gli interessi, oppure un thread del sistema operativo rende disponibili aree di memoria dell'elaboratore in modo che altri programmi possano utilizzarli per memorizzare le proprie strutture dati.

Esercizio.

L'applicazione di esempio che si vuole costruire ipotizza che la GUI consenta all'utente di svolgere alcune operazioni, mentre separatamente, il thread del produttore genera dei numeri interi e il thread del consumatore usa i numeri generati dal produttore. Sulla GUI si colloca un pulsante che rende visibile un form che ha il solo scopo di raccogliere i messaggi generati dai due thread e quindi seguire il loro avanzamento.

In questo esempio si ammette che l'area di scambio sia una variabile in grado di contenere un solo dato, anzichè un array o una struttura dinamica: il programma si limita a scambiare un dato per volta, memorizzandolo nella variabile locale unDato di un oggetto, istanza della classe chiamata varCondivisa. Il produttore deposita unDato in varCondivisa e il consumatore lo preleva. Poi il produttore deposita un nuovo dato, e così via.

Creare un nuovo progetto di tipo Java Application denominato ProdCons.

Inserire un Frame nel progetto. Il metodo main() di questo Frame sarà il punto di partenza dell'applicazione.

Inserire un pulsante su questo frame

Fare clic destro sul nome del package, dal menu contestuale, scegliere la voce New → Java JFrame … Assegnare un nome al Frame.

Ripetere l'operazione di creazione di un secondo Frame. Nella casella Class Name scrivere: varCondivisa

Inserire un componente TextArea e un pulsante su questo Frame. Al pulsante assegnare la didascalia "Nascondi" e il gestore di evento:

this.setVisible(false);

Completare la classe con le proprietà e i metodi seguenti:

public class varCondivisa extends javax.swing.JFrame {

  int unDato;

  synchronized int preleva() {

    jTextArea1.append("Preso: " + unDato);

    return unDato;

  }

  synchronized void inserisci(int d) {

    unDato=d;

    jTextArea1.append("Depositato" + d);

  }

}

Nella variabile condivisa varCondivisa è depositato un solo dato numerico; i metodi inserisci e preleva sono synchronized in quanto accedono in modo concorrente alla stessa varCondivisa e vanno evitate interferenze tra essi.

Il metodo inserisci ricopia nella variabile locale unDato il valore del parametro passato d.

Il metodo preleva restituisce il valore numerico in quel momento presente in varCondivisa.

Fare clic destro sul nome del package e, dal menu contestuale, scegliere la voce New → Java Class …

Nella casella Class Name scrivere: Produttore

Il Produttore ha un costruttore che riceve come parametro il riferimento a varCondivisa nella quale i due thread si scambiano i dati e avvia il proprio thread. Il metodo run è un ciclo for che per dieci volte mette un dato (un numero intero) in varCondivisa.

Completare la classe con le proprietà e i metodi seguenti:

public class Produttore implements Runnable {

  varCondivisa v;

  Produttore(varCondivisa datoCondiviso) {

    v = datoCondiviso;

    new Thread(this, "Produttore").start();

  }

  public void run() {

    // produce 10 interi in sequenza

    for (int i=45; i<55; i++) {

      v.inserisci(i);

    }

  }

}

Fare clic destro sul nome del package e, dal menu contestuale, scegliere la voce New → Java Class …

Nella casella Class Name scrivere: Consumatore

Analogamente, al momento della creazione, il Consumatore riceve come parametro lo stesso riferimento a varCondivisa, e il metodo run per dieci volte preleva da varCondivisa quanto depositato dal Produttore.

public class Consumatore implements Runnable {

  varCondivisa v;

  Consumatore(varCondivisa datoCondiviso) {

    v = datoCondiviso;

    new Thread(this, "Consumatore").start();

  }

  public void run() {

    for (int i=0; i<10; i++) {

      v.preleva();

    }

  }

}

Associare un gestore di evento al pulsante presente sul Frame principale. Il gestore di evento crea l'istanza di varCondivisa, un'istanza di Produttore e una di Consumatore e infine rende visibile il Frame:

      gestore di evento richiamato dal pulsante

  varCondivisa datoProd;

  datoProd = new varCondivisa();

  new Produttore(datoProd);

  new Consumatore(datoProd);

  datoProd.setVisible(true);

}

Si osservi che:
il ciclo for del Consumatore potrebbe trovare per due o più volte lo stesso valore numerico in varCondivisa; il ciclo for del Produttore potrebbe mettere in varCondivisa un nuovo valore prima che il Consumatore abbia avuto il tempo di leggere il precedente: il Consumatore perderebbe in questo modo dei dati.

Nello sviluppo dell'esempio non è stato introdotto alcun controllo sul fatto che ogni singolo dato depositato dal Produttore venga sempre prelevato dal Consumatore (modello Produttore-Consumatore senza garanzia di ricezione). Si accetta che il Consumatore possa perdere qualche dato, o che possa prelevare due volte lo stesso dato da varCondivisa. Questo perchè è praticamente impossibile che vi sia una commutazione del contesto da Produttore a Consumatore esattamente a ogni esecuzione dei rispettivi cicli for.

Questo meccanismo è accettabile in tutti quei casi nei quali non è importante che il Consumatore riceva una e una sola volta ogni singolo dato generato dal Produttore.


Modello Produttore-Consumatore con ricezione garantita

Esistono casi nei quali è importante che ogni singolo dato generato dal produttore sia sempre ricevuto una e una sola volta dal consumatore. Si immagini per esempio che il produttore sia un programma che segnala i pagamenti delle rate di un mutuo e il secondo sia la banca che riceve tali pagamenti: in questo caso non ci può essere nè duplicazione nè perdita di dati.

I prossimi due esempi trattano queste situazioni di ricezione garantita secondo due meccanismi: polling e inter-process communication.

Ricezione garantita con la tecnica del polling.

Per garantire che ogni singolo valore prodotto sia consumato una e una sola volta, si può operare aggiungendo al codice un meccanismo di polling, basato su una variabile booleana datoDisponibile:

Implementazione della classe

Modificare la classe. Eliminare

public class varCondivisa {

  int unDato;

  boolean datoDisponibile = false;

  /* synchronized */ int preleva() {

    while(!datoDisponibile) // attende un dato da prelevare

      ; // il ciclo è vuoto

    jTextArea1.append("Preso: " + unDato);

    datoDisponibile = false;

      return unDato;

  }

  /* synchronized */ void inserisci(int d) {

    while (datoDisponibile) // attende che il dato precedente venga prelevato

      ; // il ciclo è vuoto

      unDato=d;

      datoDisponibile = true;

      jTextArea1.append("Depositato: "+d);

  }

}

La sincronizzazione tra i due metodi viene ottenuta tramite la variabile booleana datoDisponibile. Si osservi con attenzione che in questo caso è necessario rimuovere la clausola synchronized dai due metodi, in quanto, in caso contrario, il primo ciclo while che entra in esecuzione non uscirebbe mai dal monitor, e quindi l'altro metodo non avrebbe mai l'opportunità di entrarvi e cambiare il valore della variabile datoDisponibile.

La situazione che si crea in questo caso viene definita di Stallo (deadlock).

Quando un programma è costituito da diversi thread concorrenti, che condividono varie risorse, è importante garantire l'equità (fairness) tra di essi. Un sistema è equo quando permette a tutti i thread di accedere alle risorse e portare avanti il lavoro. Un sistema equo permette di evitare due problemi noti come deadlock (punto morto) e starvation (inedia, carestia).

Si ha una situazione di starvation quando uno o più thread non riescono ad accedere a una risorsa e quindi non possono eseguire il proprio lavoro. Si indica con deadlock una situazione di starvation indefinita, quando due o più thread sono in attesa di una condizione che non si verificherà mai. Per esempio, il thread 1 è dentro al monitor dell'oggetto A e deve entrare anche nel monitor dell'oggetto B. Nel frattempo, il thread 2 è nel monitor di B e deve entrare nel monitor di A. In questa situazione, ognuno dei due thread impedisce all'altro di progredire.

Per verificare la situazione di deadlock nell'esempio precedente, si provi ad aggiungere la clausola synchronized ai metodi preleva e inserisci nella classe varCondivisa. Il programma, presentato in precedenza, ha caratteristiche di inefficienza: i due cicli while consumano una quantità enorme di tempo macchina senza produrre lavoro utile. Le primitive di inter-process communication di Java permettono invece di ottenere lo stesso risultato in modo molto efficiente.

Ricezione garantita via inter-process communication.

Il modo corretto di scrivere un programma in Java, per garantire che ogni valore prodotto sia consumato una e una sola volta, consiste nell'utilizzare le primitive wait e notify per far comunicare tra loro i due metodi inserisci e preleva.

Nella classe varCondivisa, la variabile booleana datoDisponibile indica ai due metodi preleva e inserisci se un dato nuovo è disponibile in varCondivisa.

Implementazione della classe

public class varCondivisa {

  int unDato;

  boolean datoDisponibile = false;

  synchronized int preleva() {

    if (!datoDisponibile)

      try {

        wait(); // sospeso in attesa del nuovo dato da leggere

      } catch (InterruptedException e) {}

    jTextArea1.append("Preso: " + unDato);

    datoDisponibile = false;

    notify();

    return unDato;

  }

  synchronized void inserisci(int d) {

    if(datoDisponibile) // attende che il dato precedente venga prelevato

      try {

        wait(); // sospeso in attesa che varCondivisa si liberi

      }  catch (InterruptedException e) {}

    unDato=d;

    datoDisponibile = true;

    jTextArea1.append("Depositato: "+d);

    notify(); // segnala un nuovo dato disponibile

  }

}

Se la variabile booleana datoDisponibile ha valore false, il metodo preleva utilizza la primitiva wait per passare nella coda dei processi in attesa fino a che il Produttore non segnala che un dato è pronto da prelevare. Quando questo succede, il metodo preleva legge il dato e con la primitiva notify indica al Produttore che varCondivisa è libera.

Se la variabile booleana datoDisponibile ha valore true, Il metodo resta in wait finchè il Consumatore non indica che varCondivisa è libera. A questo punto, il metodo inserisci deposita in varCondivisa il nuovo dato e segnala al Consumatore con la primitiva notify che il nuovo dato è disponibile.

La clausola synchronized garantisce che i due metodi accedano al dato in varCondivisa e alla variabile booleana datoDisponibile in modo sincronizzato. Ogni singolo dato viene depositato e prelevato una e una sola volta. Eseguendo il programma con queste modifiche si ottiene uno scambio corretto di messaggi tra i due processi.


Applicazioni con i Thread di tipo Console Application

Le classi Timer e TimerTask.

Il timer è un componente hardware del sistema di elaborazione.

Contiene alcuni registri utilizzati come contatori a decremento, che vengono inizializzati da programma, e possiedono un proprio ingresso di clock, la cui frequenza determina la velocità di decremento del conteggio.

Alcuni contatori sono riservati a operazioni di sistema, altri sono disponibili per i programmatori.
Un programma può accedere al contatore del timer per leggere il conteggio, e quindi misurare un intervallo di tempo.
Un programma può inizializzare un contatore, che poi viene decrementato con una determinata frequenza e, quando raggiunge il valore 0, il timer avverte la CPU attivando la linea di interruzione per comunicare che si è verificato il time-out.

Scheduling di un evento

Avviare NetBeans e creare un nuovo progetto Java Application.

Denominarlo Promemoria

Con il tasto destro sul nome della classe promemoria nella finestra del progetto, scegliere New → java Class …. Nella casella Class Name scrivere Scadenza.

Nel file Scadenza.java, prima della dichiarazione della classe, inserire la seguente riga:

import java.util.TimerTask;

Modificare l'intestazione della classe per derivarla dalla classe TimerTask, una classe che contiene il gestore del timer.

  class Scadenza extends TimerTask {

Ridefinire il metodo run ereditato dalla classe TimeTask:

Il metodo run, che verrà eseguito quando si verifica il time-out, specifica le operazioni che si dovranno svolgere:

    public void run() {

      System.out.println("Periodo trascorso!");

      this.cancel();

      System.exit(0);

    }

  } // fine classe Scadenza

Commenti:

Quando si verifica il time out viene richiamato il metodo run.
Il processo richiamato mostra un messaggio: "Periodo Trascorso",
Poi si auto elimina.
Infine termina anche il processo che lo ha generato.

Per essere usata, la classe precedente deve essere ospitata in un processo che, dopo averla schedulata, ne consentirà l'esecuzione nel proprio spazio.

Aprire la scheda del file Promemoria.java.

Prima della dichiarazione della classe, inserire la riga:

import java.util.Timer;

La classe principale, Promemoria, definisce due campi membro:

il costruttore della classe Promemoria riceve un parametro che rappresenta il numero di secondi che devono trascorrere prima che il processo venga richiamato. Poi inizializza i riferimenti ai campi membro della classe e infine schedula il processo da eseguire quando si verifica il time out.

public class Promemoria {

  Timer timer;

  Scadenza Avvertimento;

  public Promemoria(int secondi) {

    Avvertimento = new Scadenza();

    timer = new Timer();

    timer.schedule(Avvertimento, secondi*1000);

  }

  public static void main(String args[]) {

    int i=0;

    System.out.println("il processo sta per essere schedulato.");

    new Promemoria(5);

    System.out.println("Processo schedulato. intanto faccio altre operazioni");

    do {

      System.out.print(i+"\r");

      if (i<100) i++; else i=0;

    } while (true);

  }

}

Commenti:

Quando il programma verrà eseguito all'interno dell'IDE (Run → Run Project (Promemoria)), nella finestra di output, in basso nell'IDE, compaiono i messaggi che mostrano l'avanzamento del programma principale e del thread.

In questo semplice programma sono contenute le parti per implementare e schedulare un processo che deve essere eseguito dal gestore del timer.

Un programma resta in esecuzione fintanto che tutti i suoi processi timer sono in esecuzione. Ci sono quattro modi per terminare un thread del timer.

L'esempio Promemoria richiama il metodo cancel all'interno del metodo run del processo del timer.

Il metodo schedule esiste in varie versioni che, oltre a specificare il processo da schedulare, prevedono la possibilità di ripetere periodicamente la schedulazione:


Thread

Ci sono due metodi per fornire il metodo run a un thread:

  1. Creare una sottoclasse della classe Thread e ridefinire il metodo run.

  2. Fornire una classe che implementa l'interfaccia Runnable e implementare il metodo run.

Per provare l'esecuzione parallela dei processi l'esempio seguente crea un Thread che genera numeri in un assegnato intervallo, poi avvia due istanze della classe e, mediante la stampa, mostra che i risultati di un Thread sono intercalati nei risultati dell'altro Thread.

Creare un nuovo Progetto. Denominarlo Contatori.

Clic destro sul nome della classe Contatori e, dal menu contestuale, scegliere la voce: New → java Class …. Nella casella Class name scrivere: ioConto.

Completare l'intestazione della classe, per ereditare dalla classe Thread:

public class ioConto extends Thread {

  private int inizio;

  private int fine;

  public ioConto(int da, int a) {

    this.inizio=da;

    this.fine=a;

  }

  public void run(){

    System.out.println(this.getName() + " avviato ... ");

    for (int i=inizio; i<=fine; i++) {

      System.out.print(i + " ");

    }

  System.out.println(this.getName() + " Finito.");

  }

}

Passare alla scheda del file Contatori.java. Completare il metodo main della classe:

  public static void main(String[] args) {

    // TODO code application logic here

    ioConto Th1 = new ioConto(1, 100);

    ioConto Th2 = new ioConto(200, 300);

    Th1.start();

    Th2.start();

  }

La classe Contatori ospita i Thread. Il primo thread conta da 1 a 100, il secondo thread conta da 200 fino a 300.

Eseguire il programma ed osservare i risultati nella finestra di output. I numeri generati da un thread sono intercalati tra quelli dell'altro thread.

Esercizio. I nomi assegnati per default ai Thread sono Thread-0 e Thread-1. Modificare i nomi dei Thread.


Processi paralleli per analizzare dati

In molti programmi utilizzati in ambito industriale e scientifico, si pone il problema di analizzare ed elaborare grandi quantità di dati, prelevati da un disco, da un array o da un database di grandi dimensioni. In molti casi l'analisi viene effettuata da molti elaboratori sparsi sul pianeta, poichè la quantità di dati da esaminare è enorme.

In questi casi il lavoro viene suddiviso su più thread indipendenti, ognuno dei quali lavora su un sottoinsieme dei dati a disposizione.

Costruire il programma che permette a più thread di accedere a un grande deposito di dati, ottenere da questo un intervallo di dati da analizzare, effettuarne l'elaborazione, chiedere un nuovo intervallo di dati, e così via, fino a completare l'analisi di tutti i dati del deposito

Il deposito di dati contiene due attributi:

Il metodo preleva indica al thread l'intervallo di dati su cui il thread deve lavorare.

Creare un nuovo progetto: Elaborazione.

Aggiungere la classe Deposito.

public class Deposito {

  int dati[];

  int contatore = 1000;

  public Deposito() {

    dati = new int[1000];

    for (int i=0; i<1000; i++) {

      dati[i]=i;

    }

  }

  synchronized int preleva() {

    if (contatore > 0) {

      contatore -= 100;

      return contatore;

    } else {

      return -1;

    }

  }

}

Aggiungere la classe Analizzatore al progetto.

La classe Analizzatore utilizza la variabile deposito, che fa riferimento al deposito di dati su cui lavorare. Questa classe implementa il metodo run che fa svolgere ai vari thread il proprio lavoro. II costruttore crea un thread e lo attiva con il messaggio start.

public class Analizzatore implements Runnable {

  Deposito d;

  Analizzatore(Deposito mioDeposito, String nomeT) {

    d = mioDeposito;

    new Thread(this, nomeT).start();

  }

  public void run() {

    int indice = 100;

    String nomeT = Thread.currentThread().getName();

    while (indice>0) {

      indice=d.preleva();

      if (indice>=0) {

        System.out.println(nomeT + ": Analizza dati da: " + indice + " a " + (indice+99));

        // codice di elaborazione dei dati

        try {

          // simulazione del tempo di elaborazione

          Thread.sleep(500);

        } catch(Exception e) {}

      } else {

        System.out.println("Analizzatore " + nomeT + " ha terminato");

      }

    }

  }

}

Per semplicità, nella classe Deposito i dati sono rappresentati come un array di 1000 interi: naturalmente a seconda dell'applicazione specifica potrebbero essere contenuti in un file oppure in un insieme di record più o meno complessi. Nell'esempio i dati sono affidati, a blocchi di 100 per volta, ai vari thread di tipo Analizzatore, attivati dal main.

Il metodo preleva, invocato da un thread, restituisce la posizione del primo dei 100 dati su cui il thread deve lavorare. Avendo un array di 1000 interi (da 0 a 999), il primo thread riceve il valore 900 e lavora sui dati da 900 a 999, il secondo riceve il valore 800 e lavora sui dati da 800 a 899, e così via fino all'esaurimento dei dati da elaborare. A questo punto il metodo preleva restituisce il valore -1.

Nella classe Analizzatore, il metodo run ha un ciclo while all'interno del quale viene chiamato il metodo preleva, viene effettuata l'elaborazione prevista sui 100 dati così identificati (nel programma l'operazione di analisi dei dati viene simulata da una sleep), e poi il ciclo riprende fino a quando il metodo preleva restituisce il valore -1.

public class Elaborazione {

  public static void main(String[] args) {

    // TODO code application logic here

    Deposito mioDeposito = new Deposito();

    new Analizzatore(mioDeposito, "\tA1");

    new Analizzatore(mioDeposito, "\t\tA2");

    new Analizzatore(mioDeposito, "\t\t\tA3");

  }

}

Il programma main crea un Deposito di dati, e poi crea tre istanze di Analizzatore, ognuna delle quali genera un thread che esegue il ciclo while realizzato all'interno di run. I tre analizzatori creati indicano i dati su cui di volta in volta stanno lavorando e poi terminano con un messaggio all'utente.

Il programma Java proposto legge dalla classe Deposito, tramite thread paralleli, un set di valori, sottomultiplo del numero totale di dati da analizzare, fintanto che i valori da analizzare non si siano esauriti. Ipotizzando che il blocco di dati letto non sia un sottomultiplo del deposito, si gestisca correttamente il prelievo dell'ultimo blocco dati, che conterrà un numero di dati inferiore alla capacità del blocco. Si utilizzino delle costanti per i due parametri (dimensione totale del deposito e dimensione del blocco), in modo che si possano poi facilmente modificare questi due dati e provare il programma con diversi valori.