Metodologie di Programmazione: Lezione 13

Riccardo Silvestri

Threads

Usualmente si scrivono programmi che operano in modo sequenziale, un passo dopo l'altro. Le istruzioni sono cioè eseguite nell'ordine dettato dal flusso del programma. In un computer, una sequenza di passi eseguiti uno dopo l'altro è chiamata thread. Finora abbiamo considerato programmi eseguiti nel modello di programmazione single-threaded, cioè con un solo thread di esecuzione. Ma ci sono molte situazioni in cui l'uso di più thread di esecuzione è conveniente e a volte indispensabile. Si pensi ad esempio ad una applicazione con GUI che può mostrare animazioni e video, se questi non fossero eseguiti in thread di esecuzioni separati da quello che gestisce l'interazione con l'utente, l'applicazione potrebbe risultare scarsamente reattiva agli input dell'utente. Un'applicazione che deve rispondere a eventi o a comunicazioni remote, come ad esempio un server web, generalmente usa centinaia o migliaia di thread di esecuzione per gestire le diverse richieste. Un web browser usa molti thread simultaneamente per scaricare il contenuto di una pagina (immagini e altro) nel più breve tempo possibile.

Su una macchina monoprocessore le esecuzioni dei thread si intrecciano tra loro con rapidi scambi tra i rispettivi flussi di esecuzione. Però in ogni dato istante, al più un solo thread è in esecuzione. Su una macchina multiprocessore o multicore, più thread possono essere in esecuzione simultaneamente. Mentre un thread è eseguito su un processore, un'altro thread è eseguito su un altro processore. Quindi nelle moderne macchine multicore l'esecuzione dei thread più avvenire in parallelo offrendo così un ulteriore motivo per l'uso di più thread, cioè la possibilità di velocizzare le computazioni.

Purtroppo questi potenziali benefici non sono, in generale, gratis. La programmazione di un'applicazione che vuole sfruttare al meglio i possibili vantaggi offerti dall'uso di più thread può non essere affatto facile. Come avremo modo di vedere la programmazione concorrente (o multithreading), cioè la programmazione di più thread di esecuzione, può essere molto più delicata e complessa di quella di un singolo thread.

In questa prima lezione dedicata alla programmazione concorrente in Java, introduciamo solamente i concetti di base corredati da semplici esempi. Nelle successive lezioni approfondiremo l'argomento e discuteremo esempi più significativi.

Creare thread

Quando la JVM viene lanciata, generalmente, c'è un unico1 thread di esecuzione che invoca il metodo main di una classe. Tutti i programmi che abbiamo visto finora sono eseguiti interamente in questo thread che è detto appunto main thread. Tuttavia, durante l'esecuzione il programma può creare altri thread esplicitamente o implicitamente. Ad esempio, come vedremo in una prossima lezione, l'uso di una GUI comporta la creazione implicita di uno o più thread di esecuzione che non sono direttamente visibili dal main thread. La creazione esplicita di un thread è effettuata tramite la classe Thread in java.lang. Ogni oggetto di tipo Thread rappresenta un thread di esecuzione. Per specificare il programma che deve essere eseguito nel thread ci sono vari modi, quello preferibile è definire il programma tramite l'interfaccia Runnable, in java.lang, e creare il thread tramite il costruttore:

Thread(Runnable target)
Crea un oggetto Thread che quando verrà fatto partire invocherà, nel nuovo thread, il metodo run dell'oggetto target.
L'interfaccia funzionale Runnable ha appunto l'unico metodo void run() che serve proprio a definire un metodo che esegue un programma o task, generalmente, in un thread di esecuzione separato. Quindi i passi per creare un nuovo thread ed eseguirlo sono i seguenti.

  1. Creare un oggetto di tipo Thread impostando il task che deve essere eseguito ed eventualmente altre proprietà del thread.
  2. Iniziare l'esecuzione del thread invocando il metodo void start(). L'invocazione del metodo start ritorna immediatamente, cioè non attende che l'esecuzione del metodo run del nuovo thread abbia termine. L'esecuzione di run avverrà in modo concorrente alla continuazione dell'esecuzione del thread che ha iniziato il nuovo thread (e di eventuali altri thread).

Vediamo subito un semplice esempio in cui dal main thread viene creato e iniziato un nuovo thread che stampa 50 linee di testo e allo stesso tempo anche il main thread stampa 50 linee di testo.

