Java Tutorial - Parte 2 0.1
Un tutorial per esempi
Caricamento in corso...
Ricerca in corso...
Nessun risultato
Versione 0.6: EDT ed i threads secondari

Introduzione

In questo capitolo il lettore impererà ad usare i threads secondari per le elaborazioni time-consuming. Ho più volte accennato al fatto che una applicazione GUI viene eseguita in un thread principale chiamatgo EDT (Event Dispatching Thread); vedi a questo proposito la sezione Che significa invokeLater?.
I metodi che rispondono agli eventi devono durare pochissimo tempo, il minimo indispensabile e rientrare subito: in caso contrario la GUI si comporta in maniera inaspettata come abbiamo sperimentato in Event Dispatching Thread.

Che cosa è un thread

In informatica un thread viene spesso chiamato processo leggero. La differenza sostanziale tra un processo ed un thread sta nel fatto che i processi in esecuzione su un computer sono isolati e nettamente separati gli uni dagli altri. Ogni processo ha il suo spazio di indirizzamento privato in memoria e nessun processo può accedere allo spazio di un altro processo: se lo fà il sistema operativo lo termina immediatamente.
I threads, invece, condividono lo spazio degli indirizzi del processo a cui appartengono e, pertanto, ogni thread ha accesso ai membri dati ed ai metodi definiti nella applicazione: un ulteriore vantaggio dei threads rispetto ai processi è che i primi sono facili e veloci da creare e mandare in esecuzione.

Tuttavia, non sempre le elaborazioni da eseguire in risposta agli eventi possono concludersi immediatamente: in questo giochino la AI deve elaborare un numero limitato di possibili soluzioni ma come la mettiamo se dovessimo scrivere una AI per il gioco degli scacchi?
Le elaborazioni che concumano tempo possono essere eseguite da applicazione GUI (ci mancherebbe altro) ma non nel EDT; faremo partire un thread a sè stante, che chiameremo secondario, che può durare tutto il tempo necessario: quando esso si sarà concluso, i risultati saranno "notificati" al EDT che li visualizzerà nei componenti GUI della app.

Come facciamo a "rallentare" l'algoritmo classico della AI in modo che ci debba "pensare un pò sopra" prima di fornire la guess? Ma è facile: introdurremo un ritardo fittizio che specificheremo nel costruttore della classe PlayerAI. Il valore passato come argomento, specificato in millisecondi, rappresenta il ritardo che il giocatore deve aspettare prima di inviare la sequenza di guess al server di gioco.

I nuovi files sorgente

Per mantenere la coerenza con la strategia di versioning adottata finora, scriveremo una nuova classe, derivate da quelle originale, per la intelligneza artificiale. Di seguito l'elenco dei nuovi files sorgente:

nome file package descrizione
PlayerAI06.java mastermind.player la nuova intelligenza artificiale
Main06.java mastermind.app la classe principale della versione 0.6

Il metodo 'sleep'

Introdurre un ritardo in un processo è facilissimo; il linguaggio Java fornisce nella sua libreria standard il metodo statico Thread.sleep(milliseconds) che fa dormire il thread corrente per il numero specificato di millisecondi, La granularità del timer dipende dalla piattaforma specifica. Java mette a disposizione del programmatore diversi metodi e classi per gestire il tempo, vedi Gli orologi del sistema.
Potremmo semplicemente sovrascrivere il metodo della classe PlayerLocal introducendo il ritardo fittizio facendo dormire il thread corrente:

// File: PlayerAI06.java
protected void sendGuess( String guess )
{
// addormenta il thread
try {
Thread.sleep( delay );
}
catch( InterruptedException ignore ) { }
// invia la guess
SwingUtilities.invokeLater( new Runnable() {
public void run()
{
listener.haveGuess( turn, guess, false );
}
});
}

Il metodo statico Thread.sleep solleva la eccezione checked InterruptedException quando il thread viene interrotto, solitamente perchè il processo stesso in cui gira il thread viene interrotto o finisce. Non c'è molto dare fare se viene sollevata questa eccezione dal momento che il thread sta per terminare; la eccezione viene semplicemente ignorata.

