Dopo aver illustrato le direttive di sincronizzazione nel linguaggio Java e il loro uso nel caso del problema del produttore-consumatore, è giunto il momento di introdurre un ultimo meccanismo di sincronizzazione, che permette un‘interazione più complessa tra i thread e gli oggetti condivisi. Con i lock termina questa serie di articoli sulla programmazione concorrente, un argomento peraltro assai vasto e ricco di problematiche che non è stato possibile affrontare in questo contesto
Sincronizzazione attraverso i lock
L‘ultimo meccanismo di sincronizzazione che verrà analizzato è il lock. Il mese scorso è stato analizzato il monitor, un meccanismo che permette a più thread di comunicare attraverso metodi mutuamente esclusivi. Il lock è un meccanismo di sincronizzazione che permette a un thread di bloccare un oggetto per un tempo indefinito, in modo da poterne disporre in modo esclusivo per un certo tempo, e svolgere su di esso una interazione complessa, composta da diverse chiamate.
public class Lock {private boolean locked = false;public synchronized void lock() {while(locked)try {wait();}catch(InterruptedException e) {}locked = true;}public synchronized void unlock() {locked = false;notifyAll();}public void method1() {...}public void method2() {...}...}
Questa classe dispone di una coppia di metodi sincronizzati, lock() ed unlock(), che permettono rispettivamente di bloccare e sbloccare l‘oggetto. Il primo metodo verifica il contenuto della variabile locked: se è true, mette a riposo il thread chiamante, in caso contrario la mette a true e poi ritorna. Il metodo unlock() è elementare: dopo aver posto a false la variabile locked, chiama una notifyAll(). I metodi method1() e method2(), che come si può vedere non sono sincronizzati, servono ad interagire con l‘oggetto dopo averne ottenuto il lock. I meccanismi di locking vengono usati in molte circostanze, ad esempio nei database, dove è possibile effettuare un lock su un record in modo da poter eseguire in pace una serie di query senza correre il rischio che qualcuno nel frattempo cerchi di accedere allo stesso record.
Utilizzare oggetti con lock presenta due problematiche delicate: la prima è che qualunque thread abbia effettuato una lock() su un oggetto, deve prima o poi eseguire una unlock(), altrimenti si corre il rischio che un sistema si blocchi per un errore di programmazione. E‘ possibile realizzare dei lock a tempo, come quelli dei database, che scadono se l‘utente non effettua operazioni per qualche minuto. La creazione di lock a tempo presenta due grossi problemi: il primo è il meccanismo di timeout, il secondo è che prima di effettuare una unlock() a causata dallo scadere del tempo a disposizione è necessario annullare tutte le operazioni svolte nel frattempo sull‘oggetto bloccato (rollover), in modo da riportarlo nello stesso stato in cui si trovava prima di essere bloccato. Queste problematiche vanno ben al di la della portata del presente trattato, e pertanto non verranno approfondite: chi desiderasse affrontare l‘argomento può consultare [3].
Esiste invece un secondo problema legato ai lock, che verrà discusso e risolto nel prossimo paragrafo.
Lock con controllo di prelazione
Uno dei problemi degli oggetti dotati di lock è il rischio che un thread scritto male tenti di chiamare i metodi dell‘oggetto senza prima aver acquisito il lock, o peggio chiami la unlock() su un oggetto bloccato. Per evitare questo tipo di problemi è necessario aggiungere un controllo di prelazione, un meccanismo che garantisca che i metodi dell‘oggetto vengano chiamati unicamente dal detentore del lock. La soluzione a questo tipo di problema è abbastanza semplice, e si appoggia sul metodo statico currentThread() della classe Thread:
public class NonPreemptiveLock {private boolean locked = false;private Thread currentThread = null;public synchronized void lock() {while ( locked )try {wait();}catch (InterruptedException e) {}locked = true;currentThread = Thread.currentThread();}public synchronized void unlock() {if(Thread.currentThread()!=currentThread)throw new Error("Il thread chiamante non detiene il lock");currentThread = null;locked = false;notifyAll();}public void method1() {if(Thread.currentThread()!=currentThread)throw new Error("Il thread chiamante non detiene il lock");...}public void method2() {if(Thread.currentThread()!=currentThread)throw new Error("Il thread chiamante non detiene il lock");...}}
L‘oggetto ora presenta due variabili: una variabile booleana locked e una variabile currentThread di tipo Thread. Il metodo lock() assegna a questa variabile un reference al thread attualmente in esecuzione, che è esattamente il thread che possiede il lock. Tutti i metodi dell‘oggetto a questo punto possono effettuare un controllo su detta variabile, e rifiutare le chiamate effettuate da thread diversi da quello che possiede il lock:
if(Thread.currentThread()!=currentThread)throw new Error("Il thread chiamante non detiene il lock");
Questo semplice accorgimento permette di scoprire facilmente se un programma contiene errori tali da comprometterne il funzionamento.
Uso dei lock: il Bancomat
Un esempio funzionante è sempre necessario per capire il funzionamento di un meccanismo. Si vuole creare una simulazione di un bancomat, che permetta ad un utente dotato di un valido ID (una carta bancomat) di accedere al terminale, chiedere il saldo ed effettuare un prelievo:
public class Bancomat {private int[] accounts;private boolean locked = false;private int userId = -1;private Thread currentThread;public Bancomat(int userCount) {accounts = new int[userCount];for(int i=0;iLa classe Bancomat crea un vettore di interi, ciascuno contenente il saldo di un conto, che viene calcolato come numero casuale tra 100 e 600 euro. Il metodo beginSession(), che corrisponde al metodo lock() degli esempi precedenti, richiede come parametro un intero che identifica il numero di conto su cui si desidera compiere le operazioni. Il metodo endSession() corrisponde al metodo unlock() degli esempi precedenti. I metodi getAmount() e getMoney() effettuano operazioni banali. Si veda ora la classe User:
public class User implements Runnable{private int userId;private Bancomat bancomat;public User(int userId , Bancomat bancomat) {this.userId = userId;this.bancomat = bancomat;}public void run() {try {Thread.sleep((int)(Math.random()*1000));}catch(InterruptedException e) {}bancomat.beginSession(userId);int amount = bancomat.getAmount();System.out.println("L‘utente numero "+userId+" ha un saldo di "+amount+" euro.");int money = bancomat.getMoney((int)(amount/10));System.out.println("L‘utente numero "+userId+" ha prelevato " + money + " euro.");bancomat.endSession();}}Il metodo run() contiene il codice di una tipica transazione con il bancomat. Anzitutto il thread viene messo a riposo per un tempo casuale tra 0 e 1000 millisecondi: in questo modo viene garantito un ordine di accesso casuale a tutti gli utenti. Le istruzioni successive accedono al bancomat, chiedono il saldo, prelevano il 10% del totale (arrotondato all‘intero più vicino) e infine rilasciano il bancomat. L‘ultima classe dell‘esempio crea il sistema e lo avvia:
public class BancomatExample {public static void main(String argv[]) {int max = 500;Bancomat b = new Bancomat(max);for(int i=0;iIn questo caso vengono creati un bancomat e 500 utenti. L‘output del programma è simile al seguente:
L‘utente numero 33 ha un saldo di 226 euro.L‘utente numero 33 ha prelevato 22 euro.L‘utente numero 45 ha un saldo di 285 euro.L‘utente numero 45 ha prelevato 28 euro.L‘utente numero 44 ha un saldo di 592 euro.L‘utente numero 44 ha prelevato 59 euro.L‘utente numero 57 ha un saldo di 489 euro.L‘utente numero 57 ha prelevato 48 euro.L‘utente numero 7 ha un saldo di 225 euro....Si noti l‘ordine casuale con cui i vari utenti hanno accesso all‘oggetto condiviso. Si invita il lettore a modificare l‘esempio aumentando il numero dei clienti, aggiungendo metodi al Bancomat o creando interazioni più complesse tra User e Bancomat.
Conclusioni
La programmazione concorrente è una delle sfide più difficili per un programmatore. Il presente trattato non ha certo potuto in alcun modo esaurire l‘argomento, che occupa interi libri sia generali [1] che riferiti al solo Java [2]. La letteratura sull‘argomento presenta molte altre sfide, tra le quali il problema dei lettori-scrittori e quello dei filosofi a cena (non è uno scherzo!). La programmazione concorrente presenta inoltre problematiche che qui non sono state neppure accennate, come il deadlock ("abbraccio mortale"), che descrive le situazioni in cui il sistema entra in stallo perché ad esempio una coppia di thread richiede per proseguire ciascuno una risorsa bloccata dall‘altro, o la starvation (morte per fame), che si verifica quando un thread entra in un blocco dal quale non riesce più ad uscire. I casi presentati in questo trattato possono tornare utili in moltissime situazioni, e non dovrebbero dar luogo a simili problemi, se seguiti alla lettera.
Riferimenti
[1]
Gregory R. Andrews. "Concurrent Programming: Principles and Practice" Paperback Ed.[2]
Doug Lea, "Concurrent Programming in Java(TM): Design Principles and Pattern", (2nd Edition), Paperback Ed.[3]
P. Atzeni - S. Ceri - S. Paraboschi - R. Torlone, "Basi di dati: modelli e linguaggi di interrogazione" McGraw- Hill Italia, 2002