Thread in Java

Quando la mattina facciamo colazione gestiamo di gestire il tempo in modo efficiente: mentre si scalda il caffè mettiamo il pane e la marmellata in tavola, se ancora non è pronto il caffè cominciamo a mangiare. Non si può dire però che i programmi che abbiamo scritto finora siano efficienti: prima di eseguire una funzione aspettiamo sempre che quella prima sia terminata, è come se prima di cominciare a mangiare pane e marmellata aspettassimo che il caffè sia pronto, per poi arrivare in ritardo a scuola.

In programmazione il fare due cose insieme è detta programmazione concorrente e per farla si usano i Thread.

Cosa sono i Thread e quando usarli

MS-DOS, il primo sistema operativo di Microsoft, ti si presentava con una riga di comando in bianco e nero dove potevi eseguire un solo programma alla volta: era single process. Con Windows 10 invece puoi programmare, guardare stackoverflow e ascoltare spotify nello stesso momento, perché è un sistema operativo multi process.

Un Thread è una parte di un programma in esecuzione, visto dal sistema operativo come una serie di dati e istruzioni da eseguire. Allo stesso modo dei processi MS-DOS è single threaded e Windows 10 è multi threaded. Se suddividiamo quindi un programma in più thread, un sistema operativo moderno li eseguirà in contemporanea.

Un programma con un'interfaccia grafica dev'essere multithreaded: non possiamo bloccare l'interfaccia grafica per 10 secondi se dietro al click di un bottone abbiamo un'operazione di 10 secondi!

Prendiamo per esempio spotify: avremo un thread che gestisce la musica in streaming, uno per l'interfaccia utente, uno che scarica le informazioni dei brani dal server e così via...

Allo stesso modo se un'operazione impiega 20 secondi per essere completata e un'altra ne impiega 40 (e non sono dipendenti tra di loro) perchè aspettare un minuto? Mettiamole in due Thread diversi ed eseguiamole in contemporanea!

Ciclo di vita di un Thread

Il ciclo di vita di un thread in Java segue questa procedura:

  • Un thread viene inizializzato con lo stato New al momento dell'istanziazione.
  • Quando viene chiamato start() il thread passa allo stato Runnable e spetta al sistema operativo schedularlo per l'esecuzione.
  • Nel momento in cui il sistema operativo decide di avviarlo il thread passa allo stato Running e viene eseguito il codice della funzione run().
  • Se il thread viene interrotto si passa allo stato Waiting.
  • Quando termina la funzione run() o quando viene chiamato stop() il thread passa allo stato Terminated e il suo ciclo di vita termina.

Ciclo di vita thread

Usare i Thread in Java

In Java si possono usare i Thread in due modi: implementando Runnable o estendendo Thread.

All' interno della funzione run() metteremo il codice che vogliamo far eseguire al thread.

//Implementando Runnable

public class ProvaThread implements Runnable {

    public void run() { 
        System.out.println("Ciao da un thread!");
    }
}

public class Main {
    public static void main(String[] args){
        
        ProvaThread codice = new ProvaThread();
        Thread t = new Thread(codice);
        t.start();
    }
}
//Estendendo Thread

public class ProvaThread extends Thread {

    public void run() { 
        System.out.println("Ciao da un thread!");
    }
}

public class Main {
    public static void main(String[] args){
        
        ProvaThread t = new ProvaThread();
        t.start();
    }
}

Scriviamo un semplice programma che dimostra l'esecuzione in parallelo di due thread.

public class ProvaThread extends Thread {

    public void run() {
        
        try {

            for (int i = 0; i < 10; i++) {

                System.out.println("[Thread " + Thread.currentThread().getId() + "] contatore: " + i);
                Thread.sleep(200);
            }
            
        } catch (InterruptedException e) {
            System.out.println("Thread Interrotto");
        }
    }
}

public class Main {
    public static void main(String[] args){
        
        ProvaThread t1 = new ProvaThread();
        ProvaThread t2 = new ProvaThread();

        t1.start();
        t2.start();
    }
}

Possiamo prevedere che un Thread ProvaThread termini in circa due secondi dato che nel for è presente un Thread.sleep() di 200 millisecondi da moltiplicare per 10 iterazioni. Avremo inoltre modo di vedere l'id del thread nel sistema operativo assieme a il valore del contatore. Mi aspetto che il programma termini in circa due secondi e che le stampe dei due thread siano mischiate tra loro.

