CodeGym /Corsi /JAVA 25 SELF /Risorse condivise e sincronizzazione: problemi di accesso...

Risorse condivise e sincronizzazione: problemi di accesso

JAVA 25 SELF
Livello 52 , Lezione 0
Disponibile

1. Che cos’è una risorsa condivisa

Con le risorse condivise vi siete già imbattuti. La casa in cui vive una famiglia — è una risorsa condivisa di quella famiglia. Il frigorifero dell’ufficio — è una risorsa condivisa per tutti i colleghi. Penso che l’idea sia chiara.

In programmazione una risorsa condivisa è una variabile, un oggetto o una struttura dati a cui possono accedere contemporaneamente più thread. Può essere:

  • Una variabile contatore del numero di ordini elaborati.
  • Una lista di richieste, popolata da alcuni thread ed elaborata da altri.
  • Un file aperto su cui scrivono più thread.
  • Una connessione al database usata da parti diverse del programma.

In Java qualsiasi oggetto o variabile a cui possano accedere più thread diventa potenzialmente una «risorsa condivisa».

Esempio di risorsa condivisa: contatore globale

public class Counter {
    public int value = 0;
}

Se diversi thread incrementano questo contatore, faranno riferimento alla stessa variabile value — ecco una risorsa condivisa.

2. Problemi dell’accesso concorrente

In un programma monothread è tutto semplice: un thread — un esecutore — procede nel codice come un treno sui binari. Ma non appena entrano in gioco più thread, inizia una vera «danza delle sciabole»: i thread possono interferire l’uno con l’altro nei punti più inaspettati.

Race condition (stato di gara)

Race condition — è una situazione in cui il risultato del programma dipende da come si sono «mescolate» le azioni dei thread. Cioè, se eseguite più volte lo stesso programma, il risultato può essere diverso — e non è un bug, è una «caratteristica» del multithreading.

Esempio classico: due thread incrementano un contatore

Proviamo a simulare una situazione semplice: abbiamo un contatore condiviso e due thread ne incrementano il valore mille volte.

public class Counter {
    public int value = 0;
}

public class CounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable incrementTask = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.value++; // PUNTO PERICOLOSO!
            }
        };

        Thread t1 = new Thread(incrementTask);
        Thread t2 = new Thread(incrementTask);

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

        t1.join();
        t2.join();

        System.out.println("Valore atteso: 2000");
        System.out.println("Valore reale: " + counter.value);
    }
}

Che cosa vedremo sullo schermo?

A volte — 2000, a volte — 1985, a volte — 1937... Perché? Perché l’operazione counter.value++ non è atomica! È composta da tre passi:

  1. Leggere il valore corrente di counter.value.
  2. Incrementarlo di 1.
  3. Scriverlo di nuovo.

Se due thread leggono contemporaneamente lo stesso valore, entrambi lo incrementano e poi entrambi scrivono il risultato — alla fine un incremento «andrà perso». Questo è il lost update — aggiornamento perso.

Stato inconsistente dell’oggetto

Se avete un oggetto complesso composto da più campi e i thread modificano contemporaneamente campi diversi, l’oggetto può trovarsi in uno stato «strano» o inconsistente. Per esempio, il saldo si è ridotto ma lo storico delle operazioni non è stato aggiornato — il cliente va nel panico, il contabile resta scioccato.

3. Perché serve la sincronizzazione

La sincronizzazione — è un modo per dire al programma: «Stop, questo pezzo di codice deve essere eseguito da un solo thread alla volta! Gli altri — aspettino!» È come il cartello «Pulizie! Non entrare!» sulla porta del bagno: finché una persona è dentro, gli altri attendono fuori (e maledicono mentalmente chi ci mette troppo).

Garanzia di integrità dei dati

Se vogliamo che il nostro contatore si incrementi sempre correttamente, dobbiamo vietare la modifica contemporanea del suo valore da parte di più thread.

Esempio: sincronizzare l’incremento del contatore

public class Counter {
    public int value = 0;

    public synchronized void increment() {
        value++;
    }
}

Ora, se due thread chiamano increment(), solo uno di loro potrà eseguire questo metodo in un dato momento. Il secondo attenderà finché il primo non avrà terminato.

Schema: cosa succede durante la sincronizzazione

+-------------------+
|  Thread 1         |    --\
+-------------------+      \
      |                      \
      V                       \
+-------------------+          > [ synchronized increment() ]
|  Thread 2         |    --/  /
+-------------------+      / /
      |                    / /
      V                   / /
+-------------------+    / /
|  Thread 3         | --/ /
+-------------------+    /
      |                 /
      V                /
+-------------------+ /
|  Thread N         |/
+-------------------+

Tutti i thread si mettono in coda per eseguire il segmento di codice protetto. Solo un thread può trovarsi nella «sezione critica» ( synchronized-blocco) contemporaneamente.

4. Breve introduzione ai metodi di sincronizzazione

