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;i<userCount;i++)
accounts[i] = 100 + (int)(Math.random()*500);
}
public synchronized void beginSession(int userId) {
while(locked)
try {
wait();
}
catch(InterruptedException e) {}
locked = true;
this.userId = userId;
currentThread = Thread.currentThread();
}
public int getAmount() {
if(Thread.currentThread()!=currentThread)
throw new Error("L'utente non detiene il lock");
return accounts[userId];
}
public int getMoney(int amount) {
if(Thread.currentThread()!=currentThread)
throw new Error("L'utente non detiene il lock");
accounts[userId] = accounts[userId] - amount;
return amount;
}
public synchronized void endSession() {
if(Thread.currentThread()!=currentThread)
throw new Error("L'utente non detiene il lock");
userId = -1;
currentThread = null;
locked = false;
notifyAll();
}
}
La
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;i<max;i++) {
User u = new User(i,b);
Thread t = new Thread(u);
t.start();
}
}
}
In
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
richiedono 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.
Bibliografia
[1] Concurrent Programming: Principles and Practice
Gregory R. Andrews Paperback Ed.
[2]
Concurrent Programming in Java(TM): Design Principles
and Pattern (2nd Edition) Doug Lea Paperback Ed.
[3] Basi di dati: modelli e linguaggi di interrogazione
P. Atzeni, S. Ceri, S. Paraboschi, R. Torlone McGraw-
Hill Italia, 2002.
|