|
Java Tutorial - Parte 2 0.1
Un tutorial per esempi
|
Nella sezione Il metodo sendMessage avevo accennato ai problemi derivanti nella implementazione attraverso un worker-thread della operazione di invio dei messaggi usando la stessa tecnica descritta per la operazione di connessione. Vediamo brevemente come si presenterebbe il metodo sendMessage usando la stessa soluzione:
Indubbiamente, la soluzione è davvero semplice ma presenta delle criticità da non sottovalutare che possono essere riepilogate di seguito:
connectionError ma introduce una certa complessità nel codice, proprio quello che volevamo evitare La soluzione, elegante ed efficace, a tutti e tre i problemi si chiama: le code bloccanti. Il linguaggio Java mette a disposizione diverse implementazioni delle code bloccanti che vengono descritte dalla interfaccia BlockingQueue. La implementazione usata in mastermind è la ArrayBlockingQueue, contenuta nel package java.util.concurrent.
Le blocking queues sono code FIFO o LIFO nelle quali un elemento viene inserito solo se vi è spazio a sufficenza; se non c'è spazio, la operazione blocca il thread finchè non si libera spazio.
Di contro, un elemento viene rimosso dalla coda solo se vi è almeno un elemento disponibile; se la coda è vuota, l'operazione blocca il thread fino a quando un elemento non diventi disponibile.
Le code bloccanti in mastermind sono implementate nei due files sorgente:
| nome file | package | descrizione |
|---|---|---|
| SendQueue.java | mastermind.net | la coda bloccante di invio |
| RecvQueue.java | mastermind.net | la coda bloccante di ricezione |
La strategia implementata in MasterMind per risolvere i problemi descritti in introduzione si basa su questi principi i quali usano la coda bloccante:
doSendMessage Con questa soluzione abbiamo anche un altro vantaggioche riguarda il metodo Connection.sendMessage: se il metodo ritorna, possiamo disinteressarci totalmente dell'esito della trasmissione:
Ma se nella coda di invio non c'è più spazio? Ecco perchè si usano le code bloccanti: se non c'è più spazio, il EDT si blocca nel tentativo di inserire il messaggio in coda; poichè la trasmissione avviene in un thread separato, prima o poi un messaggio verrà trasmesso sul canale di comunicazione e un posto si libererà.
Se la connessione è talmente lenta da bloccare parzialmente la GUI, allora è meglio così: l'utente avrà così un feedback notando il forte rallentamento.
A dire il vero, una coda di ricezione non è strettamente necessaria. Non serve per ripristinare la ricezione di un messaggio in caso di errori di connessione dal momento che, se il messaggio non è stato ricevuto correttamente esso non è nemmeno stato inviato con successo: ci penserò la coda di invio del remoto a ritrasmetterlo.
Per quanto riguarda la lentezza della connessione, essa si risolve nel worker-thread di lettura: dal momento che la operazione di lettura è bloccante, i messaggi arriveranno molto lentamente ma questo non bloccherà la GUI dal momento che la effettiva ricezione del messaggio viene eseguita in un thread separato.
Abbiamo già incontrato il concetto di worker-thread in Versione 0.5, introdurre un ritardo ed anche in Versione 0.8, i metodi di I/O.
Nel primo caso abbiamo usato uno SwingWorker per introdurre un ritardo nella esecuzione di una operazione che sarebbe, in effetti, immediata. Nel secondo caso, invece, abbiamo usato uno SwingWorker per eseguire operazioni di I/O bloccanti le quali, se eseguite nel EDT, potrebbero congelare la GUI.
Prendiamo come esempio il caso del giocatore A.I.: esso elabora la guess in una frazione di secondo ma, per dare al giocatore umano l'impressione che la macchina ci pensa in po su abbiamo escogitato il seguente trucchetto:
done Questo thread separato quindi ha un inizio ed una fine: quando il metodo doInBackground termina, il dato elaborato nel thread separato è pronto e può essere restituito per essere poi elaborato nel thread principale. Per la operazione di introdurre un ritardo fittizio nella elaborazione della guess da parte della A.I. questa modalità di esecuzione è perfettamente funzionale e assolutamente congrua ed efficiente: una volta inviata la guess al server, non ha più senso mantenere in esecuzione il thread separato; meglio creare un altro thread quando una guess sarà nuovamente disponibile.
Diverso è il caso in cui serve un thread separato per gestire una coda di messaggi da inviare: creare un thread per ogni messaggio da inviare sarebbe, oltre che costoso in termini di tempo e risorse, controproducente dal momento che è proprio ciò che vogliamo evitare. Meglio creare un thread unico che gestisce la intera coda: se la coda è vuota il thread può dormire per qualche centinaio di millisecondi; se invece vi è almeno un messaggio da inviare il thread lo invia e poi verifica nuovamente se la coda è vuota oppure no. Si tratta quindi di un thread che non termina mai.
Voglio attirare la attenzione del lettore sul fatto che il thread derivato da SwingWorker è del tipo one-shot (=spara una sola volta) nel senso che, una volta finito, il thread non può più essere ri-eseguito nemmeno se viene richiamato nuovamente il metodo execute.
Come facciamo allora a restituire il dato elaborato dal thread se esso deve terminare per restituirlo? La classe SwingWorker può essere eseguita in un altro modo oltre a quello visto finora: essa è una classe parametrizzata nel senso che è specializzata nel restituire un tipo di dato specifico ma, come potete osservare dalla sua dichiarazione, essa accetta due parametri (T e V) ed essi possono anche essere di tipi diversi:
Il primo tipo (T) è il tipo di dato restituito dal metodo doInbackground quando esso termina (e questo fà terminare anche il thread).
Il secondo tipo (V) è il tipo di dato restituito come risultato intermedio del metodo publish che, al pari di quello in background, viene eseguito nel thread secondario. Mentre il metodo doInBackground ha la sua controparte nel EDT in done, il metodo publish ha la sua controparte in EDT nel metodo process.
Possiamo quindi sfruttare questa caratteristica della classe SwingWorker per mantenere sempre in esecuzione il worker-thread che ispeziona la coda dei messaggi ed inviare i messaggi in coda come risultati intermedi. In questo scenario, il worker-thread non ha un dato da restituire e quindi non terminerà mai diventando pertanto un thread infinito; cosa restituità il thread infinito quando termina? Ovviamente null! I parametri del thread SendQueue saranno:
Void come primo parametro: il dato restituito alla fine del thread Message come secondo parametro: il dato restituito come risultato intermedioLe operazioni eseguite nella classe SendQueue sono sostanzialmente queste:
doInBackground viene eseguito in un ciclo while infinito, salvo la cancellazione del thread, ovviamente doInBackground viene inviato un messaggio in coda, se esiste doInBackground viene pubblicata la ID del messaggio inviato, richiamando il metodo publish process elabora la ID del messaggio inviatoUna nota di commento merita la definizione della coda bloccante queue. Perchè dichiararla statica? Ebbene, questo è proprio il trucco che permette la ritrasmissione dei messaggi non andati a buon fine in presenza di errori di connessione. Poichè la coda di invio termina in presenza di eccezioni, si rende necessario costruire una nuova istanza della classe SendQueue se e quando la connessione sarà ripristinata.
Tuttavia, la coda esistente che contiene eventuali messaggi non trasmessi resterà in essere essendo un membro statico.
Il costruttore della coda di invio dei messaggi alloca la coda bloccante in un membro statico: questo è necessario perchè la coda dei messaggi, pur essendo un thread separato, può terminare, per esempio a causa di errori di comunicazione.
Per aggiungere un messaggio alla coda di invio ho scritto il metodo addMessage il quale richiama semplicemente il metodo della implementazione Java di una coda bloccante: BlockingQueue.put
Perchè il metodo di libreria può sollevare una InterruptedException? la risposta è semplice: come scritto poc'anzi, la coda bloccante può bloccare il thread se si tenta di aggiungere un elemento ma la coda è piena: in questo caso la interruzione del thread provoca anche la interruzione del metodo put che rientra sollevando la InterruptedException.
Poichè questo è un thread che non finisce mai, il metodo doInBackground viene eseguito in un loop infinito; a dire il vero quasi infinito poichè è necessario verificare che esso non sia stato cancellato:
Il cuore del thread della coda di invio è proprio nel metodo doInBackground: il codice prima verifica se la coda è vuota oppure no ispezionando la coda bloccante col metodo peek il quale ritorna il messaggio da inviare oppure null se la coda è vuota:
BlockingQueue.peek è quello che implementa la strategia di ri-trasmissione dei messaggi il cui invio non è andato a buon fine a causa di errori di comunicazione. Il metodo peek restituisce il messaggio da inviare MA NON LO RIMUOVE DALLA CODA. Se un messaggio viene restituito dal metodo peek, esso viene passato come argomento al metodo doSendMessage che provvede alla effettiva trasmissione:
Poichè il metodo doSendMessage è bloccante, abbiamo solo due casi:
BlockingQueue.poll. doInBackground ed intercettata dal metodo done; il thread termina ma il messaggio è ancora in coda e sarà ritrasmesso se la connessione sarà ripristinataIl metodo process viene richiamato nel EDT (vedi Il Event Dispatching Thread) e ad esso vengono passati come argomento i risultati intermedi della elaborazione in background. Nel nostro caso, i risultati intermedi sono i messaggi inviati sulla connessione di rete e "pubblicati" nel metodo doInBackground:
Vi sembrerà strano che, benchè il metodo publish pubblichi uno ed un solo dato (un oggetto Message), il metodo process accetti come argomento non uno ma una lista di Message. La spiegazione è piuttosto semplice: ricordate sempre che il thread separato e il EDT vengono eseguiti in parallelo, ognuno per conto suo, e nessuno dei due è a conoscenza dello stato di avanzamento dell'altro.
Può succedere che il worker-thread pubblichi un messaggio per essere elaborato dal EDT ma quest'ultimo sia in altre faccende affaccendato e non possa quindi elaborare subito il dato pubblicato. Nel mentre, il worker-thread elabora un altro messaggio in coda e la pubblica: a questo punto vi sono DUE messaggi pubblicati e non uno solo.
Ecco perchè il metodo process accetta una lista di risultati intermedi: esso deve iterare sulla lista di risultati per poterli elaborare tutti.
Come accennato in precedenza, il never-ending-thread (thread infinito) non è "infinito" in senso letterale in quanto può terminare in due modi:
Quando il thread termina viene richiamato il metodo done dal EDT:
Le due eccezioni InterruptedException e CancellationException vengono ignorate: se il thread è stato interrotto o cancellato è perchè la connessione è stata chiusa probabilmente perchè la applicazione stessa sta terminando.
In caso di errori di connessione, invece, intercetteremo la ExecutionException che inoltreremo al listener degli eventi di rete.
Benchè il nome di questa classe evochi una coda anche per i messaggi ricevuti, la classe RecvQueue, che legge i messaggi dal canale di comunicazione, non usa davvero una coda bloccante per questa operazione. In effetti, quando un messaggio è stato ricevuto dal metodo in background e pubblicato come risultato intermedio, esso può semplicemente essere inoltrato al listener degli eventi di rete tramite il metodo di interfaccia messageReceived.
Implementare una coda in ricezione non è del tutto sbagliato; è semplicemente inutile in una applicazione come questa ma se consideriamo altri scenari, una coda in ricezione potrebbe essere una grande risorsa. Pensate ad esempio ad un server centrale che monitora decine di telecamere remote che trasmettono immagini del luogo ma solo nel momento in cui rilevano del movimento. In condizioni normali è pressochè impossibile che tutte abbiano la necessità di trasmettere dati ma ci potrebbero essere momenti particolari in cui un numero significativo di telecamere debbano trasmettere le immagini perchè si sono attivate.
La conseguenza è che il server centrale è messo sotto stress dovendo elaborare una mole di lavoro inaspettata e probabilmente non riuscirà ad elaborare tutti i dati in tempo reale con inevitabile perdita di alcune immagini. Invece di dotarci di un supercomputer che possa gestire tale mole di lavoro possiamo usare una coda bloccante in ricezione nella quale i messaggi trasmessi dalle telecamere vengono memorizzati ma non elaborati. Il server preleva un messaggio alla volta dalla coda e lo elabora nei suoi tempi. Anche in questo caso si noterà un rallentamento nelle operazioni ma è fisiologico e temporaneo: non appena la situazione si normalizzerà il flusso di dati riprenderà con i normali volumi ma l'uso della coda in ricezione ci ha permesso di non perdere nemmeno un frame, almeno fino a che la coda non si riempie.
Uno scenario come quello delle telecamere è ciò che in gergo informatico si chiama "un ambiente producer / consumer" in cui abbiamo sostanzialmente tre threads:
Ciò che conta davvero in questo scenario è che il EDT non si blocca mai in una attesa di input o perchè la coda è piena: è certo che se il producer è troppo veloce si noterà un certo rallentamento nelle operazioni e le immagini non saranno visualizzate in tempo reale ma la applicazione rimane reattiva; è questo quello che conta.
Per sperimentare questo aspetto delle code bloccanti ho scritto una piccola app che usa una coda bloccante e visualizza in un componente personalizzato il riempimento della coda stessa. Questa app è descritta in Le code bloccanti.
E' molto simile a quello della coda di invio ma, ovviamente, il metodo di I/O richiamato non sarà doSendMessage bensì doReadMessage. Quando in messaggio viene ricevuto esso viene pubblicato per mezzo del metodo publish:
Non c'è molto da aggiungere a quanto già visto per la coda di invio in Elaborare i risultati intermedi. Il metodo process elabora tutti i messaggi pubblicati come risultati intermedi richiamando il metodo di interfaccia del listener degli eventi di rete:
La strategia delle code bloccanti risolve egregiamente i tre problemi riscontrati in Introduzione tuttavia, non sono tutte rose e fiori. Anche questa soluzione ha il suo "lato oscuro", il "rovescio della medaglia" ma non riguarda le code bloccanti in se stesse quanto, piuttosto, l'avere a che fare con i threads separati. Mi spiego meglio con un esempio, preso proprio da mastermind. Analizziamo i due metodi principali della classe SendQueue:
Teniamo bene presente che questi due metodi vengono eseguiti in threads SEPARATI; il primo viene sempre eseguito nel EDT mentre il secondo metodo viene eseguito nel thread che chiameremo secondario (o anche worker-thread). Teniamo presente anche che ognuno dei due threads esegue per conto suo, in modo asincrono, indipendentemente l'uno dall'altro.
Per accodare un messaggio viene richiamato il metodo BlockingQueue.put il quale è implementato più o meno così:
la coda, come tutti i containers di Kava sono in ultima analisi implementate come una array di elementi di dimensioni calcolate in modo approssimativo, per eccesso e viene mantenuto un contatore che rappresenta il numero effettivo di elementi presenti nella coda; all'inizio la coda è vuota e counter sarà pari a ZERO. Nel metodo put, l'elemento fornito come argomento viene inserito nella array all'indice espresso dal contatore. SUBITO DOPO l'inserimento dell'elemento nella array, il contatore viene aggiornato: esso rappresenta in ogni momento il numero di elementi effettivamente presenti nel contenitore e l'indice del primo elemento libero.
Notate come ho evidenziato le parole "SUBITO DOPO". Questo è il nocciolo della questione. Supponiamo che proprio quando il metodo ha aggiunto l'elemento alla array ma prima dell'aggiornamento del contatore, il thread secondario esegue la istruzione peek e preleva il primo ed unico elemento presente nella coda.
Un elemento in coda esiste ma finchè il contatore non viene aggiornato è come se non esistesse.
Quello che è accaduto è uno dei tre gravi problemi che si devono affrontare quando si ha a che fare con i threads. Il problema che ho descritto ora si chiama memory inconsistency (=inconsistenza della memoria) ed è dovuta al fatto che la maggior parte delle operazioni eseguite in una applicazione non sono atomiche, vale a dire possono essere interrotte in qualsiasi momento da un altro thread proprio nel bel mezzo di un metodo che sta aggiornando i dati interni in seguito ad una operazione.
Appare chiaro che le due istruzioni che aggiornano la array ed il contatore sono critiche: non devono assolutamente essere interrotte da un altro thread che legge la coda o, peggio, che aggiunge un altro elemento alla coda.
Quello che ci serve per risolvere il problema della inconsistenza di memoria è la sincronizzazione tra i due metodi addMessage, che inserisce un messaggio in coda, e doInBackground che preleva il messaggio dalla coda: la esecuzione dell'uno preclude la esecuzione dell'altro.
Il linguaggio Java mette a disposizione due semplici costrutti per la sincronizzazione delle operazioni:
I metodi sincronizzati acquiscono un blocco sull'oggetto (non sulla classe, ma sull'oggetto reale) col quale vengono richiamati. Tutti gli altri metodi sincronizzati in altri thread vengono bloccati, in attesa che il blocco venga rilasciato. Per definire un metodo sincronizzato si usa la parola chiave synchronized come attributo del metodo stesso:
Facile,no? Siete tentati di fare lo stesso per il metodo concorrente, vero?
Sarebbe un grave errore. Come detto poc'anzi, il metodo acquisisce il blocco automaticamente, subito prima di essere eseguito e lo rilascia solo quando il metodo termina: ma il metodo doInBackground non termina MAI (salvo cancellazione, ma a quel punto la applicazione è terminata).
Il risultato è che il metodo addMessage resta in attesa dello sblocco all'infinito e, dall'altra parte, il metodo doInBackground resterà in attesa di un messaggio da inviare all'infinito; quello che abbiamo ottenuto è il secondo grave problema di avere a che fare con i threads: il cosidetto deadlock (=il blocco della morte).
La soluzione a questo problema è quella di usare le istruzioni sincronizzate anzichè l'intero metodo; quello che ci serve sincronizzare non è l'intero metodo doInBackground ma solo una piccola parte di codice, quello tra le istruzioni peek e poll:
Il costrutto synchronized(this) acquisisce un blocco sull'oggetto per il quale il metodo doInBackground è stato richiamato (this) e con questi due accorgimenti abbiamo ottenuto che:
addMessage non potrà essere eseguito finchè il blocco di codice sincronizzato del metodo doInBackground, se in esecuzione, non sarà terminato doInBackground non potrà essere eseguito finchè il metodo addMessage, se in esecuzione, non sarà terminatoAvrete notato dai sorgenti SendQueue.java che le istruzioni di cincronizzazione dei metodi che ho esposto più sopra sono commentate; in realtà non sono affatto operative. Questo è dovuto al fatto che tutte le implementazioni Java della interfaccia BlockingQueue sono thread-safe (=sicure con i threads) e che i metodi put, peek e poll sono già automaticamente sincronizzati dalla libreria Java.
Per saperne di più sui metodi sincronizzati e su come si creano i blocchi che eseguono la sincronizzazione ho scritto una breve sezione in appendice (vedi Le operazioni atomiche).
La documentazione completa dei sorgenti descritti in questo capitolo può essere visualizzata clikkando il seguente link che riporta alla documentazione Javadoc del progetto: The MasterMind Project - version 0.8
Argomento precedente - Argomento successivo - Indice Generale