Introduzione ai Thread in Java

Concetti Chiave
  • Il Multithreading in Java consente l'esecuzione simultanea di più thread, migliorando l'efficienza e riducendo il tempo di inattività.
  • I thread sono unità di esecuzione più leggere rispetto ai processi, condividono lo stesso spazio di indirizzi e hanno meno overhead.
  • Java gestisce i thread attraverso un modello di thread integrato, che include la sincronizzazione e la comunicazione tra thread.
  • La classe Thread e l'interfaccia Runnable sono fondamentali per la creazione e la gestione dei thread in Java.

Programmazione Multithread in Java

Java fornisce supporto integrato per la programmazione multithreaded. Un programma multithread contiene due o più parti che possono essere eseguite contemporaneamente.

Ogni parte di tale programma è chiamata thread, e ogni thread definisce un flusso di esecuzione separato. Pertanto, il multithreading è una forma specializzata di multitasking.

Tutti i sistemi operativi moderni supportano il multitasking. Tuttavia, esistono due tipi distinti di multitasking:

  1. Multitasking basato sui processi;
  2. Multitasking basato sui thread.

È importante comprendere la differenza tra i due. Per molti lettori, il multitasking basato sui processi è la forma più familiare. Un processo è, in sostanza, un programma in esecuzione con un proprio stato. Pertanto, il multitasking basato sui processi è la caratteristica che consente al computer di eseguire due o più programmi contemporaneamente. Ad esempio, il multitasking basato sui processi consente di eseguire il compilatore Java allo stesso tempo in cui si sta utilizzando un editor di testo o visitando un sito web. Nel multitasking basato sui processi, un programma è la più piccola unità di codice che può essere messa in esecuzione dal sistema operativo.

In un ambiente di multitasking basato sui thread, il thread è la più piccola unità di codice eseguibile. Ciò significa che un singolo programma può eseguire due o più compiti simultaneamente. Ad esempio, un editor di testo può formattare il testo allo stesso tempo in cui sta stampando, purché queste due azioni vengano eseguite da due thread separati. Pertanto, il multitasking basato sui processi si occupa del quadro generale, e il multitasking basato sui thread gestisce i dettagli.

I thread multitasking richiedono meno overhead rispetto ai processi multitasking. I processi sono compiti pesanti che richiedono il proprio spazio di indirizzi separato. La comunicazione tra processi è costosa e limitata. Il cambio di contesto da un processo all'altro è anche costoso. I thread, d'altra parte, sono più leggeri. Condividono lo stesso spazio di indirizzi e condividono cooperativamente lo stesso processo. La comunicazione tra thread è poco costosa, e il cambio di contesto da un thread al successivo ha un costo inferiore. Mentre i programmi Java fanno uso di ambienti di multitasking basati sui processi, il multitasking basato sui processi non è sotto il controllo diretto di Java. Tuttavia, il multitasking multithreaded lo è.

Il multithreading consente di scrivere programmi efficienti che fanno uso massimo della potenza di elaborazione disponibile nel sistema. Un modo importante in cui il multithreading raggiunge questo obiettivo è mantenendo al minimo il tempo di inattività. Questo è particolarmente importante per l'ambiente interattivo e collegato in rete in cui opera Java perché il tempo di inattività è comune. Ad esempio, la velocità di trasmissione dei dati su una rete è molto più lenta della velocità con cui il computer può elaborarli. Anche le risorse del file system locale vengono lette e scritte a un ritmo molto più lento di quanto possano essere elaborate dalla CPU. E, naturalmente, l'input dell'utente è molto più lento del computer. In un ambiente single-threaded, il programma deve aspettare che ognuno di questi compiti finisca prima di poter procedere al successivo—anche se la maggior parte del tempo il programma è inattivo, aspettando l'input. Il multithreading aiuta a ridurre questo tempo di inattività perché un altro thread può essere eseguito quando uno sta aspettando.

Il fatto che Java gestisca i thread rende il multithreading particolarmente conveniente perché molti dei dettagli vengono gestiti per voi.

Il Modello di Thread di Java

Il sistema di runtime di Java dipende dai thread per molte cose, e tutte le librerie di classe sono progettate con il multithreading in mente. In effetti, Java usa i thread per abilitare l'intero ambiente ad essere asincrono. Questo aiuta a ridurre l'inefficienza prevenendo lo spreco di cicli della CPU.

Il valore di un ambiente multithreaded è meglio compreso in contrasto con la sua controparte. I sistemi single-threaded usano un approccio chiamato event loop con polling. In questo modello, un singolo thread di controllo gira in un loop infinito, facendo polling di una singola coda di eventi per decidere cosa fare dopo. Una volta che questo meccanismo di polling ritorna con, diciamo, un segnale che un file di rete è pronto per essere letto, allora l'event loop invia il controllo al gestore di eventi appropriato. Fino a quando questo gestore di eventi non ritorna, nient'altro può accadere nel programma. Questo spreca tempo della CPU. Può anche risultare in una parte di un programma che domina il sistema e previene qualsiasi altro evento dall'essere processato. In generale, in un ambiente single-threaded, quando un thread si blocca (cioè, sospende l'esecuzione) perché sta aspettando qualche risorsa, l'intero programma smette di girare.