[Thread 16] contatore: 0
[Thread 17] contatore: 0
[Thread 16] contatore: 1
[Thread 17] contatore: 1
[Thread 17] contatore: 2
[Thread 16] contatore: 2
[Thread 17] contatore: 3
[Thread 16] contatore: 3
[Thread 17] contatore: 4
[Thread 16] contatore: 4
[Thread 17] contatore: 5
[Thread 16] contatore: 5
[Thread 17] contatore: 6
[Thread 16] contatore: 6
[Thread 17] contatore: 7
[Thread 16] contatore: 7
[Thread 17] contatore: 8
[Thread 16] contatore: 8
[Thread 17] contatore: 9
[Thread 16] contatore: 9

Come puoi osservare i due thread vengono eseguiti in parallelo: terminando in circa 2 secondi anziché quattro e l'output è mescolato.

Race condition e sincronizzazione

Cosa succede se condividiamo dati tra due thread? Se non stiamo attenti potremmo trovarci davanti ad una race condition, ovvero quando thread diversi competono per modificare uno stesso dato provocando funzionamenti anomali o peggio il deadlock, che si verifica quando dei thread si attendono l'un l'altro bloccando l'esecuzione.

Una race condition si verifica quando due o più thread modificano la stessa variabile senza fare nessun controllo.

In questo esempio abbiamo tre thread diversi che aggiungono e poi rimuovono 1 a un contatore inizializzato a 0. Vediamo che succede eseguendo il programma.

public class IncrementaDecrementa implements Runnable {

    private int contatore = 0;

    public void run() {
        
        try {

            contatore++;
            System.out.println("[Thread " + Thread.currentThread().getId() + "] Aggiungo 1 a Contatore: " + contatore);

            Thread.sleep(10);

            contatore--;
            System.out.println("[Thread " + Thread.currentThread().getId() + "] Rimuovo 1 a Contatore: " + contatore);
            
        } catch (InterruptedException e) {
            System.out.println("Thread Interrotto");
        }
    }

    public static void main(String[] args){
        
        Runnable r = new IncrementaDecrementa();

        Thread t1 = new Thread(r);
        Thread t2 = new Thread(r);
        Thread t3 = new Thread(r);

        t1.start();
        t2.start();
        t3.start();
    }
}
[Thread 14] Aggiungo 1 a Contatore: 2
[Thread 13] Aggiungo 1 a Contatore: 3
[Thread 15] Aggiungo 1 a Contatore: 3
[Thread 14] Rimuovo 1 a Contatore: 2
[Thread 13] Rimuovo 1 a Contatore: 1
[Thread 15] Rimuovo 1 a Contatore: 0

Come potete vedere il valore di contatore non rimane solo 1 e 0, questo succede perché il sistema operativo approfitta di quel Thread.sleep(10) per mandare in esecuzione un altro thread, che incrementa di nuovo contatore prima che il suo valore torni a 0.

Per comportamenti non voluti come questo possiamo usare l'istruzione synchronized, che permette ad un solo thread alla volta di accedere ad una funzione o un blocco di codice.

Spostando le due modifiche alla variabile contatore in un blocco synchronized siamo sicuri che verranno eseguite una dopo l'altra senza venir interrotte da altri thread.

public void run() {
        
    try {
        //Un solo thread alla volta può eseguire il codice contenuto in synchronized
        synchronized(this){
            contatore++;
            System.out.println("[Thread " + Thread.currentThread().getId() + "] Aggiungo 1 a Contatore: " + contatore);

            Thread.sleep(10);

            contatore--;
            System.out.println("[Thread " + Thread.currentThread().getId() + "] Rimuovo 1 a Contatore: " + contatore);
        }
    } catch (InterruptedException e) {
        System.out.println("Thread Interrotto");
    }
}
[Thread 13] Aggiungo 1 a Contatore: 1
[Thread 13] Rimuovo 1 a Contatore: 0
[Thread 15] Aggiungo 1 a Contatore: 1
[Thread 15] Rimuovo 1 a Contatore: 0
[Thread 14] Aggiungo 1 a Contatore: 1
[Thread 14] Rimuovo 1 a Contatore: 0
© Nicola Bovolato 2020