L‘obiettivo di questo articolo è quello di tradurre la soluzione a un tipico problema di programmazione concorrente, realizzata in Java, in un‘equivalente Scala. Partendo dalla definizione del problema, ne realizzeremo una prima versione in Java, tramite threading logic e stato condiviso, che poi migreremo in Scala. Avere il riferimento a una soluzione che utilizza concetti (e linguaggio) familiari, potrà essere di aiuto nel capire a differenza filosofica tra i due differenti approcci.
In questo articolo non intendiamo fornire un tutorial introduttivo dedicato agli Actor di Scala: se ne trovano tantissimi validi in rete (e magari ne scriveremo uno ad hoc in un futuro numero di MokaByte). Ci interessa invece fornire una sorta di confronto pratico tra i due mondi: illustriamo un problema, e proponiamo due soluzioni ugualmente valide, in Java e in Scala, che hanno però una filosofia e un approccio diverso. Speriamo in questo modo di far comprendere il succo della questione meglio che con un paragone solo teorico.
Partiamo, ovviamente, dalle specifiche
Come sempre avviene alla partenza di ogni progetto su cui ci capita di lavorare, iniziamo con studiare le chiare e dettagliate specifiche fornite dal cliente su quanto occorre realizzare. Ai fini didattici di questo articolo, il nostro compito consiste nel modellare gli accessi a un florido conto corrente bancario. Tale conto è accessibile contemporaneamente a diverse entità software, di seguito dettagliate.
Persona di successo
Si tratta di un brillante e accattivante personaggio della finanza, dello spettacolo o dello sport. È in pratica il proprietario del conto, sul quale effettua unicamente dei versamenti.
Alimentazione automatica
Poiche’, ahinoi, i soldi non bastano mai, la persona di successo si premura di deviare sul proprio conto, più o meno legalmente, fondi provenienti dalla propria azienda/posto di lavoro.
Coniuge
Legittimo utilizzatore del conto corrente, nonche’ compagno ufficiale della persona di successo, effettua prelievi destinati al benessere familiare, non tralasciando donazioni ad associazioni di volontariato/religiose.
Amante
Si tratta di un utilizzatore estemporaneo del conto, dal quale effettua unicamente prelievi destinati al compiacimento della propria vanità.
Ottemperando al Decreto legislativo 11/04/2006 n. 198 sulle pari opportunità, nessuna assunzione viene fatta circa il sesso dei vari attori coinvolti, ammettendo tutte le combinazioni possibili tra persona di successo, coniuge e amante.
Il conto corrente ha due sole semplici regole da rispettare:
- Non è possibile prelevare una quantità di denaro superiore al saldo presente.
- Non è possibile depositare sforando un limite massimo superiore sul saldo.
Approccio classico: dispieghiamo l’arsenale di java.util.concurrent
Proviamo a realizzare in Java una soluzione del problema sopra descritto. La prima cosa da identificare, in casi come questo, è se ci sono oggetti condivisi in memoria, accessibili contemporaneamente a differenti thread.
Il compito non è particolarmente difficile nel caso specifico: il conto corrente è, per sua natura, un oggetto condiviso. In mancanza di appropriate precauzioni, potrebbero verificarsi sgradevoli race-conditions (ad esempio consentire il prelievo di somme non fisicamente disponibili). Le operazioni rese disponibili dal conto devono quindi essere protette in mutua esclusione.
La soluzione naturale in Java è il ricorso a metodi o blocchi synchronized, tuttavia scegliamo un approccio “più moderno”, utilizzando allo scopo un Lock esplicito come membro privato del conto, da utilizzare all’ingresso delle regioni critiche.
Sistemata la sincronizzazione, dedichiamoci ai meccanismi di collaborazione. Dobbiamo implementare due semplici regole:
- se un thread Amante o Coniuge tenta di prelevare una somma superiore al saldo totale, si deve bloccare in attesa che qualcuno effettui un deposito di importo sufficiente.
- se il thread PersonaDiSuccesso o AlimentazioneAutomatica tenta di depositare sforando il saldo massimo consentito, deve sospendersi in attesa di uno o più prelievi.
Storicamente, la soluzione per simili scenari è l’utilizzo dei metodi notifyAll/wait (all’interno di un blocco synchronized). Tale approccio funziona, è stracollaudato, ma ha un grande difetto: prevede un’unica coda dei thread in attesa. Qualunque thread invochi wait sullo stesso oggetto, finisce nella stessa coda. Non è certamente un problema insormontabile, ma farebbe comodo per noi avere due code distinte, quella dei thread in attesa di prelevare e quella dei thread in attesa di poter depositare. Avere a disposizione due insiemi disgiunti di thread bloccati semplificherebbe la politica di risveglio.
Usare Condition per risolvere il problema
Per ottenere questo risultato, possiamo sfruttare l’oggetto Condition, uno dei costrutti classici della programmazione concorrente, adesso disponibile anche in Java. Perfetto: scelte le soluzioni e definite le strategie, lasciamo spazio al codice.
public class ContoCorrente { public static final float MAX_AMOUNT = 10000; private Lock lock = new ReentrantLock(); private Condition saldoInsufficiente = lock.newCondition(); private Condition troppiSoldi = lock.newCondition(); private Float saldoAttuale; private int inAttesaDiPrelievo, inAttesaDiDeposito; public ContoCorrente(Float saldoAttuale) { this.saldoAttuale = saldoAttuale; } public void prelievo(Float importoPrelievo, String message) { lock.lock(); try { while (saldoAttuale < importoPrelievo) { Logger.log("Troppi pochi soldi sul conto, aspetto un deposito"); inAttesaDiDeposito++; saldoInsufficiente.await(); Logger.log("Riparto - Qualcuno ha depositato"); inAttesaDiDeposito--; } saldoAttuale -= importoPrelievo; if (inAttesaDiPrelievo > 0) { troppiSoldi.signalAll(); } } catch (InterruptedException e) { e.printStackTrace(); } finally { Logger.log("[[[ prelievo di " + importoPrelievo + " completato " + message + " ]]]"); lock.unlock(); } } public void deposito(Float importoDeposito) { lock.lock(); try { while (saldoAttuale + importoDeposito >= MAX_AMOUNT){ Logger.log("Troppi soldi sul conto, aspetto un prelievo"); inAttesaDiPrelievo ++; troppiSoldi.await(); Logger.log("Riparto - Qualcuno ha prelevato"); inAttesaDiPrelievo --; } saldoAttuale += importoDeposito; if (inAttesaDiDeposito > 0){ saldoInsufficiente.signalAll(); } } catch (InterruptedException e) { e.printStackTrace(); } finally { Logger.log("[[[ deposito di " + importoDeposito + " completato ]]]"); lock.unlock(); } } }
Come si può notare, l’accesso al conto avviene solo dopo averne ottenuto il lock esclusivo. Durante l’esecuzione di prelievo o deposito, a nessun altro thread è consentito l’accesso al conto. Se tentasse di farlo, verrebbe accodato sull’EntrySet del lock e risvegliato dopo che il thread detentore ha chiamato unlock. Ottenuto l’accesso esclusivo al conto, il nostro thread per prima cosa verifica che l’operazione possa avvenire in base al saldo attuale e alla quantità di denaro da depositare o prelevare. Se non è possibile proseguire, il thread deve sospendersi, in attesa che le cose cambino. La sospensione avviene chiamando il metodo await sull’oggetto Condition opportuno (saldoInsufficiente per i consumatori, troppiSoldi per i produttori). L’invocazione di await rilascia atomicamente il lock cui la condition stessa è legata, quindi il conto diviene accessibile ad altri thread. Si noti che Condition, come tutto in Java, estende Object, quindi sono disponibili anche i metodi wait/notify da non utilizzare assolutamente in questo scenario.
Una volta effettuta l’operazione di deposito/prelievo, il thread detentore del lock verifica se ci sono altri thread in attesa di risveglio e, nel caso, invoca signalAll sulla condition opportuna. È assolutamente fondamentale che il test preliminare avvenga in un ciclo while.
I thread per chi preleva…
Modellato il conto, il codice dei vari thread è davvero molto semplificato. Ad esempio, una possibile realizzazione dei thread che prelevano è la seguente:
public abstract class Spendaccione implements Runnable { private ContoCorrente contoCorrente; private float importo; public Spendaccione(ContoCorrente contoCorrente, float importo) { this.contoCorrente = contoCorrente; this.importo = importo; } @Override public void run() { contoCorrente.prelievo(importo, getMessage()); } public abstract String getMessage(); }
public class Amante extends Spendaccione { public Amante(ContoCorrente contoCorrente, float importo) { super(contoCorrente,importo); } @Override public String getMessage() { return "Mi do alla pazza gioia"; } }
public class Coniuge extends Spendaccione { public Coniuge(ContoCorrente contoCorrente, float importo) { super(contoCorrente,importo); } @Override public String getMessage() { return "Penso al bene dei figli"; } }
… e i thread per chi versa sul conto
In maniera del tutto analoga potremmo modellare la “persona di successo” e “l’alimentazione automatica”. La soluzione Java non è troppo complessa, tuttavia richiede molta cautela e buona padronanza dei concetti generali di programmazione concorrente, soprattutto per quanto concerne la difesa dalle famigerate race conditions. Non basta scrivere ovunque synchronized e sperare che le cose vadano per il meglio, soprattutto perche’ bug in scenari concorrenti sono difficili da individuare e riprodurre.
Una scala verso l’ignoto
Ora che la soluzione Java è pronta, testata e, si spera, priva di errori, lasciamo le tranquille familiari acque del nostro linguaggio preferito e avventuriamoci nelle ignote profondità di Scala. Essendo quest’ultimo un linguaggio molto ricco, fin troppo a volte, non sarebbe difficile realizzare la stessa identica soluzione. In Scala esiste synchronized, ci sono i locks, abbiamo a disposizione persino un package scala.concurrent. Non è tuttavia quello cui siamo interessati; oltre che cambiare linguaggio, vogliamo mutare l’intero paradigma di programmazione concorrente. E per ottenere questo…
…andiamo a scuola di recitazione
Fedeli alle premesse, introduciamo i concetti generali indispensabili alla comprensione di quanto ci occorre. Un attore, nella sua definizione più semplice, è un’entità software destinata a scambiare messaggi. Ancora meglio, un attore interagisce con gli altri attori spedendo e ricevendo messaggi (la purezza del cinema muto, tanto cara a certi cinefili, non è applicabile al rumoroso mondo software).
I messaggi spediti a un attore vengono accodati in una mailbox, dalla quale vengono estratti ed elaborati. La garanzia offerta da Scala è che l’elaborazione dei messaggi sia strettamente sequenziale: solo un messaggio alla volta viene gestito, anche se l’ordine in cui questo avviene non è specificabile a priori. In altri termini, l’operazione di ricezione (estrazione dalla mailbox) da parte del singolo attore è garantita essere safe.
Il mestiere dell’attore: spedire e ricevere messaggi
In Scala esistono due modi di spedire un messaggio a un attore, sincrono e asincrono (e su questo non occorre dilungarsi troppo, bisogna semplicemente scegliere tra due metodi da invocare) e due modalità di ricezione. Il modo in cui un attore Scala decide di ricevere un messaggio ha invece un impatto profondo sull’intero sistema, in termini di scalabilità.
Senza tirarla troppo per le lunghe, dentro il metodo act di Actor (l’equivalente del run di Thread) un messaggio può essere ricevuto invocando receive oppure react. Benche’ la semantica sia abbastanza simile (sono entrambe operazioni bloccanti fino alla ricezione di un messaggio destinato all’attore che l’ha invocata) il comportamento è drasticamente divergente.
L’utilizzo di receive impone infatti che all’attore sia assegnato uno specifico thread; fin quando un messaggio non arriva, tale thread è bloccato. L’invocazione di receive assomiglia invece molto di più a un listener di eventi. L’attore si blocca in attesa di un messaggio, ma in quel frangente non occupa nessun thread. Il thread in esecuzione in quel momento viene infatti rilasciato e reso disponibile a eventuali altri attori in esecuzione. Alla ricezione del primo messaggio, uno dei thread disponibili, non necessariamente lo stesso in cui è stata invocata react, è assegnato all’attore, che riprende l’esecuzione.
In realtà la scelta tra i due modelli comporta anche altre valutazioni e differenze, ad esempio nell’utilizzo dei cicli per rendere effettivo il comportamento di react. Se tuttavia ci addentrassimo nei dettagli, tradiremmo la premessa iniziale. Fermiamoci dunque qua e proviamo a scrivere la simulazione in Scala.
Come è strutturato l’esempio in Scala
Per prima cosa, identifichiamo quali sono gli attori coinvolti. La cosa più semplice da riconoscere è che ciascun thread Java della nostra simulazione diventa un’istanza di Actor. Cosa dire del ContoCorrente? In base a quanto detto finora, deve diventare anch’esso un Actor, e le operazioni su di esso si trasformano, da invocazione diretta di metodi a invio/ricezione di messaggi appositi.
Definiti gli attori, più semplice è individuare i messaggi che essi si scambiano. Sicuramente, le due operazioni principali da modellare, deposito e prelievo, si tradurranno in due messaggi, da implementare attraverso una semplice case class Scala. Inoltre, poiche’ non stiamo invocando dei metodi, occorre un messaggio di risposta da parte dell’attore ContoCorrente che testimoni l’operazione andata a buon fine. Anche questo può essere rappresentato da un’istanza di una case class, chiamiamola OperazioneEffettuata.
Bene, siamo ora arrivati alla parte difficile. Come fare a rappresentare la condizione di operazione non disponibile, ossia creare un meccanismo che abbia la stessa semantica di wait/notifyAll ma evitando blocchi su uno stato condiviso? Sul lato dei clienti del ContoCorrente la cosa è abbastanza semplice: sia Amante sia Coniuge, dopo aver inviato il messaggio di richiesta operazione, si devono bloccare in attesa del messaggio di conferma. Il meccanismo di wait è quindi realizzato semplicemente attraverso una receive.
Le cose si complicano con le notifiche successive a una condizione di operazione non disponibile. Ad esempio, un amante spedisce il messaggio di prelievo, per un importo al momento non disponibile. L’attore/thread Amante si blocca in attesa del messaggio di conferma, come appena detto, ma cosa deve fare l’attore ContoCorrente? Assodato che non può rispondere con il messaggio di operazione effettuata, dovrà aspettare che un altro attore mandi un messaggio di versamento che vada a buon fine. Proprio in questo differimento sta la principale difficoltà. Infatti un qualunque messaggio (in Scala) si porta dietro un riferimento al sender, che può essere utilizzato per un messaggio di risposta. Se l’elaborazione del messaggio, e la conseguente risposta, avvengono tuttavia in momenti diversi, è evidente che il riferimento al sender originario dovrà essere memorizzato in un’apposita struttura dal ContoCorrente. Benchè possa sembrare piuttosto complicato, si tratta in realtà di un’operazione piuttosto semplice da implementare, che si porta dietro anche una piacevole conseguenza. A differenza della notifyAll, su cui non abbiamo nessun controllo nella scelta del thread da risvegliare, avere a diposizione una tale lista di attori ci da la possibilità di implementare una politica personalizzata di scheduling in maniera molto semplice.
Lasciamo quindi spazio alleparti più salienti del codice relativo all’esempio (l’intero progetto può essere trovato negli allegati, nel file “esempi_threading.zip” scaricabile dal menu in alto a destra).
object ContoCorrente { val saldoMax: Integer = 10000 } class ContoCorrente(private var saldoAttuale: Integer) extends Actor { private var spendaccioniInAttesa = Map.empty[Spendaccione, Int] private var correntistiInAttesa = Map.empty[Correntista, Int] def act { while (true) { receive { case Prelievo(importo) => Logger.log("[CONTO] Ricevuta richiesta Prelievo per euro " + importo) if (importo <= saldoAttuale) { saldoAttuale -= importo Logger.log("[CONTO] Saldo sufficiente. Prelievo effettuato") sender ! OperazioneEffettuata notificaCorrentistiInAttesa } else { Logger.log("[CONTO] Saldo NON sufficiente. Accodo lo spendaccione") spendaccioniInAttesa = spendaccioniInAttesa + (sender.asInstanceOf[Spendaccione] -> importo) sender ! SaldoInsufficiente } case Deposito(importo) => Logger.log("[CONTO] Ricevuta richiesta Deposito per euro " + importo ) if (importo + saldoAttuale <= ContoCorrente.saldoMax) { saldoAttuale += importo Logger.log("[CONTO] Saldo ok. Deposito effettuato") sender ! OperazioneEffettuata notificaSpendaccioniInAttesa } else { Logger.log("[CONTO] Saldo TROPPO alto. Accodo il correntista") sender ! OperazioneNonDisponibile correntistiInAttesa = correntistiInAttesa + (sender.asInstanceOf[Correntista] -> importo) } case Stop => Logger.log("Sportello chiuso") exit } } } private def notificaSpendaccioniInAttesa: Unit = { for (key <- spendaccioniInAttesa.keys; if spendaccioniInAttesa(key) < saldoAttuale) { saldoAttuale -= spendaccioniInAttesa(key) spendaccioniInAttesa -= key Logger.log("[CONTO] Saldo AUMENTATO. Prelievo sbloccato.") key ! OperazioneEffettuata } } private def notificaCorrentistiInAttesa: Unit = { for (key <- correntistiInAttesa.keys; if (correntistiInAttesa(key) + saldoAttuale <= ContoCorrente.saldoMax) { saldoAttuale += correntistiInAttesa(key) correntistiInAttesa -= key Logger.log("[CONTO] Saldo DIMINUITO. Deposito sbloccato.") key ! OperazioneEffettuata } } } class Spendaccione (val contoCorrente : ContoCorrente, val importoPrelievo:Integer) extends Actor { var finito = false def act { contoCorrente ! Prelievo (importoPrelievo) while(!finito){ receive { case OperazioneEffettuata => finito = true case SaldoInsufficiente => Logger.log("Non mi danno i soldi - attendo che qualcuno versi") } } } } class Amante(contoCorrente : ContoCorrente, importoPrelievo:Integer) extends Spendaccione(contoCorrente : ContoCorrente, importoPrelievo) class Coniuge(contoCorrente: ContoCorrente, importoPrelievo: Integer) extends Spendaccione(contoCorrente: ContoCorrente, importoPrelievo) class Correntista (val contoCorrente : ContoCorrente, val importoDeposito:Integer) extends Actor { var finito = false def act { contoCorrente ! Deposito (importoDeposito); while(!finito) { receive { case OperazioneEffettuata => finito = true case SaldoEccessivo => Logger.log("Troppi soldi - attendo che qualcuno prelevi") } } } } class PersonaDiSuccesso(contoCorrente: ContoCorrente, importoPrelievo: Integer) extends Correntista(contoCorrente: ContoCorrente, importoPrelievo) class AlimentazioneAutomatica(contoCorrente: ContoCorrente, importoPrelievo: Integer) extends Correntista(contoCorrente: ContoCorrente, importoPrelievo)
Conclusioni
Dopo questa faticaccia, dovrebbe essere quindi chiaro che avere un amante in Scala non è molto più difficile di quanto non lo sia in Java. Anzi, il modello ad attori, una volta comprese le basi, si rivela in realtà essere più semplice da utilizzare e meno incline a insidiosi errori time-depending.
È bene sottolineare come non ci sia nulla di rivoluzionario in questo paradigma; non è stato inventato con Scala, ed esiste anzi da moltissimi anni, anche se per lo più confinato nel mondo accademico. Non è nemmeno, a ben pensarci, troppo complesso da implementare; realizzarlo in Java, che non ne prevede un supporto diretto, potrebbe essere considerato un problema di programmazione concorrente non certo impossibile.
Naturalmente, a costo di attentare alla nostra autostima di programmatori, farselo da soli potrebbe non essere la migliore delle idee. Molto meglio appoggiarsi a un framework come Akka o, ancora meglio, sfruttare le potenzialità ancora appena sfiorate di Scala.
Riferimenti
[1] Allen Holub, “Taming Java Threads”, aPress 2000
[2] Doug Lea, “Concurrent programming in Java Second Edition”, Addison Wesley 2001
[3] Odersky – Spoon – Venners, “Programming in Scala, second edition”, Artima 2010