La sincronizzazione in Java non è un unico metodo, ma un intero «arsenale» di strumenti che permettono di proteggere la risorsa condivisa dall’accesso simultaneo.

Parola chiave synchronized

È lo strumento principale di sincronizzazione in Java. Si può usare in due modi:

Metodo sincronizzato

public synchronized void increment() {
    value++;
}

Blocco sincronizzato

public void increment() {
    synchronized (this) {
        value++;
    }
}

Qui this — è l’oggetto su cui avviene la lock. Finché un thread esegue questo blocco, gli altri thread che vogliono entrare in un blocco identico sullo stesso oggetto attenderanno.

Classi specializzate di java.util.concurrent

  • Lock, ReentrantLock — un’alternativa più flessibile a synchronized.
  • ReadWriteLock — per separare le lock di lettura e di scrittura.
  • Semaphore — per limitare il numero di thread che eseguono il codice contemporaneamente.
  • CountDownLatch, CyclicBarrier e altri — per coordinare il lavoro dei thread.

Importante: Oggi ci limitiamo alle basi — parleremo di queste classi un po’ più avanti.

5. Esempio pratico: applicazione con contatore multithread

Supponiamo di implementare le statistiche degli accessi degli utenti a un servizio. Ogni thread — è un utente separato che incrementa il contatore condiviso.

Senza sincronizzazione

public class Counter {
    public int value = 0;
}

public class MultiThreadCounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable user = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.value++;
            }
        };

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

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println("Valore atteso: 30000");
        System.out.println("Valore reale: " + counter.value);
    }
}

Risultato: Quasi sempre meno di 30000. A volte — molto meno! Perché? Perché i thread si intralciano a vicenda.

Sincronizzazione: correggiamo l’errore

public class Counter {
    public int value = 0;

    public synchronized void increment() {
        value++;
    }
}

public class MultiThreadCounterDemo {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable user = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        };

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

        t1.start();
        t2.start();
        t3.start();

        t1.join();
        t2.join();
        t3.join();

        System.out.println("Valore atteso: 30000");
        System.out.println("Valore reale: " + counter.value);
    }
}

Risultato: Sempre 30000. Evviva, la sincronizzazione funziona!

6. Dettagli utili

Visualizzazione: come appare una race condition

Disegniamo una piccola tabella per mostrare come due thread possano «perdere» un incremento:

Passo Thread 1 Thread 2 Valore di value
1 Legge value=0 0
2 Legge value=0 0
3 Incrementa a 1 0
4 Incrementa a 1 0
5 Scrive 1 1
6 Scrive 1 1

Quando serve la sincronizzazione

La sincronizzazione non è sempre necessaria. Quando una variabile vive nel suo piccolo mondo e ci lavora un solo thread — ci si può rilassare. Ma basta condividerla con altri thread e senza sincronizzazione non si va da nessuna parte. Anche se sembra che andrà tutto bene — non fidatevi. L’errore di race è subdolo: può restare nascosto a lungo e poi esplodere all’improvviso nel momento meno opportuno.

Uno sguardo al futuro: quali altri strumenti di sincronizzazione esistono

Oggi abbiamo visto solo lo strumento di base — synchronized. Nelle prossime lezioni vedremo:

  • Come funziona il monitor di un oggetto e quali tipi di lock esistono.
  • Che cosa sono i metodi sincronizzati statici (static + synchronized).
  • Come funziona la parola chiave volatile e a cosa serve.
  • Quali sono le classi moderne per la sincronizzazione (Lock, Semaphore e altri).

7. Errori tipici nel lavoro con le risorse condivise

Errore n. 1: ignorare il multithreading.
Uno degli errori più comuni è non pensare che una variabile possa essere accessibile da più thread. Anche se ora il programma è monothread, in seguito qualcuno potrebbe aggiungere thread — e i bug compariranno «dal nulla».

Errore n. 2: sincronizzazione insufficiente o eccessiva.
Se non sincronizzate l’accesso alla risorsa condivisa, avrete race condition e dati inconsistenti. Se invece sincronizzate tutto, il programma «soffocherà» per le lock e diventerà lento. Cercate sempre di sincronizzare solo ciò che è davvero necessario.

Errore n. 3: sincronizzare sull’oggetto sbagliato.
Se si sincronizza su oggetti diversi (ad esempio, variabili locali o letterali di stringa), ciò non proteggerà la risorsa condivisa. Tutti i thread devono sincronizzarsi sullo stesso oggetto.

Errore n. 4: aspettarsi atomicità da operazioni non atomiche.
L’operazione i++ non è atomica! Anche se la variabile è dichiarata come volatile, questo non rende atomico l’incremento. Per tali operazioni serve la sincronizzazione.

Errore n. 5: «Mi è andata bene, a me funziona tutto».
Una race condition può non manifestarsi sul vostro computer, ma prima o poi si manifesterà sul server o presso un utente. Non fate mai affidamento sulla «fortuna» nei programmi multithread!

Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION