ATTENZIONE: il nuovo sito di riferimento per questa guida e altre cose più o meno interessanti è il mio BLOG

Indice
Prossimo capitolo - Test delle unità

Capitolo 8 - Programmazione concorrente

L'esecuzione di un programma java si divide in threads, o "processi leggeri". Questi frammenti di programma possono essere eseguiti indipendentemente (o quasi) dagli altri, ovvero contemporaneamente.

Il comportamento di un thread è implementato nella classe Thread, e l'esecuzione effettiva avviene nel suo metodo run().
Come esempio la seguente classe estende Thread e ridefinisce il metodo run(), in modo che calcoli se un numero è primo o no - un numero è primo se è maggiore di 1 e se è divisibile solo per 1 e per sé stesso:

	public class Primi
		extends Thread
	{
		private int n;

		public Primi(int n)
		{
			super("Computing " + n);	// nome del thread
			this.n = n;
			start();	// fa partire questo thread - chiama run()
		}

		public void run()
		{
			System.out.println(n +
				( primo() ? "" : " non" ) +
				" e' primo.");
		}

		/**
		* Calcola se n e' primo.
		*/
		public boolean primo()
		{
			if( n < 2 )
				return false;	// 1 non e' primo

			for(int i = 2; i < n; i++)
				if( n % i == 0 )
					return false;

			return true;
		}

		public static void main(String... args)
		{
			// crea alcuni thread
			new Primi(100000007);	// questo ci mette un po'
			new Primi(100000081);	// anche questo
			new Primi(1234567891);	// questo di piu'
			new Primi(123456);
			new Primi(4999);
			new Primi(123);

			// qui tutti i thread hanno cominciato l'esecuzione
			System.out.println("Partiti!");
		}
	}
Nel costruttore si chiama super(...) per assegnare un nome al thread, quindi lo si fa partire con start().
Il main stampa - non necessariamente nello stesso ordine:
123456 non e' primo.
4999 e' primo.
Partiti!
123 non e' primo.
100000081 e' primo.
100000007 e' primo.
1234567891 e' primo.
Il programma precedente non termina finché tutti i thread non sono terminati. Volendo un comportamento differente - il programma termina quando termina il main - basterà marcare i thread come demoni, chiamando setDaemon(true) prima di start().

Il metodo yield() permette di "fare una pausa" e informare così lo scheduler che può passare l'esecuzione a un altro thread, ma va usato con cautela; ad esempio, se nella classe precedente si modificasse il ciclo for così:

	for(int i = 2; i < n; i++, yield())
		if( n % i == 0 )
			return false;

si avrebbe un notevole overhead, visto che yield() verrebbe chiamato a ogni ciclo.

È anche possibile impostare una priorità per un thread tramite il metodo setPriority(...); il suo valore, nel caso della JVM della Sun, è compreso tra 1 e 10.

Runnable