/** Classe per semplici esempi d'uso di threads */
public class TestThread {
    public static void main(String[] args) {
        Runnable task = () -> {        // Task da eseguire in un nuovo thread
            for (int i = 0 ; i < 50 ; i++)
                out.println("Nuovo "+i);
        };
        Thread t = new Thread(task);   // Crea un nuovo thread
        t.start();                     // Inizia l'esecuzione del nuovo thread
        for (int i = 0 ; i < 50 ; i++)
            out.println("MAIN "+i);
    }
}

Il risultato potrebbe essere qualcosa del tipo:

Nuovo 0
Nuovo 1
Nuovo 2
Nuovo 3
Nuovo 4
Nuovo 5
Nuovo 6
Nuovo 7
Nuovo 8
Nuovo 9
Nuovo 10
Nuovo 11
Nuovo 12
Nuovo 13
Nuovo 14
Nuovo 15
Nuovo 16
MAIN 0
MAIN 1
MAIN 2
Nuovo 17
Nuovo 18
MAIN 3
MAIN 4
MAIN 5
MAIN 6
MAIN 7
MAIN 8
MAIN 9    
. . .

Come si vede i passi del thread principale e del nuovo thread si intrecciano in modo disordinato, mantenendo però l'ordinamento proprio di ogni singolo thread. Probabilmente, ad ogni esecuzione del programma otterremo un intreccio differente dei passi dei due thread. Questo perché l'ordine di esecuzione dei passi di due o più thread dipende da molti fattori e sopratutto dalle sofisticate ottimizzazioni dinamiche operate dal JIT della JVM. Quest'ultime rendono praticamente impossibile, in generale, prevedere quale sarà l'ordine con cui si intrecceranno i passi dei diversi thread.

Vita di un thread

L'esecuzione di un thread inizia con l'invocazione del suo metodo start, il quale a sua volta invocherà il metodo run. Il metodo start di un thread può essere invocato una sola volta, le volte successive lancia l'eccezione IllegalThreadStateException. Quindi un thread può essere usato per eseguire una sola volta un task e non può più essere riusato. Ovviamente il task potrebbe fare qualsiasi cosa anche rimanere in esecuzione continuativamente. Un thread termina quando l'esecuzione del suo metodo run termina in un qualsiasi modo (eseguendo un return, raggiungendo l'ultima istruzione o a causa di un'eccezione non catturata).

In una versione iniziale di Java è stato introdotto il metodo stop che può essere invocato da un thread per terminare un altro thread. Per motivi di compatibilità è ancora presente ma ormai da molto tempo il suo utilizzo è deprecato (deprecated). Le ragioni sono molteplici, sintetizzando si può dire che il metodo stop interrompe brutalmente un task senza la cooperazione del task stesso e questo può lasciare oggetti o risorse in uno stato inconsistente. Si pensi ad esempio, all'interruzione brutale di un task che sta effettuando una transazione bancaria o una prenotazione di un volo aereo. Nessun programmatore dovrebbe mai usare il metodo stop.

Quindi non c'è nessun modo adeguato per forzare la terminazione di un thread. Però si può chiedere la terminazione invocando il metodo void interrupt(). L'effetto, se il thread sul quale è invocato è ancora in esecuzione, è che un flag interno booleano interrupted status è impostato a true. Il task eseguito dal thread può usare il metodo boolean isInterrupted() per sapere se c'è una richiesta di interruzione del thread ed agire di conseguenza. Ma se il task non fa attenzione all'interrupted status, le richieste di interruzione non avranno effetto.

Vediamo un esempio. Consideriamo un thread contatore che ad ogni secondo stampa il numero di secondi da quando è iniziata la sua esecuzione.

/** Aspetta che siano passati il numero di secondi specificati
 * @param seconds  numero di secondi di attesa */
public static void waitFor(int seconds) {
    long time = System.currentTimeMillis();
    while (System.currentTimeMillis() - time < 1000*seconds) ;
}

public static void main(String[] args) {
    Thread counter = new Thread(() -> {
        int count = 0;
        while (true) {         // Ogni secondo stampa il numero di secondi
            waitFor(1);        // passati dalla partenza del thread
            count++;
            out.println(count);
        }
    });
    counter.start();
    waitFor(4);
    counter.interrupt();
}

Il thread principale fa partire il thread contatore, aspetta per 4 secondi e poi chiede l'interruzione del thread. Se proviamo ad eseguire il programma,

1
2
3
4
5
6
7
8
9
10
. . .

