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.
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.
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.

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.
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.

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.
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.
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.
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):
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.

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.
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.
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.
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

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.
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)
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.
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
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.
| Aspetto | implements Runnable | extends Thread |
|---|---|---|
| Ereditarietà | La classe resta libera di estendere un'altra classe | La classe "consuma" la sua unica ereditarietà diretta su Thread |
| Separazione concetti | Il 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 pool | Ogni istanza è già legata a un unico thread |
| Codice avvio | Runnable r = new ServerThread(socket); new Thread(r).start(); | ServerThread t = new ServerThread(socket); t.start(); |
| Quando preferirlo | Caso generale, specialmente se la classe deve ereditare da altro — best practice moderna | Casi semplici, didattici, dove non serve altra ereditarietà |
| Moderno | Base per ExecutorService, thread pool, lambda | Legato 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.
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.
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 LaboratoriVuoi 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.
