Server TCP Multithread in Java - Runnable vs Thread | Tecnologia Anno 5 Modulo 2 Lezione 4 | Prof. Giagnotti
Tecnologia · Anno 5 · Modulo 2 · Lezione 4 - ULTIMA DEL MODULO
✍ Autore: profgiagnotti · Materia: Tecnologie Informatiche · Server Multithread TCP

Server TCP Multithread: gestire 100 client contemporaneamente con Runnable e Thread

Nelle lezioni precedenti il server gestiva un solo client alla volta, bloccato su accept(). Ora risolviamo il limite reale: un thread per ogni client, il main torna subito libero. Due strade a confronto: Runnable vs extends Thread.

Anno 5Modulo 2 Lezione 4Multithread RunnableTCP ~35 min
OBIETTIVI DI APPRENDIMENTO
✓Comprendere perché un server di rete reale ha bisogno del multithreading, e distinguere i concetti di processo e thread
✓Conoscere il concetto di thread safety e gli strumenti Java per gestire le risorse condivise (cenni a synchronized, lock, volatile)
✓Distinguere le due modalità di creazione di un thread in Java: implementazione di Runnable ed estensione della classe Thread
✓Realizzare un server TCP multithread che gestisce più client contemporaneamente, ciascuno con un thread dedicato
✓Riconoscere pregi e limiti dei due approcci (Runnable vs extends Thread) e sapere quando preferire l'uno o l'altro

Nelle due lezioni precedenti abbiamo scritto client e server capaci di gestire un solo interlocutore alla volta: il server TCP restava bloccato su accept() finché non arrivava una connessione, la gestiva, e solo allora poteva tornare ad ascoltarne un'altra. Questo è uno dei passaggi che può creare maggiori difficoltà a chi programma reti per la prima volta: nella vita reale un server deve poter parlare con centinaia di client contemporaneamente, non uno alla volta in coda. In questa lezione risolviamo esattamente questo problema, introducendo il multithreading: un server che, per ogni client che si connette, apre un thread dedicato e torna immediatamente libero di accettarne altri.

💬 Nota docente - La svolta del modulo

Questa è la lezione che trasforma il vostro server da "giocattolo didattico" a "server vero". Fino ad ora se Cliente A si collegava e stava 10 minuti a scrivere, Cliente B restava fuori 10 minuti. Inaccettabile per Google, per un gioco online, per una banca. Con il multithreading il thread principale fa solo una cosa: accept() → crea thread → torna ad accept(). La conversazione vera vive nel thread figlio. È esattamente così che funziona Apache, Tomcat, ogni web server.

SINGLE THREAD VS MULTITHREAD BLOCCO

1. Perché un server ha bisogno del multithreading

Quando più client si collegano allo stesso server, l'uso di un singolo flusso di esecuzione comporterebbe il blocco del servizio durante la gestione di ogni richiesta: mentre il server "chiacchiera" con il primo client, tutti gli altri restano in attesa, anche se il server passa la maggior parte del tempo semplicemente fermo su un readLine() che aspetta l'input di quell'unico client. Il multithreading risolve questo problema assegnando a ciascun client un thread dedicato: il thread principale del server torna subito libero di accettare nuove connessioni, mentre ogni client viene servito in parallelo dal proprio thread.

🎯 Esempio reale - Web server

Pensa a un sito con 1000 utenti contemporanei. Se fosse single-thread, l'utente 1000 dovrebbe aspettare che i 999 prima di lui finiscano di caricare la pagina. Con multithreading, il main accetta in millisecondi e delega. Mentre Thread-1 legge il DB per utente 1, Thread-2 invia già HTML a utente 2. Tutti procedono in parallelo, nessuno blocca nessuno.

2. Processo e thread: concetti fondamentali

Qui ho preferito aggiungere un esempio proprio perché il passaggio non è immediato la prima volta che lo si incontra. Un processo è composto da due ingredienti: il codice, cioè la logica eseguibile, e le risorse, cioè memoria, file aperti, variabili. Il thread rappresenta l'unità di esecuzione che utilizza le risorse del processo: più thread possono convivere all'interno dello stesso processo, condividendone lo spazio di memoria.