ci accorgiamo che se non lo blocchiamo proseguirebbe per sempre. Affinché il thread sia effettivamente terminabile è necessario che faccia un controllo esplicito sull'interrupted status.

Thread counter = new Thread(() -> {
    int count = 0;
    while (true) {         // Ogni secondo stampa il numero di secondi
        waitFor(1);        // passati dalla partenza del thread
        count++;
        out.println(count);
        if (Thread.currentThread().isInterrupted()) break;
    }
    out.println("Conteggio terminato");
});
counter.start();
waitFor(4);
counter.interrupt();

Per ottenere il Thread che è correntemente in esecuzione si usa il metodo statico static Thread currentThread(). Cioè Thread.currentThread() ritorna l'oggetto Thread del thread che sta eseguendo la detta invocazione. Provando la nuova versione del programma otteniamo,

1
2
3
4
Conteggio terminato

Ora il thread contatore non appena si accorge che c'è una richiesta di interruzione e termina la propria esecuzione.

L'implementazione che abbiamo dato del contatore non è soddisfacente perché spreca la maggior parte del tempo di esecuzione nelle attese. Durante le attese non fa nulla di significativo e quindi toglie tempo di esecuzione ad eventuali altri thread. Per casi come questi è conveniente usare il metodo statico

static void sleep(long millis) throws InterruptedException
Causa l'addormentamento del thread corrente per il numero specificato di millisecondi. Se durante l'addormentamento è richiesta l'interruzione del thread, lancia l'eccezione controllata InterruptedException.

Se un thread deve mettersi in attesa per un tempo prestabilito è bene che usi il metodo sleep perché così dà la possibilità alla JVM di tenerne conto per dare più tempo all'esecuzione di altri thread. Inoltre non c'è bisogno di controllare l'interrupted status, perché se c'è una richiesta di interruzione anche se è stata fatta prima di invocare lo sleep quest'ultimo lancerà InterruptedException.

Thread counter = new Thread(() -> {
    int count = 0;
    while (true) {            // Ogni secondo stampa il numero di secondi
        try {                 // passati dalla partenza del thread
            Thread.sleep(1000);
        } catch (InterruptedException e) { break; }
        count++;
        out.println(count);
    }
    out.println("Conteggio terminato");
});
counter.start();
waitFor(4);
counter.interrupt();

Ricapitolando un thread può essere in uno dei seguenti sei stati.

New
Quando il thread è stato creato, ad esempio con new, ma non è stato ancora invocato il metodo start.
Runnable
Dopo che è stato invocato il metodo start, il thread è eseguibile. Ma dipende dal sistema operativo e dalla JVM se è in esecuzione attualmente. Si trova in questo stato se è in esecuzione o non è in esecuzione ma è eseguibile, cioè pronto per essere eseguito.
Blocked
Quando è bloccato da una richiesta per ottenere un lock2 che è attualmente detenuto da qualche altro thread. Uscirà dal blocco quando il lock sarà rilasciato e sarà acquisito dal thread.
Waiting
Quando è in attesa che una certa condizione sia soddisfatta tramite ad esempio il metodo wait. Uscirà da questo stato quando ci sarà una notifica circa il soddisfacimento della condizione.
Timed waiting
Quando è in attesa dopo l'invocazione di un metodo con un parametro di timeout, come ad esempio sleep.
Terminated
Quando l'esecuzione del run termina o normalmente o a causa di un'eccezione non catturata. Una volta che un thread entra in questo stato, vi rimane per sempre.

Gli stati New e Terminated sono, rispettivamente, lo stato iniziale e quello terminale, mentre gli altri sono stati intermedi. Durante la sua vita un thread può passare più volte per ognuno degli stati intermedi. Quando un thread si trova in Blocked, Waiting o Timed waiting, è temporaneamente inattivo, non è in esecuzione e il suo consumo di risorse è minimo.

Reattività

Uno dei più importanti motivi che rendono la programmazione multithreading conveniente, se non indispensabile, è il mantenimento della reattività (responsiveness) della UI, cioè dell'interfaccia utente. Si immagini un'applicazione che su richiesta dell'utente deve eseguire un operazione che può richiedere molto tempo (ad es. a causa di calcoli molto intensi o per l'accesso a risorse remote), se usasse un unico thread l'utente non potrebbe interagire con la UI mentre è in atto l'operazione, né potrebbe interrompere l'operazione stessa. Come minimo occorrono due thread, uno per gestire la UI e l'altro per eseguire l'operazione.