Il beneficio del multithreading di Java è che il meccanismo di main loop/polling viene eliminato. Un thread può mettersi in pausa senza fermare altre parti del programma. Per esempio, il tempo di inattività creato quando un thread legge dati da una rete o aspetta input dell'utente può essere utilizzato altrove. Il multithreading permette ai loop di animazione di dormire per un secondo tra ogni frame senza causare la pausa dell'intero sistema. Quando un thread si blocca in un programma Java, solo il singolo thread che è bloccato si mette in pausa. Tutti gli altri thread continuano a girare.

Negli ultimi anni i sistemi multicore sono diventati comuni. Ovviamente, i sistemi single-core sono ancora abbastanza diffusi. È importante capire che le caratteristiche di multithreading di Java funzionano in entrambi i tipi di sistemi. In un sistema single-core, i thread lavorano in maniera concorrente condividendo la CPU, con ogni thread che riceve una fetta di tempo della CPU. Pertanto, in un sistema single-core, due o più thread non girano effettivamente allo stesso tempo, ma il tempo di inattività della CPU viene utilizzato. Tuttavia, nei sistemi multicore, è possibile per due o più thread di eseguire effettivamente simultaneamente. In molti casi, questo può migliorare ulteriormente l'efficienza del programma e aumentare la velocità di certe operazioni.

Consiglio

Fork/Join Framework

Il sistema di runtime di Java include anche un altro meccanismo di multithreading chiamato Fork/Join Framework.

Il Fork/Join Framework è parte del supporto di Java per la programmazione parallela, che è il nome comunemente dato alle tecniche che ottimizzano alcuni tipi di algoritmi per l'esecuzione parallela in sistemi che hanno più di una CPU. Per una discussione del Fork/Join Framework e altre utilità di concorrenza si rimanda alle lezioni successive.

I thread esistono in diversi stati:

  • In Esecuzione (Running):

    Si riferisce a un thread che è attualmente in esecuzione e sta utilizzando il tempo della CPU.

  • Pronto (Ready):

    Si riferisce a un thread che è pronto per essere eseguito, ma non sta attualmente utilizzando il tempo della CPU. Questo stato è anche chiamato Pronto per l'Esecuzione (Ready to Run).

  • In attesa (Waiting):

    Si riferisce a un thread che sta aspettando che un'altra operazione finisca prima di poter continuare. Questo può accadere quando un thread aspetta l'input dell'utente, o quando aspetta che un altro thread completi un'operazione.

  • Sospeso (Suspended):

    Si riferisce a un thread che è stato temporaneamente fermato e non sta attualmente eseguendo. Un thread sospeso può essere ripreso in seguito.

In qualsiasi momento, un thread può essere terminato, il che ferma la sua esecuzione immediatamente. Una volta terminato, un thread non può essere ripreso.

Priorità dei Thread

Java assegna a ciascun thread una priorità che determina come quel thread dovrebbe essere trattato rispetto agli altri. Le priorità dei thread sono numeri interi che specificano la priorità relativa di un thread rispetto a un altro.

Come valore assoluto, una priorità è priva di significato; un thread con priorità più alta non viene eseguito più velocemente di un thread con priorità più bassa se è l'unico thread in esecuzione. Invece, la priorità di un thread viene utilizzata per decidere quando passare da un thread in esecuzione al successivo. Questo è chiamato cambio di contesto o context switch.

Le regole che determinano quando avviene un cambio di contesto sono semplici:

  • Un thread può rinunciare volontariamente al controllo (yield):

    Questo accade quando cede esplicitamente il controllo, effettua una sleep, o quando è bloccato. In questo scenario, tutti gli altri thread vengono esaminati, e il thread con priorità più alta che è pronto per l'esecuzione riceve la CPU.

  • Un thread può essere interrotto da un thread con priorità più alta (preemption):

    In questo caso, un thread con priorità più bassa che non cede il processore viene semplicemente interrotto, non importa cosa stia facendo, da un thread con priorità più alta. Fondamentalmente, non appena un thread con priorità più alta vuole essere eseguito, lo fa. Questo è chiamato multitasking preemptive o multitasking con prelazione.

Nei casi in cui due thread con la stessa priorità competono per i cicli della CPU, la situazione è un po' complicata. Per alcuni sistemi operativi, i thread di uguale priorità vengono divisi automaticamente in time-slice in modo round-robin. Ossia, ogni thread di uguale priorità ottiene una fetta di tempo della CPU in modo equo. Questo significa che se due thread hanno la stessa priorità, ciascuno di essi ottiene metà del tempo della CPU.

Per altri tipi di sistemi operativi, i thread di uguale priorità devono cedere volontariamente il controllo ai loro pari. Se non lo fanno, gli altri thread non verranno eseguiti.

Nota

Java, Thread e Portabilità

Sebbene Java fornisca una serie di astrazioni per mascherare i dettagli del multithreading, non può garantire che il comportamento dei thread sia identico su tutti i sistemi operativi. Questo perché Java si basa sul sistema operativo sottostante per la gestione dei thread.

Problemi di portabilità possono sorgere dalle differenze nel modo in cui i sistemi operativi effettuano il cambio di contesto dei thread di uguale priorità.

Per cui, non bisogna mai fare affidamento su un comportamento specifico dei thread di uguale priorità. Invece, si dovrebbe progettare il codice in modo che funzioni correttamente indipendentemente da come il sistema operativo gestisce i thread di uguale priorità.

Torneremo su questo argomento in una lezione successiva.

Sincronizzazione

Poiché il multithreading introduce un comportamento asincrono nei programmi, deve esistere un modo per imporre la sincronizzazione quando necessaria. Ad esempio, se vogliamo che due thread comunichino e condividano una struttura dati complessa, come una lista collegata, abbiamo bisogno di un modo per assicurarci che non vadano in conflitto tra loro.

Cioè, dobbiamo impedire che un thread scriva dati mentre un altro thread è nel mezzo della lettura. Per questo scopo, Java implementa un'elegante variazione su un venerabile modello di sincronizzazione interprocesso: il monitor.

Il monitor è un meccanismo di controllo definito per la prima volta da C.A.R. Hoare. Possiamo pensare a un monitor come a una scatola che può ospitare solo un thread alla volta. Una volta che un thread entra in un monitor, tutti gli altri thread devono aspettare fino a quando quel thread esce dal monitor. In questo modo, un monitor può essere utilizzato per proteggere una risorsa condivisa dall'essere manipolata da più di un thread alla volta.

In Java, non esiste una classe Monitor; invece, ogni oggetto ha il proprio monitor implicito che viene automaticamente attivato quando viene chiamato uno dei metodi sincronizzati dell'oggetto. Una volta che un thread è all'interno di un metodo sincronizzato, nessun altro thread può chiamare qualsiasi altro metodo sincronizzato sullo stesso oggetto. Questo ci consente di scrivere codice multithreaded molto chiaro e conciso, perché il supporto per la sincronizzazione è incorporato nel linguaggio.

Comunicazione tra Thread

Dopo aver diviso il programma in thread separati, è necessario definire come comunicheranno tra loro.

Quando si programma con alcuni linguaggi, bisogna dipendere dal sistema operativo per stabilire la comunicazione tra thread. Questo, ovviamente, aggiunge overhead.

Al contrario, Java fornisce un modo pulito e a basso costo per far comunicare due o più thread tra loro, tramite chiamate a metodi predefiniti che tutti gli oggetti hanno. Il sistema di messaggistica di Java consente a un thread di entrare in un metodo synchronized su un oggetto, e poi aspettare lì fino a quando qualche altro thread non invia una notifica esplicita che il thread può continuare. Questo meccanismo è chiamato notifica e attesa.

La Classe Thread e l'Interfaccia Runnable

Il sistema multithreading di Java è costruito sulla classe Thread, i suoi metodi e la sua interfaccia imparentata, Runnable.

Thread incapsula un thread di esecuzione. Poiché non è possibile riferirsi direttamente allo stato etereo di un thread in esecuzione, lo si può fare attraverso il suo proxy, l'istanza Thread che lo ha generato. Per creare un nuovo thread, i nostri programmi dovranno estendere Thread o implementare l'interfaccia Runnable.

La classe Thread definisce diversi metodi che aiutano a gestire i thread. Alcuni di quelli più utilizzati sono mostrati nella tabella seguente:

Metodo Significato
getName Ottenere il nome di un thread.
getPriority Ottenere la priorità di un thread.
isAlive Determinare se un thread è ancora in esecuzione.
join Attendere che un thread termini.
run Punto di ingresso per il thread.
sleep Sospendere un thread per un periodo di tempo.
start Avviare un thread chiamando il suo metodo run.
Tabella 1: Principali metodi della classe Thread.

Finora, tutti gli esempi in questa guida hanno utilizzato un singolo thread di esecuzione.

Le prossime lezioni spiegheranno come utilizzare Thread e Runnable per creare e gestire thread, iniziando con l'unico thread che tutti i programmi Java devono necessariamente possedere: il thread principale. Questo sarà l'argomento della prossima lezione.