IMMAGINE 02 - PROCESSO CON PIU' THREAD CONDIVISIONE MEMORIA
📖 Definizioni fondamentali

Processo: codice + risorse (memoria, file). È pesante da creare.
Thread: unità di esecuzione leggera che vive dentro un processo e ne condivide le risorse.
Thread-safe: un programma garantisce che l'accesso simultaneo ai dati condivisi non produca risultati incoerenti. Se due thread scrivono stessa variabile senza protezione → race condition.

Proprio perché i thread condividono la stessa memoria, occorre fare attenzione a quando più thread accedono contemporaneamente agli stessi dati: un programma è thread-safe se garantisce che l'accesso simultaneo ai dati non produca risultati incoerenti. Java mette a disposizione diversi strumenti per gestire le risorse condivise, tra cui la parola chiave synchronized, i lock espliciti e la parola chiave volatile — argomenti che approfondiremo quando scriveremo applicazioni multithread più complesse di un semplice echo server.

💡 Tips - Quando serve synchronized

Nel nostro server multithread ogni thread ha il suo Socket, il suo BufferedReader, il suo PrintWriter — nessuna risorsa condivisa tra client, quindi niente synchronized necessario. Se invece avessimo una variabile globale contatoreClientConnessi incrementata da ogni thread, lì sì servirebbe synchronized o AtomicInteger, altrimenti due thread potrebbero sovrascriversi il contatore.

3. Due modi per creare un thread in Java

Java offre due modalità principali per creare un thread: implementare l'interfaccia Runnable, oppure estendere direttamente la classe Thread. In questa lezione privilegiamo la prima soluzione, più flessibile e modulare, ma vedremo anche la seconda per completezza.

3.1 Le interfacce in Java e il loro ruolo nel multithreading

In Java, un'interfaccia rappresenta un contratto che definisce un insieme di metodi che una classe si impegna a implementare. A differenza delle classi, un'interfaccia non fornisce una concreta implementazione del comportamento, ma stabilisce cosa una classe deve fare, non come. L'implementazione di un'interfaccia avviene tramite la parola chiave implements, e l'annotazione @Override consente di indicare esplicitamente che un metodo sta sovrascrivendo un metodo dichiarato nell'interfaccia.

Runnable impone l'implementazione di un solo metodo: void run();. Separando il comportamento del thread (cosa deve fare) dal meccanismo di esecuzione (l'oggetto Thread che lo fa girare), Java consente maggiore modularità, riusabilità del codice, e la possibilità per la classe di estendere comunque un'altra classe — cosa che l'estensione diretta di Thread impedirebbe, dato che Java non permette l'ereditarietà multipla. Per questi motivi, Runnable è generalmente la soluzione preferita nelle applicazioni multithread moderne.

💬 Nota docente - Perché insisto su Runnable

Java ha ereditarietà singola: se la vostra classe estende Thread, non può estendere altro. Se invece implementa Runnable, può ancora estendere, per esempio, una classe Astratta Utente o Connessione. Inoltre Runnable è riusabile: stesso task eseguito da thread diversi, o da un thread pool. Per questo nei colloqui vi chiederanno sempre "meglio Runnable o extends Thread?" — risposta corretta: Runnable, tranne casi didattici semplici.

3.2 Esempio introduttivo: due thread indipendenti

Prima di arrivare al server vero e proprio, vediamo un esempio minimo che mostra due thread indipendenti in esecuzione concorrente.