Il nuovo costruttore

Di quanti millisecondi facciamo dormire il thread? La soluzione migliore è quella di lasciare questo valore impostabile a piacimento e quindi lo specificheremo come argomento al costruttore della classe. Non modifichiamo il costruttore già in essere: se lo facessimo, romperemo la compatibilità con le versioni precedenti e le classi Main04 e Main05 non compilerebbero più. La strategia corretta in questi casi è proprio quella di scrivere un nuovo costruttore:

// File: PlayerAI06.java
public PlayerAI06( int turn, int delay )
{
super( turn );
this.delay = delay;
}

D'ora in poi, questa è la classe che dobbiamo usare nel metodo createPlayer della applicazione principale. E se ci dimentichiamo di farlo e usassimo ancora la vecchia classe PlayerAI che manteniamo nei sorgenti per compatibilità con le versioni precedenti? Il rischio di usarla ancora la vecchia classe per sbaglio è piuttosto alto.
Il linguaggio Java ci viene in aiuto per mitigare questo rischio con le annotations (=annotazioni) e, nello specifico la annotazione @Deprecated che scriviamo subito prima della classe il cui uso è "sconsigliato".

// File: PlayerAI.java
@Deprecated
public class PlayerAI extends PlayerLocal
{
... omissis ...

La annotazione fà parte del compilatore Java il quale ci avverte, in fase di compilazione, che abbiamo usato una classe, un metodo e/o un costruttore "deprecato" (=sconsigliato). Se proviamo a compilare il sorgente Main05.java, la versione precedente della applicazione principale che fà ancora uso del vecchio costruttore, otterremo un avvertimento:

mastermind\V060>javac mastermind\game\Main05.java
Note: mastermind\game\Main05.java uses or overrides a deprecated API.
Note: Recompile with -Xlint:deprecation for details.

Specificando la opzione -Xlint:deprecation il compilatore ci informa dei dettagli e cioè quali istruzioni usano la API deprecata:

mastermind\game\Main05.java:94: warning: [deprecation] PlayerAI in mastermind.player has been deprecated
                        case "AI" -> new PlayerAI( turn );

Il messaggio del compilatore non è un errore ma un warning (=avvertimento) ed il sorgente compila correttamente. E' compito del programmatore analizzare il warning: sappiamo che le vecchie versione della classe principale usano la vecchia classe PlayerAI ma non è un problema.
Diverso il caso se il warning riguardasse sorgenti nuovi: in questo caso dobbiamo correre ai ripari e la documentazione javadoc ci viene in aiuto mettendo a disposizione due speciali comandi:

  • @deprecated che ha lo stesso significato della annotazione del compilatore ma al quale possiamo aggiungere del testo a nostro piacere
  • @since mediante il quale si è solito indicare il numero di versione dal quale una classe, un metodo e/o costruttore è disponibile
// File: PlayerAI06.java
@since 0.6
public class PlayerAI extends PlayerLocal
{
... omissis ...

Il nuovo Main06

La nuova classe Main06 deriva da Main05 in modo da poterne ereditare tutte le funzionalità già implementate nella versione precedente e sovrascrive, oltre al costruttore ed al metodo getVersion anche il metodo connectPlayers il quale usa il nuovo costruttore di PlayerLocal e, di conseguenza, anche delle sue classi derivate:

// File: Main06.java
@Override
protected Player createPlayer( int turn, String playerType, String name )
{
// legge la proprietà 'guessDelay', usa 1000 ms se errori
int delay = 1000;
try {
delay = properties.getPropertyInt( "guessDelay", 1000);
}
catch( PropertyException ignore ) { }
Player player = switch( playerType ) {
case "AI" -> new PlayerAI06( turn, delay );
... omissis ...

La proprietà guessDelay

Il valore del ritardo fittizio in millisecondi viene specificato dalla proprietà "guessDelay" che per default è impostata a 1000 (un secondo) Possiamo però specificare un valore diverso sulla cmdline:

>java -ea mastermind.game.Main guessDelay=0

Se provassimo questo nuovo feature con il codice descritto in Il metodo 'sleep' in cui abbiamo semplicemente introdotto il ritardo con una semplice istruzione sleep (ed il relativo blocco try..catch) osserviamo che, effettivamente, la guess del giocatore AI non viene visualizzata subito dopo che noi abbiamo premuto il bottone Check ma con un certo ritardo che possiamo valutare in circa un secondo come da noi desiderato.
Tuttavia, c'è qualcosa che non quadra: continuamo la partita, inserendo altre guess senza pensarci troppo e ad un certo punto notiamo le stranezze:

  • il bottone Check da noi premuto rimane nello stato clikked per circa un secondo
  • la statusbar non si aggiorna: in quel secondo in cui la AI sta pensando la propria guess si dovrebbe in teoria accendere il flag del turno del giocatore destro ma invece rimane acceso il turno sinistro (il nostro)

Cosa è successo? Ebbene, la risposta è: la GUI non si aggiorna!

Il Event Dispatching Thread

Ho già accennato al fatto che una applicazione GUI Java viene eseguita in due threads diversi:

  • il primo thread è quello in cui la applicazione esegue la inizializzaione ed è costituito dal solo metodo main
  • un secondo thread viene fatto partire dalla libreria Swing nel quale vengono elaborati tutti gli eventi della GUI. Questo thread è chiamato Event Dispacthing Thread (EDT)

L'aggiornamento della GUI viene gestito come evento dal EDT nel dispatcher (il gestore degli eventi) e fintanto che esso non viene eseguito non si può avere alcun aggiornamento visivo.

Mi spiego meglio con un esempio. Prendiamo come esempio l'aggiornamento della statusbar che avviene nel metodo notifySwapTurn:

//File: MasterMind.java
public void notifySwapTurn( int turn, int tryCounter )
{
... omissis ...
if ( statusbar != null ) {
statusbar.setMessage( msg );
statusbar.setTurn( turn );
}
}

Il nuovo turno viene impostato correttamente nel oggetto StatusBar il quale a sua volta imposta il colore di fondo (verde) nella JLabel relativa al turno indicato nell'argomento:

this.turn[i].setBackground( back );

Il codice è assolutamente corretto. Ma allora, perchè non lo vediamo? Ebbene, non si vede nulla perchè il metodo della libreria Swing JLabel.setBackground NON disegna affatto il nuovo colore di fondo nel componente ma si limita a impostare il nuovo colore di fondo nei membri dati del componente e poi "accoda" un evento PaintEvent nella coda degli eventi.
L'evento accodato contiene tutte le informazioni su quale componente deve essere aggiornato in visualizzazione. Come tutti gli eventi, esso viene accodato nella coda ed elaborato dal dispatcher quando il controllo ritorna al dispatcher stesso.. Tuttavia, il controllo non ritorna subito al dispatcher poichè il EDT è bloccato in un Thread.sleep e questo impedisce al dispatcher degli eventi di eseguirne la coda. Solo quando il EDT si "risveglia" il dispatcher viene eseguito di nuovo.

In una applicazione GUI non si deve MAI eseguire elaborazioni costose in termini di tempo nel EDT altrimenti la GUI diventa unresponsive (=non responsiva) cioè non esegue la coda degli eventi e pertanto sembra non rispondere più ai comandi. Come si risolve questo problema? Semplice: le elaborazione che consumano tempo si eseguono in un thread separato.

Creare nuovi threads

Per creare nuovi threads il linguaggio Java fornisce la classe Thread la quale implementa la interfaccia Runnable il cui unico metodo astratto è il run che non accetta argomenti e ritorna void. La creazione e gestione di un thread mediante la classe all'uopo predisposta è piuttosto complicata e questo non è il momento adatto per affrontare aspetti così complessi della programmazione.

Per fortuna, il linguaggio Java mette a disposizione uno strumento facile da usare e veloce da implementare ed è stato scritto in modo specifico per essere usato in una applicazione Swing. Quando si scrive una applicazione multi-thread in Swing (e, di norma, anche in altri ambienti GUI) si devono osservare alcune restrizioni:

  • i lavori che consumano molto tempo non devono essere svolti nel EDT altrimenti la GUI diventa unresponsive (non risponde ai comandi)
  • l'accesso ai componenti della interfaccia grafica deve essere eseguito nel EDT

Appare quindi chiaro che scrivere un thread separato non è banale nemmeno per introdurre un semplice ritardo di un secondo nel metodo che invia una guess da parte del giocatore locale poichè è necessario:

  • creare e mandare in esecuzione il thread nel EDT
  • quando il thread separato ha ottenuto il risultato è necessario comunicarlo al thread chiamante e cioè al EDT
  • nel EDT è possibile richiamare i metodi che aggiornano il pannello del giocatore mostrando la sequenza di guess nella GUI

Si deve quindi in qualche modo implementare una sorta di comunicazione tra il thread separato che fornisce i risultati e l'EDT che li riceve e li usa per aggiornare la GUI accedendo ai componenti grafici.

La classe SwingWorker

La classe SwingWorker del package javax.swing facilita enormemente questo lavoro consentendo al programmatore di disinteressarsi del tutto di questi aspetti tecnici: tutto quello che serve sapere al programmatore sono pochi concetti fondamentali:

  • uno SwingWorker può essere costruito anche mediante una classe anonima poichè il codice da scrivere è composto da poche righe
  • lo SwingWorker è una classe tipizzata che viene istanziata con due tipi di dato: il tipo del dato ritornato ed il tipo di dato intermedio
  • il worker viene eseguito col suo metodo execute
  • una volta concluso il lavoro, lo SwingWorker non può più essere ri-eseguito: è necessario creare una nuova istanza di esso e richiamare il metodo execute

Per eseguire il lavoro nel worker è sufficente sovrascrivere due soli metodi:

  • doInBackground: questo metodo viene eseguito nel thread secondario e può pertanto essere lungo a piacere; esso ritorna il risultato della elaborazione
  • done; questo metodo viene eseguito nel EDT e quindi è possibile accedere a qualsiasi componente GUI in questo metodo. Per ottenere i risultati elaborati in background si usa il metodo get.

Quindi nella classe PlayerAI06 sovrascriviamo il metodo sendGuess della classe base PlayerLocal ed implementiamo uno SwingWorker che fà partire un thread secondario che dura il tempo necessario ad introdurre il ritardo fittizio. Quando il thread secondario termina, la guess viene restituita al EDT tramite il metodo done il quale poi la invia al server di gioco per la ulteriore elaborazione. Osservate quando facile è creare ed eseguire qualsiasi compito, anche molto lungo, in un thread secondario usando Java Swing:

// File: PlayerAI06.java
@Override
protected void sendGuess( String guess )
{
... omissis ...
SwingWorker worker = new SwingWorker<String, Void>()
{
// il metodo eseguito nel thread secondario
public String doInBackground() throws InterruptedException
... omissis ...
// il metodo richiamato dal EDT
public void done()
... omissis ...
};
worker.execute();
}

Nel metodo eseguito in background si inserisce il ritardo facendo dormire il thread secondario: poichè si tratta di un thread separato, il EDT continua ad eseguire e la GUI non si blocca: il thread secondario termina ritornando la guess elaborata dalla AI.
Quando il thread secondario termina, dal EDT viene richiamato il metodo done il quale recupera il dato elaborato in background richiamando il metodo get. Il dato recuperato, cioè la guess già elaborata, viene inviata al server di gioco richiamando il metodo haveGuess della interfaccia InputListener.

Il thread separato viene creato istanziando una classe anonima di tipo SwingWorker che è classe parametrizzata. La sua definizione è la seguente:

Class SwingWorker<T,V>
Type Parameters:
T - il tipo del risultato restituito dai metodi doInBackground e get
V - il tipo dei risultati intermedi restituiti dai metodi publish e process

Poichè i metodi della nostra classe anomina devono restituire String quale risultato della elaborazione in background (la guess, peraltro già conosciuta) e nessun risultato intermedio, istanziamo la classe anomina con:

SwingWorker worker = new SwingWorker<String, Void>()

Il "lavoro" viene eseguito sovrascrivendo i due metodi all'uopo predisposti della classe SwingWorker:

  • doInBackground che è il metodo eseguito nel thread separato e che restituisce il dato elaborato
  • done che viene eseguito nel EDT ed ottiene il dato elaborato in background

il metodo doInBackground

Questo è davvero facile:

@Override
public String doInBackground() throws InterruptedException
{
Thread.sleep( delay );
return guess;
}

La elaborazione in background introduce il ritardo stabilito nel membro dati delay il cui valore viene passato al costruttore di PlayerLocal. Allo scadere, ritorna la sequenza di guess passata come argomento al metodo. Il metodo statico Thread.sleep può sollevare eccezioni di tipo InterruptedException che sono di tipo checked e quindi vanno obbligatoriamente intercettate o propagate.

il metodo done

Il metodo done viene eseguito nel EDT; otteniamo la guess con il metodo get e la inoltriamo al server di gioco per mezzo del metodo di interfaccia haveGuess.

@Override
public void done()
{
try {
String guess = get();
listener.haveGuess( turn, guess, false );
}
catch (InterruptedException | ExecutionException ignore)
{}
}

Un commento speciale meritano le eccezioni: il metodo get deve essre eseguito in un blocco try ... catch perchè può sollevare due eccezioni checked:

  • InterrupedException: sollevata se il thread separato è stato interrotto; questa eccezione di solito viene ignorata poichè se l'elaborazione in background è stata interrotta allora la applicazione non è interessata ai risultati
  • ExecutionException: se il metodo doInBackground ha sollevato una qualsiasi altra eccezione; in questo caso la eccezione sollevata dal metodo in background viene impostata come causa della eccezione ExecutionException

Nel nostro caso, il metodo doInBackground non può sollevare eccezioni diverse da InterruptedException considerato che la sequenza di guess da ritornare è disponibile già in ingresso al metodo haveGuessDelayWorker; essa deve solo essere ritornata allo scadere del ritardo.

La classe principale

La classe Main06, derivata da Main05, è davvero facile; essa si limita a sovraascrivere il metodo createPlayer il quale da questa versione in avanti istanzia la nuova classe PlayerAI06 per il giocatore A.I.

// Main06.java
@Override
protected Player createPlayer( int turn, String playerType, String name ) throws MMException
{
// ottiene il valore del ritardo in millisecondi
int delay = 1000; // default se errori
try {
delay = properties.getPropertyInt( "guessDelay", 1000);
}
catch( PropertyException ignore ) { }
// istanzia i players
Player player = switch( playerType ) {
case "AI" -> new PlayerAI06( turn, delay );
... omissis ...
}

Compiliamo e proviamo: la GUI è responsiva, il flag del turno viene aggiornato e si nota anche che i bottoni di comando usati dal giocatore umano per digitare la guess sono disabilitati nel turno della AI.

Questo capitolo sul EDT è importante. Il lettore deve verificare di aver compreso bene l'argomento trattato e di poterlo padroneggiare totalmente. Nel sorgente PlayerAI06.java esistono due versioni del metodo sendGuess:
  • la versione effettivamente usata, quella che usa lo SwingWorker, e che consente alla app di comportarsi normalmente
  • una seconda versione, interamente commentata, che introduce il ritardo fittizio nel EDT
Se avete dubbi e volete "toccare con mano" il problema della non-responsività della GUI, commentata la versione definitiva del metodo sendGuess e togliete i commenti alla versione alternativa. Compilate ed eseguite: noterete immediatamente i problemi che ho descritto.

Ulteriore documentazione

La documentazione completa del package descritto in questo capitolo può essere visualizzata clikkando il seguente link che riporta alla documentazione Javadoc della versione 0.6 del progetto: The MasterMind Project Version 0.6

Argomento precedente - Argomento successivo - Indice Generale