In seguito vedremo esempi riguardanti interfacce grafiche. Adesso mostriamo un semplice esempio relativo all'interfaccia testuale. Consideriamo un'applicazione che permette all'utente di digitare un intero n e di ottenere l'n-esimo numero di Fibonacci Fn, anche per valori di n molto grandi. Per poter calcolare anche solo F200 il tipo long non è sufficiente, occorre la precisione illimitata dei BigInteger in java.math. Ovviamente le operazioni aritmetiche con il tipo BigInteger sono significativamente più lente che con il tipo long. Quindi se chiediamo alla nostra applicazione di calcolare F100000 o peggio F1000000, queste operazioni potrebbero richiedere molto tempo. Perciò il thread principale sarà usato per gestire l'interazione con l'utente (lettura di ciò che viene digitato) e un altro thread sarà usato per calcolare Fn.

public static void fibonacci() {
    Scanner input = new Scanner(System.in);
    boolean quit = false;
    out.println("Digita n per calcolare F_n o 0 per terminare");
    Thread comp = null;
    while (!quit) {
        try {
            long n = input.nextLong();
            if (n > 0) {
                if (comp != null) comp.interrupt();
                comp = new Thread(() -> {
                    BigInteger a = BigInteger.valueOf(0);
                    BigInteger b = BigInteger.valueOf(1);
                    for (long i = 1 ; i < n ; i++) {
                        BigInteger c = b.add(a);
                        a = b;
                        b = c;
                        if (Thread.interrupted()) {
                            out.println("Calcolo di F_"+n+" interrotto");
                            return;
                        }
                    }
                    out.println("F_"+n+" = "+b);
                });
                comp.start();
            } else
                quit = true;
        } catch(Exception e) { break; }
    }
    out.println("Fine");
}

Si noti che abbiamo usato il metodo statico Thread.interrupted() invece del metodo Thread.currentThread().isInterrupted(). L'unica differenza tra i due metodi è che il primo re-imposta l'interrupted status sempre a false dopo la lettura. Provando fibonacci() potremmo avere,

Digita n per calcolare F_n o 0 per terminare
100
F_100 = 354224848179261915075
200
F_200 = 280571172992510140037611932413038677189525
1000
F_1000 = 43466557686937456435688527675040625802564660517371780402481729089536555
         41794905189040387984007925516929592259308032263477520968962323987332247
         1161642996440906533187938298969649928516003704476137795166849228875
1000000
5
Calcolo di F_1000000 interrotto
F_5 = 5
0
Fine

Nelle prossime lezioni vedremo molti altri esempi d'uso della programmazione multithreading o concorrente.

Esercizi

[Balance]    Scrivere un programma che dati due interi nt e n, crea nt thread che fanno tutti lo stesso calcolo: determinano se n è un numero primo o meno. Ogni thread quando termina stampa il suo nome, il risultato del test di primalità e il tempo in millisecondi che ha impiegato. Il nome di un thread si ottiene tramite il metodo String getName(). Per il test di primalità si può usare il metodo mp.util.Utils.prime. Provare il programma con primi grandi, ad es. 1_000_000_000_000_000_003L e variando il numero nt di thread. I tempi dei diversi thread sono simili? Come variano al variare di nt?

[SleepOrNotSleep]    Scrivere un programma che dato un booleano s e un intero k, crea k thread di tipo contatore (come quelli sopra) e se s è true usano il metodo sleep altrimenti usano il metodo waitFor, e crea un thread come quelli dell'esercizio precedente (con input un primo grande). Provare il programma variando s e k. Che differenze si osservano? A cosa sono dovute?

[Fibonacci+]    Modificare il programma fibonacci in modo tale che possa mantenere in esecuzione fino a K thread per il calcolo di numeri di Fibonacci, dove K è un parametro fissato. Il programma dato sopra è relativo a K = 1. Quando l'utente chiede di effettuare un nuovo calcolo, il programma controlla quanti thread sono attualmente in esecuzione, se sono meno di K ne crea uno per il nuovo calcolo, altrimenti prima di creare il nuovo thread interrompe quello più vecchio in esecuzione. Per determinare se un thread è vivo (cioè non è ancora terminato) si può usare il metodo boolean isAlive().

19 Apr 2015


  1. In realtà possono esserci anche altri thread attivi ma sono di un tipo speciale.

  2. Tratteremo i lock più avanti, per adesso diciamo che servono a restringere l'accesso ad una risorsa in modo che un solo thread alla volta possa operare su di essa.