JAVA - MYTASK + MAIN - DUE THREAD CONCORRENTI
// Implementazione dell'interfaccia Runnable class MyTask implements Runnable { // MyTask si impegna a fornire un metodo run() @Override // dichiara esplicitamente che stiamo sovrascrivendo il metodo dell'interfaccia public void run() { // il metodo che verrà eseguito "dentro" il thread for (int i = 0; i < 5; i++) { // ripete 5 volte // Thread.currentThread().getName() restituisce il nome del thread che sta eseguendo QUESTA riga System.out.println(Thread.currentThread().getName() + " - Iterazione: " + i); try { Thread.sleep(1000); // sospende il thread corrente per 1000 ms, simulando un'attività } catch (InterruptedException e) { // eccezione controllata lanciata da sleep() e.printStackTrace(); // stampa l'errore se il thread viene interrotto mentre dorme } } } }public class Main { // classe con il metodo main, punto di ingresso del programma public static void main(String[] args) { // creazione delle "attivazioni": due istanze dello stesso task, indipendenti tra loro Runnable task1 = new MyTask(); Runnable task2 = new MyTask();// creazione degli "esecutori": due oggetti Thread, ciascuno con un nome e un task da eseguire Thread thread1 = new Thread(task1, "Thread 1"); Thread thread2 = new Thread(task2, "Thread 2");// avvio dei thread: chiamare start() (non run()!) fa partire l'esecuzione concorrente thread1.start(); thread2.start(); } }

3.2.1 Spiegazione del codice

La classe MyTask implementa Runnable, definendo il metodo run(), che stampa un messaggio a ogni iterazione. Nel main(): vengono create due istanze di MyTask, ciascuna viene passata a un oggetto Thread distinto, e i thread vengono avviati con start(), che ne fa eseguire il metodo run() in parallelo.