Nell'impossibilità di estendere Thread, magari perché si deve estendere un'altra classe, si può implementare l'interfaccia Runnable:

	public class Primi
		implements Runnable
	{
		...

		public void run()
		{
			...
e creare un thread a partire dall'oggetto Runnable:
		...

		public Primi(int n)
		{
			Thread t = new Thread(this, "Computing " + n);

			this.n = n;
			t.start();
		}

		...
In realtà questo è il modo più pulito e quindi preferibile di creare un thread, se non si devono ridefinire altri metodi della classe Thread oltre a run().

Timer

La classe java.util.Timer consente di temporizzare un evento, rappresentato come TimerTask, una classe astratta che implementa Runnable.
Dal punto di vista del programmatore cambia poco, si implementa il metodo run() e si schedula l'evento con varie modalità; ecco un esempio:

	import java.util.*;

	public class Scrittore
		extends TimerTask
	{
		private Timer tim;
		private static int contatore = 0;
		private int volte;
		private String msg;

		public Scrittore(String msg, int sec, int volte)
		{
			this.msg = msg;
			this.volte = volte;

			tim = new Timer();

			tim.schedule(this,	// esegui questo TimerTask...
				0,		// ... da adesso...
				sec * 1000);	// ... ogni 'sec' secondi
		}

		public void run()
		{
			contatore++;

			if(contatore >= volte)
				tim.cancel();	// questa esecuzione di run() e' l'ultima

			System.out.println(msg + " numero: " + contatore);
		}

		public static void main(String[] args)
		{
			// scrivi "Ciao" ogni secondo per 5 volte
			new Scrittore("Ciao", 1, 5);
		}
	}
I metodi schedule(...) di Timer permettono di posticipare un evento, oppure di eseguirlo ripetutamente, a distanza di almeno 'period' millisecondi tra un'esecuzione e l'altra; i metodi scheduleAtFixedRate(...), invece, eseguono il task esattamente ogni 'period' millisecondi, risultando più precisi sul lungo periodo.

Sincronizzare i thread

Molto spesso vari thread devono operare sugli stessi dati, e questo può portare a problemi di inconsistenza. Supponiamo di avere due thread, t1 e t2, che agiscono su un oggetto condiviso; può succedere che lo scheduler trasferisca l'esecuzione da t1 a t2 mentre t1 deve ancora terminare di aggiornare l'oggetto.

Ci sono molti modi per superare questo problema. Se l'oggetto in questione è di un tipo primitivo diverso da long e double l'assegnamento e la lettura sono atomici, nel senso che non possono essere interrotti. Per long e double, essendo "lunghi" 64 bit, l'atomicità non è garantita, ma questo si può risolvere con il modificatore volatile. Bisogna comunque fare attenzione, operazioni semplici come l'incremento, i++, non sono atomiche - in questo caso sono necessari una lettura, un incremento e un assegnamento.

Un'altra soluzione è quella di permettere a un solo thread alla volta di accedere all'oggetto condiviso, isolando il codice corrispondente dentro una regione critica.
Ogni oggetto java ha un monitor, una variabile che permette di realizzare la mutua esclusione (mutex). Tramite il costrutto seguente:

	synchronized(this) {
		// opera sull'oggetto condiviso
	}
si dichiara che il codice compreso nel blocco può essere eseguito da un solo thread alla volta.
Si può sincronizzare anche l'intero corpo di un metodo:
	private synchronized void do()
	{
		...

Quando un thread arriva all'inizio della regione critica si possono verificare due condizioni:
- se il monitor dell'oggetto (in questo caso this) è disponibile significa che nessun altro thread sta eseguendo la regione, quindi il suddetto thread può acquisire il monitor ed entrare nel blocco synchronized;
- se il monitor non è disponibile significa che un altro thread sta eseguendo la regione, quindi il thread corrente deve aspettare finché il monitor non viene rilasciato.

Nel caso l'oggetto in questione sia una collezione è possibile produrne una versione sincronizzata tramite alcuni metodi della classe Collections; ad esempio per avere una lista sincronizzata il codice dovrebbe apparire più o meno così:

	List l = Collections.synchronizedList(new ArrayList());
	...
	synchronized(l) {
		Iterator it = l.iterator();
		while(it.hasNext()) {
			...
		}
	}
da notare che l'iterazione, tramite Iterator o for-each, deve essere comunque sincronizzata sul monitor della collezione stessa - vedi anche le nuove collezioni concorrenti.

E java 1.5?

Come in altre aree la release 5 di Java ha introdotto sostanziali novità anche per quanto riguarda la programmazione concorrente, tramite tre nuovi package, java.util.concurrent e i suoi "amici" atomic e locks. In essi sono state inserite molte nuove classi e interfacce. Nello spirito di questa guida ne presenterò solo alcune, sufficienti però a dare un'idea della loro utilità.

Esecutori

Prima di tutto è presente la nuova interfaccia Callable<V>, con il solo metodo call(), che a differenza di Runnable.run() ritorna un valore di tipo V (usa i generics) e può lanciare eccezioni. Una volta implementata per un tipo specifico (o generico, lasciando indicato V):

	class Calcolatore
		implements Callable<Integer>
	{
		...
		Integer call()
		{
			...
			return risultato;
		}
		...
l'oggetto Callable può essere eseguito da un Executor:
	ExecutorService esecutore =	// sottoclasse di Executor
		Executors.newCachedThreadPool();

	Future<Integer> f =	// vedi anche FutureTask
		esecutore.submit(new Calcolatore());

	... // posso fare altre cose mentre l'oggetto Callable viene eseguito

	System.out.println("Risultato: " +
		f.get());	// attendo il risultato - puo' lanciare eccezioni

	esecutore.shutdown(); // finisci i task e non accettarne altri
Possono risultare utili in questo contesto anche i metodi invokeAll(...) e invokeAny(...) di ExecutorService.

Collezioni concorrenti

L'uso di synchronized è sicuro, ma può portare a un notevole degrado di prestazioni nel caso in cui molti thread si contendano un oggetto condiviso.

Se si intende condividere una collezione tra più thread, nella quale le operazioni in lettura siano molto più numerose delle modifiche, risulta conveniente usare le collezioni concorrenti, che sono thread-safe, ma non sincronizzate. Le classi (generiche) che implementano code, mappe, liste e insiemi concorrenti sono ConcurrentLinkedQueue<E>, ConcurrentHashMap<K,V>, CopyOnWriteArrayList<E> e CopyOnWriteArraySet<E>.

Ci sono poi le code bloccanti - interfaccia BlockingQueue<E> e cinque implementazioni - che consentono di risolvere senza fatica problemi di tipo produttore/consumatore, tramite i metodi put() e take().

Atomic

Nel package java.util.concurrent.atomic sono presenti classi come AtomicInteger, che fornisce metodi per varie operazioni atomiche, come getAndIncrement(), compareAndSet(...), set(...), eccetera.

Altre classi, come AtomicLongArray, rappresentano array sui quali sono possibili analoghe operazioni atomiche.

AtomicMarkableReference<V> e AtomicStampedReference<V> consentono di associare a un oggetto un boolean o un int, rispettivamente.

E gli altri

Infine java.util.concurrent.locks e i due package precedenti comprendono altre classi e interfacce che permettono una maggiore flessibilità nel controllo della concorrenza.



Indice
Prossimo capitolo - Test delle unità