Output atteso (l'ordine esatto delle righe può variare da un'esecuzione all'altra, perché i due thread procedono in modo indipendente):

OUTPUT - THREAD INTERLEAVED
Thread 1 - Iterazione: 0 Thread 2 - Iterazione: 0 Thread 1 - Iterazione: 1 Thread 2 - Iterazione: 1 Thread 1 - Iterazione: 2 Thread 2 - Iterazione: 2 ...
⚠️ Errore comune - start() vs run()

Un errore molto comune per chi inizia con i thread è chiamare direttamente task1.run() invece di thread1.start(). Chiamare run() esegue semplicemente il metodo come una normale chiamata sincrona, sullo stesso thread che ha fatto la chiamata: niente concorrenza, nessun nuovo thread creato. Solo start() avvia davvero un nuovo thread di esecuzione. Se vedete output ordinato 0-1-2-3-4 di Thread1 e poi 0-1-2-3-4 di Thread2, avete chiamato run() per sbaglio.

4. Server TCP multithread con Runnable

Obiettivo: realizzare un server TCP che accetta più client contemporaneamente, riceve stringhe di testo e restituisce la versione in maiuscolo — lo stesso comportamento delle lezioni precedenti, ma questa volta capace di servire più client alla volta. Ogni client viene gestito tramite un thread dedicato.

Per realizzare il server servono due classi: Server, che gestisce le connessioni in arrivo e avvia un thread per ciascun client, e ServerThread, che implementa Runnable e gestisce la comunicazione con un singolo client.

IMMAGINE 03 - ARCHITETTURA SERVER MULTITHREAD
💬 Nota docente - Due responsabilità separate

La difficoltà principale, in questo caso, è capire la differenza tra i due concetti che stiamo per mettere in pratica: Server si occupa solo di accettare connessioni e "smistarle", mentre ServerThread si occupa solo di parlare con un singolo client, dall'inizio alla fine della sua sessione. Tenere separate queste due responsabilità è esattamente il motivo per cui il codice usa due classi invece di una sola. Se mettete tutto in un'unica classe, confondete "chi accetta" con "chi parla" — e il codice diventa ingestibile.

4.1 Server.java — versione con Runnable (commentato riga per riga)

Ho mantenuto questa versione del codice perché è più leggibile rispetto a un'unica classe che facesse tutto: seguila con calma, e nota in particolare come il ciclo while(true) in Connetti() non si fermi mai ad aspettare la fine di una conversazione con un client, perché quella conversazione vive ormai nel suo thread dedicato.

JAVA - SERVER MULTITHREAD RUNNABLE - SERVER + SERVERTHREAD
import java.io.*; // per BufferedReader, PrintWriter, IOException import java.net.*; // per ServerSocket, Socket// Classe principale del server public class Server { private static final int PORT = 12345; // porta su cui il server ascolta (costante di classe) private ServerSocket serverSocket; // il socket di ascolto, condiviso da tutto il ciclo di vita del server// costruttore: crea il ServerSocket sulla porta indicata public Server(int port) throws IOException { serverSocket = new ServerSocket(port); // avvia l'ascolto sulla porta }// avvia il ciclo di accettazione delle connessioni public void connetti() { System.out.println("Server in ascolto sulla porta " + PORT); // conferma a console try { while (true) { // ciclo infinito: il server resta sempre pronto a nuove connessioni// accept() blocca SOLO il thread principale, finché non arriva un client Socket clientSocket = serverSocket.accept(); System.out.println("Client connesso: " + clientSocket.getInetAddress()); // log a console// crea l'oggetto che sa COSA fare con questo client (implementa Runnable) ServerThread attivaThread = new ServerThread(clientSocket); // crea l'esecutore vero e proprio, a cui affidare quel comportamento Thread thread = new Thread(attivaThread); thread.start(); // avvia il nuovo thread: da qui in poi gira in parallelo al main// il ciclo while riparte SUBITO: il thread principale non aspetta che il client finisca } } catch (IOException e) { // errori di rete/IO durante l'ascolto e.printStackTrace(); } finally { try { serverSocket.close(); // chiude il ServerSocket, liberando la porta 12345 } catch (IOException e) { e.printStackTrace(); } } }public static void main(String[] args) { // punto di ingresso del programma try { Server server = new Server(PORT); // crea il server sulla porta 12345 server.connetti(); // avvia il ciclo di accettazione (blocca il main per sempre, di proposito) } catch (IOException e) { // se la porta è già occupata, per esempio e.printStackTrace(); } } }// Classe che gestisce la comunicazione con UN client, eseguita dentro il suo thread dedicato class ServerThread implements Runnable { // si impegna a fornire un metodo run() private Socket clientSocket; // il socket dedicato a questo specifico client private BufferedReader in; // stream di lettura dei messaggi del client private PrintWriter out; // stream di scrittura delle risposte al clientpublic ServerThread(Socket socket) { // costruttore: riceve il socket già connesso this.clientSocket = socket; // lo memorizza per usarlo dentro run() }@Override public void run() { // il codice che gira DENTRO il thread dedicato a questo client try { // crea i canali di comunicazione con QUESTO client in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); out = new PrintWriter(clientSocket.getOutputStream(), true);String benvenuto = "Ciao, dimmi il tuo nome: "; // messaggio di apertura out.println(benvenuto); // lo invia al clientString nome = in.readLine(); // legge il nome digitato dal client (bloccante) // log a console, utile per vedere più client collegati contemporaneamente System.out.println(nome + " si è connesso! IP: " + clientSocket.getInetAddress() + " Porta: " + clientSocket.getPort());String inputLine; // conterrà, di volta in volta, la stringa inviata dal client while (true) { // ciclo di conversazione, specifico di QUESTO client/thread out.println("Scrivi una stringa (0=Esci): "); // invito a scrivere inputLine = in.readLine(); // legge la risposta del clientif (inputLine.equals("0")) { // condizione di uscita concordata con il client System.out.println(nome + " si è disconnesso"); // log a console break; // esce dal ciclo: la conversazione con questo client termina }out.println(inputLine.toUpperCase()); // risponde con la stringa in maiuscolo } } catch (IOException e) { // errori di rete/IO durante la conversazione e.printStackTrace(); } finally { try { // chiude, in ordine, stream e socket dedicati a QUESTO client (non l'intero server!) in.close(); out.close(); clientSocket.close(); } catch (IOException e) { e.printStackTrace(); } } } }

4.2 Client1.java (commentato riga per riga) — con correzioni

Prima di consegnarti il codice ho provato a compilarlo davvero, ed è così che ho scovato un paio di imprecisioni che nella traccia originale erano passate inosservate — le correggo qui sotto, e te le segnalo, perché sono ottimi esempi di errori che capitano davvero anche a chi programma da anni.

🔍 Revisione codice - Errori corretti dal docente

1. Import incompleti: nella versione originale gli import erano scritti import java.io.; e import java.net.;, senza asterisco — un codice così non compila. Forma corretta: import java.io.*; e import java.net.*;.

2. Parametri costruttore ignorati: il costruttore dichiarava public Client1(String host, int port) ma nel corpo si collegava sempre a SERVER_ADDRESS e PORT — le costanti fisse — ignorando i parametri ricevuti. Funziona perché nel main() i valori coincidono, ma se provi a connetterti a un server diverso passando altro host, continuerà silenziosamente a collegarsi a localhost. Nella versione sotto uso correttamente i parametri host e port.

JAVA - CLIENT1 CORRETTO - USA PARAMETRI HOST/PORT
package cs_tcp_06_multithread_touppercase_runnable; // package del progetto (nome in minuscolo per convenzione)import java.io.*; // per BufferedReader, PrintWriter, IOException import java.net.*; // per Socketpublic class Client1 { private Socket socket; // la connessione TCP verso il server private BufferedReader in; // stream di lettura delle risposte del server private PrintWriter out; // stream di scrittura verso il server private BufferedReader tastiera; // stream di lettura dell'input digitato dall'utenteprivate static final String SERVER_ADDRESS = "localhost"; // indirizzo di default del server private static final int PORT = 12345; // porta di default del server// costruttore: usa DAVVERO i parametri host e port ricevuti (a differenza della bozza originale) public Client1(String host, int port) throws IOException { socket = new Socket(host, port); // si connette all'host e alla porta indicati tastiera = new BufferedReader(new InputStreamReader(System.in)); // pronto a leggere da tastiera }// crea gli stream di comunicazione con il server, una volta stabilita la connessione private void creaCanali() throws IOException { in = new BufferedReader(new InputStreamReader(socket.getInputStream())); out = new PrintWriter(socket.getOutputStream(), true); }// gestisce l'intera conversazione con il server, dall'inizio alla disconnessione private void comunica() throws IOException { String messaggioDalServer; // conterrà, di volta in volta, ciò che arriva dal server String userInput; // conterrà, di volta in volta, ciò che digita l'utentemessaggioDalServer = in.readLine(); // legge il messaggio di benvenuto System.out.println(messaggioDalServer); // lo stampa a consoleuserInput = tastiera.readLine(); // legge il nome digitato dall'utente out.println(userInput); // lo invia al serverwhile (true) { // ciclo principale di conversazione messaggioDalServer = in.readLine(); // legge l'invito del server a scrivere una stringa System.out.println(messaggioDalServer); // lo mostra all'utenteuserInput = tastiera.readLine(); // legge la stringa digitata dall'utente out.println(userInput); // la invia al serverif (userInput.equals("0")) { // condizione di uscita concordata con il server System.out.println("Disconnessione dal server..."); break; // esce dal ciclo: non ci si aspetta più risposta dal server per questo invio }messaggioDalServer = in.readLine(); // legge la risposta del server (stringa in maiuscolo) System.out.println("Risposta dal server: " + messaggioDalServer); // la mostra all'utente }chiudiConnessione(); // libera tutte le risorse aperte }// chiude, in ordine, tutti gli stream e il socket private void chiudiConnessione() throws IOException { in.close(); out.close(); socket.close(); tastiera.close(); System.out.println("Connessione chiusa."); }public static void main(String[] args) throws IOException { Client1 client1 = new Client1(SERVER_ADDRESS, PORT); // crea il client verso host/porta di default client1.creaCanali(); // apre gli stream di comunicazione client1.comunica(); // avvia la conversazione con il server } }
📝 Perché struttura creaCanali() + comunica() + chiudiConnessione()?

Nel Client1 abbiamo separato in 3 metodi per pulizia: creaCanali() apre gli stream, comunica() contiene il loop di conversazione, chiudiConnessione() libera tutto. Così se domani volete aggiungere log, retry, o gestione errori, sapete dove mettere mano senza sporcare main(). È la stessa logica di "separazione responsabilità" vista lato server.

4.3 Output a console

IMMAGINE 04 - OUTPUT CONSOLE SERVER + CLIENT1 + CLIENT2
OUTPUT - SERVER MULTITHREAD CON 2 CLIENT
Server in ascolto sulla porta 12345 Client connesso: /127.0.0.1 Mario si è connesso! IP: /127.0.0.1 Porta: 54321 Client connesso: /127.0.0.1 Luca si è connesso! IP: /127.0.0.1 Porta: 54322 Mario si è disconnesso Luca si è disconnesso
OUTPUT - CLIENT 1 - MARIO
Ciao, dimmi il tuo nome: Mario Scrivi una stringa (0=Esci): ciao mondo Risposta dal server: CIAO MONDO Scrivi una stringa (0=Esci): 0 Disconnessione dal server... Connessione chiusa.

5. La seconda strada: estendere la classe Thread

Oltre alla soluzione con Runnable, è possibile implementare un server multithread estendendo direttamente la classe Thread. Questo approccio permette di associare il comportamento del thread alla gestione del singolo client senza dover creare un oggetto Thread separato: la classe che gestisce il client È essa stessa un Thread, non ha più bisogno di un oggetto Runnable passato al costruttore.

In questo caso il test è particolarmente utile per capire il funzionamento delle due alternative fianco a fianco: l'esempio che segue replica esattamente lo stesso esercizio (il server accetta più connessioni, riceve stringhe e le restituisce in maiuscolo). L'unica differenza sostanziale è che la classe che gestisce il client estende Thread invece di implementare Runnable.

💬 Nota docente - Stesso risultato, codice più corto ma meno flessibile

Con extends Thread risparmiate 2 righe: non dovete fare new Thread(runnable), fate direttamente new ServerThread(socket) e start(). Sembra più semplice, ma avete "bruciato" la vostra unica ereditarietà. Se domani ServerThread deve estendere una classe ConnessioneLoggata per aggiungere log, non potete più — Java non permette ereditarietà multipla. Ecco perché vi mostro entrambe, ma consiglio Runnable.

5.1 Server.java — versione con extends Thread (commentato riga per riga)

JAVA - SERVER MULTITHREAD EXTENDS THREAD - PIU' COMPATTO
package cs_tcp_07_multithread_touppercase_extendsthread; // package del progettoimport java.io.*; // per BufferedReader, PrintWriter, IOException import java.net.*; // per ServerSocket, Socket// Classe principale del server (identica nella struttura alla versione con Runnable) public class Server { private static final int PORT = 12345; // porta su cui il server ascolta private ServerSocket serverSocket; // il socket di ascoltopublic Server(int port) throws IOException { // costruttore serverSocket = new ServerSocket(port); // avvia l'ascolto sulla porta indicata }// avvia il ciclo di accettazione delle connessioni public void connetti() { System.out.println("Server in ascolto sulla porta " + PORT); // conferma a console try { while (true) { // ciclo infinito: sempre pronto a nuove connessioni Socket clientSocket = serverSocket.accept(); // attende una nuova connessione System.out.println("Client connesso: " + clientSocket.getInetAddress()); // log// qui la differenza rispetto a prima: ServerThread È GIÀ un Thread, // non serve più avvolgerlo in un oggetto Thread separato ServerThread thread = new ServerThread(clientSocket); thread.start(); // avvia direttamente il thread appena creato } } catch (IOException e) { // errori di rete/IO e.printStackTrace(); } finally { try { serverSocket.close(); // chiude il ServerSocket, liberando la porta } catch (IOException e) { e.printStackTrace(); } } }public static void main(String[] args) { // punto di ingresso del programma try { Server server = new Server(PORT); // crea il server server.connetti(); // avvia il ciclo di accettazione } catch (IOException e) { e.printStackTrace(); } } }// Sottoclasse di Thread: gestisce la comunicazione con UN client, essendo essa stessa un thread class ServerThread extends Thread { // eredita direttamente da Thread private Socket clientSocket; // il socket dedicato a questo client private BufferedReader in; // stream di lettura private PrintWriter out; // stream di scritturapublic ServerThread(Socket socket) { // costruttore this.clientSocket = socket; // memorizza il socket ricevuto }@Override public void run() { // sovrascrive il metodo run() ereditato da Thread try { in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); // stream lettura out = new PrintWriter(clientSocket.getOutputStream(), true); // stream scritturaString benvenuto = "Ciao, dimmi il tuo nome: "; // messaggio di apertura out.println(benvenuto); // invia il messaggio di benvenutoString nome = in.readLine(); // legge il nome del client System.out.println(nome + " si è connesso! IP: " + clientSocket.getInetAddress() + " Porta: " + clientSocket.getPort()); // log a consoleString inputLine; // conterrà, volta per volta, l'input del client while (true) { // ciclo di conversazione con questo client out.println("Scrivi una stringa (0=Esci): "); // invito a scrivere inputLine = in.readLine(); // legge la rispostaif (inputLine.equals("0")) { // condizione di uscita System.out.println(nome + " si è disconnesso"); // log break; // termina il ciclo per questo client }out.println(inputLine.toUpperCase()); // risponde in maiuscolo } } catch (IOException e) { // errori di rete/IO e.printStackTrace(); } finally { try { in.close(); // chiude lo stream di lettura out.close(); // chiude lo stream di scrittura clientSocket.close(); // chiude il socket dedicato a questo client } catch (IOException e) { e.printStackTrace(); } } } }

5.2 Perché il client resta invariato

Il client, in questa seconda versione, resta invariato rispetto a quello scritto per la versione con Runnable: non dipende in alcun modo da come il server organizza internamente i propri thread, perché dal punto di vista del client — e del protocollo TCP — non cambia nulla: apre un socket, scrive, legge, chiude. Solo il server, dietro le quinte, gestisce la concorrenza in un modo leggermente diverso.

💡 Concetto chiave - Trasparenza lato client

Questo è fondamentale: il client non sa e non deve sapere se il server è single-thread, multithread, Runnable o extends Thread. Il protocollo TCP è lo stesso. È come telefonare a un call center: tu componi numero e parli, non ti interessa se dietro ci sono 1 o 100 operatori. La concorrenza è responsabilità solo del server. Per questo Client1.java è identico nelle due versioni.

5.3 Test dell'applicazione

  • Apri Visual Studio Code (o un altro IDE Java)
  • Esegui il server (Server.java) — in una qualunque delle due versioni
  • Avvia uno o più client (Client1.java): prova ad aprirne due contemporaneamente, in due terminali distinti
  • Digita i messaggi nel client e osserva la risposta in maiuscolo dal server
  • Nota, sul terminale del server, i messaggi di connessione intrecciati dei diversi client
  • Inserisci "0" per terminare la sessione di un singolo client, senza fermare il server
🧪 Prova di laboratorio - Test multi-client che dovete fare

1. Avviate server. 2. Avviate Client1 Mario. 3. Mentre Mario è connesso, avviate Client2 Luca in altro terminale — deve connettersi subito, non aspettare che Mario esca. Se Luca resta bloccato, avete dimenticato il thread. 4. Fate scrivere Mario e Luca alternati, verificate che le risposte non si incrocino. 5. Chiudete Mario con "0", verificate che Luca continui a funzionare e che il server resti acceso. Se tutto questo funziona, avete fatto un server multithread corretto.

6. Runnable vs extends Thread: come scegliere

Questo esperimento permette di vedere la differenza tra teoria e pratica meglio di qualunque spiegazione astratta: il comportamento finale dei due server è identico, byte per byte, agli occhi del client. Cambia solo come è organizzato il codice del server.

Aspettoimplements Runnableextends Thread
EreditarietàLa classe resta libera di estendere un'altra classeLa classe "consuma" la sua unica ereditarietà diretta su Thread
Separazione concettiIl comportamento (cosa fare) è separato dall'esecutore (Thread che lo fa girare)Comportamento ed esecutore coincidono nella stessa classe
RiusabilitàLo stesso Runnable può essere eseguito da più Thread, o da un thread poolOgni istanza è già legata a un unico thread
Codice avvioRunnable r = new ServerThread(socket); new Thread(r).start();ServerThread t = new ServerThread(socket); t.start();
Quando preferirloCaso generale, specialmente se la classe deve ereditare da altro — best practice modernaCasi semplici, didattici, dove non serve altra ereditarietà
ModernoBase per ExecutorService, thread pool, lambdaLegato a modello vecchio, meno usato in produzione

La scelta tra Runnable ed estensione di Thread dipende spesso da esigenze di progettazione e dalla necessità di ereditare da altre classi: nella pratica professionale, Runnable (o le interfacce ancora più moderne introdotte con i thread pool e l'Executor Framework) è la scelta nettamente più comune.

🚀 Oltre questa lezione - ExecutorService

Creare un Thread per ogni client funziona, ma se arrivano 10.000 client creiamo 10.000 thread — troppi, la JVM esplode. In produzione si usa ExecutorService con pool di thread riutilizzabili: Executors.newFixedThreadPool(100) gestisce 10.000 client con solo 100 thread che si alternano. Runnable è già pronto per quel modello, extends Thread no. Ecco perché Runnable vince sul lungo periodo.

7. 📌 Punti chiave e chiusura modulo

  • Single-thread vs multithread: un server single-thread gestisce un solo client alla volta; il multithreading assegna a ciascun client un thread dedicato, lasciando il thread principale libero di accettare nuove connessioni con while(true) { accept() → start thread }.
  • Processo vs Thread: un processo è fatto di codice e risorse; un thread è un'unità di esecuzione che condivide le risorse del processo con altri thread — da qui la necessità di scrivere codice thread-safe (synchronized, lock, volatile) quando si condividono dati.
  • Due modi Java: implementando Runnable (un solo metodo run(), passato a un oggetto Thread) oppure estendendo direttamente Thread. Stesso comportamento, organizzazione codice diversa.
  • Architettura server multithread: due classi — Server che accetta connessioni in loop, ServerThread che gestisce comunicazione con un singolo client. Separazione responsabilità fondamentale.
  • start() non run(): chiamare run() esegue metodo sullo stesso thread, nessuna concorrenza. Solo start() crea davvero nuovo thread.
  • Client invariato: il client non cambia in base a come il server gestisce i thread: la concorrenza è interamente responsabilità del server, trasparente al client (protocollo TCP identico).
  • Runnable preferibile: separa comportamento ed esecuzione, non consuma ereditarietà singola di Java, riusabile in thread pool, base per ExecutorService moderno.
🐙 Laboratori Pratici

Vuoi passare all'azione? Accedi alla repository ufficiale GitHub del corso per scaricare il file del laboratorio pratico

🧪 GitHub ZIP →

Vuoi consultare la sezione dedicata ai laboratori sul sito? Accedi cliccando il pulsante qui sotto.

📊 Sezione dedicata ai Laboratori

Vuoi consultare la sezione dedicata alle guide sul sito? Accedi cliccando il pulsante qui sotto.

🧪 Guida preparazione ambiente di lavoro →

🎓 Modulo 2 completato - Cosa sapete fare ora

Avete chiuso il modulo più operativo dell'anno: sapete creare socket TCP e UDP in Java, gestire buffer e stream, convertire stringhe in byte e viceversa, gestire eccezioni di rete, e ora sapete rendere un server capace di gestire decine di client contemporaneamente con multithreading. Da qui in poi potete costruire chat multi-utente, giochi di rete, web server minimali. Prossimo modulo: thread pool, synchronized e protocolli applicativi custom. Esercizio finale: aggiungete a ServerThread un contatore globale di client serviti — vi servirà synchronized per farlo thread-safe.