Vai al contenuto principale

Guida completa alla programmazione di socket in Python

Crea da zero client e server TCP, gestisci più connessioni con il threading e scopri quando affidarti ad asyncio.
· 15 min leggi

Scopri con l'IA

Collegare dispositivi per scambiarsi informazioni è l'essenza del networking. I socket sono una parte fondamentale di una comunicazione di rete efficace: sono il concetto alla base della trasmissione di messaggi tra dispositivi su reti locali o globali e tra processi sulla stessa macchina. Forniscono un'interfaccia di basso livello che consente un controllo fine del traffico da inviare o ricevere.

Questa natura di basso livello permette di creare canali di comunicazione molto performanti (o protocolli personalizzati) per casi d'uso specifici con un overhead ridotto rispetto ai protocolli tradizionali, che sono costruiti sopra la comunicazione via socket.

È questo che rende i socket estremamente utili nelle applicazioni client-server in tempo reale che dipendono dallo scambio istantaneo di messaggi o che operano con grandi quantità di dati.

In questo articolo, vedremo le basi della programmazione di socket e forniremo una guida passo passo per creare applicazioni client e server basate su socket usando Python. 

Basi del networking

Il networking consente la comunicazione e la condivisione di informazioni di qualsiasi tipo. Una raccolta di dispositivi interconnessi è chiamata rete.

Nel mondo fisico possiamo osservare molte reti: le reti aeree o elettriche, o le città interconnesse da autostrade, sono buoni esempi.

Allo stesso modo, nell'informatica esistono numerose reti; la più rilevante e conosciuta è Internet, la rete globale di reti che connette miriadi di dispositivi e che probabilmente stai usando proprio ora per leggere questo articolo.

Tipi di reti

Internet racchiude al suo interno molte altre reti, che differiscono per scala o altre proprietà: ad esempio le reti locali (LAN), che tipicamente collegano computer situati in prossimità tra loro. Le macchine in aziende o altre istituzioni (banche, università, ecc.) o anche i tuoi dispositivi domestici collegati a un router costituiscono una rete di questo tipo.

Esistono anche reti più grandi o più piccole come le PAN (personal area network), che possono essere semplicemente il tuo smartphone collegato a un laptop via Bluetooth, le MAN (metropolitan area network), che possono interconnettere i dispositivi di un'intera città, e le WAN (wide area network), che possono coprire interi paesi o il mondo intero. E sì, la WAN più grande è Internet stessa.

È superfluo dire che le reti di computer possono essere molto complesse e composte da molti elementi. Una delle primitive più basilari e cruciali è un protocollo di comunicazione.

Tipi di protocolli di comunicazione di rete

I protocolli di comunicazione specificano le regole su come e in quale formato le informazioni devono essere inviate e ricevute. Questi protocolli sono organizzati in una gerarchia per gestire i vari compiti coinvolti nella comunicazione di rete.

Alcuni protocolli di comunicazione di rete comunemente usati e molto conosciuti includono:

Wi‑Fi

Un esempio di protocollo di livello collegamento, cioè molto vicino all'hardware e responsabile dell'invio fisico dei dati da un dispositivo all'altro in ambiente wireless.

IP (Internet Protocol)

IP è un protocollo di livello rete principalmente responsabile dell'instradamento dei pacchetti e dell'indirizzamento IP.

TCP (Transmission Control Protocol)

Un protocollo affidabile e orientato alla connessione che fornisce comunicazione full‑duplex e garantisce l'integrità e la consegna dei dati. È un protocollo di livello trasporto, che gestisce le connessioni, rileva gli errori e controlla il flusso di informazioni.

UDP (User Datagram Protocol)

Un protocollo della stessa suite di TCP. La differenza principale è che UDP è un protocollo senza connessione più semplice e veloce ma inaffidabile, che non esegue verifiche di consegna e segue il paradigma “spara e dimentica”. Come TCP, anche UDP si trova a livello trasporto.

HTTP (Hypertext Transfer Protocol)

Un protocollo di livello applicazione e il più comunemente usato per la comunicazione browser‑server sul web, impiegato in particolare per servire i siti web. È ovvio che anche questo articolo che stai leggendo ora è stato servito via HTTP. Il protocollo HTTP si basa su TCP e gestisce e trasferisce informazioni rilevanti per le applicazioni web come gli header, usati per trasferire metadati e cookie, i diversi metodi HTTP (GET, POST, DELETE, UPDATE) ecc.

MQTT (Message Queue Telemetry Transport)

Un altro esempio di protocollo a livello applicazione usato per dispositivi con potenza di calcolo e batteria limitate, operanti in condizioni di rete inaffidabili (ad esempio, sensori di gas in un sito minerario o semplicemente una lampadina smart in casa). MQTT è un protocollo di messaggistica standard usato nell'IoT (Internet of Things). È leggero e semplice da usare, progettato con meccanismi di ritrasmissione integrati per una maggiore affidabilità. Se ti interessa usare questo protocollo con Python, puoi leggere questa guida Python MQTT che offre una panoramica approfondita del client Paho MQTT.

Un aspetto importante è che tutti i protocolli sopra menzionati usano i socket dietro le quinte ma aggiungono la loro logica e l'elaborazione dei dati sopra di essi. Questo perché i socket sono un'interfaccia di basso livello per qualsiasi comunicazione di rete nei dispositivi moderni, come vedremo nella prossima sezione.

Concetti e termini chiave

Ovviamente, nel contesto delle reti esistono molti altri concetti e termini importanti. Ecco una rapida carrellata di alcuni dei più rilevanti che potrebbero ricorrere nel resto del tutorial:

  • Pacchetto: un'unità standard di trasmissione dei dati in una rete di computer (si può paragonare colloquialmente al termine “messaggio”).
  • Endpoint: una destinazione in cui arrivano i pacchetti.
  • Indirizzo IP: un identificatore numerico che identifica in modo univoco un dispositivo sulla rete. Un esempio di indirizzo IP è: 192.168.0.0
  • Porte: un identificatore numerico che identifica in modo univoco un processo in esecuzione su un dispositivo e che gestisce particolari comunicazioni di rete: ad esempio, serve il tuo sito via HTTP. Mentre un indirizzo IP identifica il dispositivo, una porta identifica l'applicazione (ogni applicazione è un processo o è composta da processi). Alcuni esempi noti di porte sono: la porta 80, usata convenzionalmente dalle applicazioni server per gestire il traffico HTTP, e la porta 443 per HTTPS (HTTP sicuro).
  • Gateway: un tipo particolare di nodo di rete (dispositivo) che funge da punto di accesso da una rete a un'altra. Queste reti possono anche usare protocolli diversi, quindi il gateway potrebbe dover eseguire una traduzione di protocollo. Un esempio di gateway è un router che collega una rete locale domestica a Internet.

Capire i socket

Che cos'è un socket?

Un socket è un'interfaccia (porta) per la comunicazione tra processi diversi situati sulla stessa o su altre macchine. In quest'ultimo caso, parliamo di socket di rete.

I socket di rete astraggono la gestione della connessione. Nei sistemi Unix, in particolare, i socket sono semplicemente file che supportano le stesse operazioni di scrittura‑lettura ma inviano tutti i dati sulla rete.

Quando un socket è in stato di ascolto o di connessione, è sempre associato a una combinazione di indirizzo IP e numero di porta che identifica l'host (macchina/dispositivo) e il processo.

Come funzionano le connessioni socket

I socket possono mettersi in ascolto di connessioni in arrivo oppure effettuare essi stessi connessioni in uscita. Quando una connessione viene stabilita, il socket in ascolto (server socket) viene inoltre associato all'IP e alla porta del lato che si connette.

In alternativa, viene creato un nuovo socket che ora è associato a due coppie di indirizzi IP e numeri di porta di listener e richiedente. In questo modo, due socket connessi su macchine diverse possono identificarsi a vicenda e condividere una singola connessione per la trasmissione dei dati senza bloccare il socket in ascolto, che nel frattempo continua ad attendere altre connessioni.

Nel caso del socket che si connette (socket client), questo viene implicitamente associato all'indirizzo IP del dispositivo e a una porta libera casuale all'avvio della connessione. Quindi, al momento dell'instaurazione, avviene l'associazione all'IP e alla porta dell'altra parte in modo analogo a quanto succede per un socket in ascolto, ma senza creare un nuovo socket.

Programmazione di socket

Quando parliamo di programmazione di socket, istanziamo oggetti socket nel nostro codice ed eseguiamo operazioni su di essi (listen, connect, receive, send, ecc.). In questo contesto, i socket sono semplicemente oggetti speciali che creiamo nel nostro programma e che hanno metodi specifici per lavorare con connessioni e traffico di rete.

Dietro le quinte, questi metodi chiamano il kernel del sistema operativo, o più precisamente lo stack di rete, una parte speciale del kernel responsabile della gestione delle operazioni di rete.

Socket e comunicazione client‑server

I socket sono legati alle connessioni; sono gestori di connessione. Sul web, ogni volta che vuoi inviare o ricevere dei dati, avvii una connessione (che viene avviata tramite l'interfaccia chiamata socket).

Ora, o tu o la parte a cui stai cercando di connetterti agite come server e l'altra parte come client. Mentre un server serve dati ai client, i client si connettono attivamente e richiedono dati a un server. Un server ascolta tramite un socket in ascolto le nuove connessioni, le stabilisce, riceve le richieste del client e comunica i dati richiesti nella sua risposta al client.

Dall'altro lato, un client crea un socket usando l'indirizzo IP e la porta del server a cui desidera connettersi, avvia una connessione, comunica la sua richiesta al server e riceve i dati in risposta. Questo scambio fluido di informazioni tra i socket client e server è la spina dorsale di numerose applicazioni di rete.

I socket come base per i protocolli di rete

Il fatto che i socket siano una spina dorsale implica anche che esistano vari protocolli costruiti e usati sopra di essi. Tra i più comuni ci sono UDP e TCP, di cui abbiamo già parlato brevemente. I socket che usano uno di questi protocolli di trasporto sono chiamati socket UDP o TCP.

Socket IPC

Oltre ai socket di rete, esistono anche altri tipi. Ad esempio, i socket IPC (Inter Process Communication). I socket IPC servono a trasferire dati tra processi sulla stessa macchina, mentre i socket di rete possono fare lo stesso attraverso la rete.

Il vantaggio dei socket IPC è che evitano gran parte dell'overhead della costruzione dei pacchetti e della risoluzione dei percorsi per inviare i dati. Poiché nel contesto dell'IPC mittente e destinatario sono processi locali, la comunicazione tramite socket IPC ha in genere una latenza inferiore.

Socket Unix

Un buon esempio di socket IPC sono i socket Unix che sono, come spesso in Unix, semplici file nel filesystem. Non sono identificati da indirizzo IP e porta, ma dal percorso del file nel filesystem.

Socket di rete come socket IPC

Nota che puoi utilizzare anche i socket di rete per la comunicazione tra processi se sia server sia destinatario sono su localhost (cioè hanno indirizzo IP 127.0.0.1).

Certo, da un lato questo aggiunge latenza per via dell'overhead legato all'elaborazione dei dati da parte dello stack di rete, ma dall'altro ci consente di non preoccuparci del sistema operativo sottostante, poiché i socket di rete sono presenti e funzionano su tutti i sistemi, al contrario dei socket IPC che sono specifici per un dato OS o famiglia di OS.

Libreria socket di Python

Per la programmazione di socket in Python, usiamo la libreria ufficiale integrata socket di Python, composta da funzioni, costanti e classi per creare, gestire e lavorare con i socket. Alcune funzioni comunemente usate di questa libreria includono:

  • socket(): crea un nuovo socket.
  • bind(): associa il socket a uno specifico indirizzo e porta.
  • listen(): avvia l'ascolto di connessioni in arrivo sul socket.
  • accept(): accetta una connessione da un client e restituisce un nuovo socket per la comunicazione.
  • connect(): stabilisce una connessione a un server remoto.
  • send(): invia dati attraverso il socket.
  • recv(): riceve dati dal socket.
  • close(): chiude la connessione del socket.

Esempio di socket in Python

Vediamo la programmazione di socket con un esempio pratico scritto in Python. Qui, il nostro obiettivo è connettere due applicazioni e farle comunicare tra loro. Useremo la libreria socket di Python per creare un'applicazione server socket che comunicherà e scambierà informazioni con un client su una rete.

Considerazioni e limitazioni

Nota, tuttavia, che per scopi didattici il nostro esempio è semplificato e le applicazioni verranno eseguite in locale e non parleranno sulla rete reale: useremo l'indirizzo di loopback localhost per connettere il client al server.

Ciò significa che sia client sia server gireranno sulla stessa macchina e il client avvierà una connessione verso la stessa macchina su cui è in esecuzione, sebbene verso un processo diverso che rappresenta il server.

Esecuzione su macchine diverse

In alternativa, potresti avere le applicazioni su due dispositivi diversi e connetterli entrambi allo stesso router Wi‑Fi, formando così una rete locale. Il client in esecuzione su un dispositivo potrebbe quindi connettersi al server in esecuzione su un'altra macchina.

In questo caso, però, dovresti conoscere gli indirizzi IP che il router ha assegnato ai tuoi dispositivi e usarli al posto dell'indirizzo IP di loopback localhost (127.0.0.1) (per vedere gli indirizzi IP, usa il comando da terminale ifconfig per sistemi tipo Unix o ipconfig per Windows). Dopo aver ottenuto gli indirizzi IP delle tue applicazioni, puoi modificarli di conseguenza nel codice e l'esempio funzionerà comunque.

Creare un server socket in Python

Iniziamo creando un server socket (in particolare un server TCP in Python, poiché lavorerà con socket TCP, come vedremo), che scambierà messaggi con i client. 

Quindi, segui i passaggi qui sotto:

Creare un file Python con un po' di boilerplate

  • Crea un file chiamato server.py

  • Importa il modulo socket nel tuo script Python.

import socket
  • Aggiungi una funzione chiamata run_server. Aggiungeremo lì la maggior parte del nostro codice. Quando aggiungi il codice alla funzione, non dimenticare di indentarlo correttamente:
def run_server():
    # your code will go here

Istanziare l'oggetto socket

Come passo successivo, in run_server crea un oggetto socket usando la funzione socket.socket().

Il primo argomento (socket.AF_INET) specifica la famiglia di indirizzi IP per IPv4 (altre opzioni includono: AF_INET6 per la famiglia IPv6 e AF_UNIX per i socket Unix)

Il secondo argomento (socket.SOCK_STREAM) indica che stiamo usando un socket TCP.

Nel caso dell'uso di TCP, il sistema operativo creerà una connessione affidabile con consegna in ordine dei dati, rilevamento degli errori e ritrasmissione, e controllo di flusso. Non dovrai preoccuparti di implementare tutti questi dettagli.

C'è anche l'opzione per specificare un socket UDP: socket.SOCK_DGRAM. Questo creerà un socket che implementa tutte le funzionalità di UDP dietro le quinte.

Se vuoi scendere a un livello ancora più basso e costruire il tuo protocollo di livello trasporto sopra il protocollo di livello rete TCP/IP usato dai socket, puoi usare il valore socket.SOCK_RAW per il secondo argomento. In questo caso, il sistema operativo non gestirà per te alcuna funzionalità di protocolli di livello superiore e dovrai implementare da solo tutti gli header, la conferma della connessione e le funzionalità di ritrasmissione, se necessarie. Esistono anche altri valori di cui puoi leggere nella documentazione.

# create a socket object
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

Associare il socket del server a indirizzo IP e porta

Definisci il nome host o l'IP del server e la porta per indicare l'indirizzo dal quale il server sarà raggiungibile e dove ascolterà le connessioni in arrivo. In questo esempio, il server è in ascolto sulla macchina locale, definita dalla variabile server_ip impostata su 127.0.0.1 (detta anche localhost).

La variabile port è impostata su 8000, che è il numero di porta con cui l'applicazione server sarà identificata dal sistema operativo (si consiglia di usare valori superiori a 1023 per evitare collisioni con porte usate da processi di sistema).

server_ip = "127.0.0.1"
    port = 8000

Prepara il socket a ricevere connessioni associandolo all'indirizzo IP e alla porta che abbiamo definito prima.

# bind the socket to a specific address and port
    server.bind((server_ip, port))

Mettersi in ascolto delle connessioni in arrivo

Metti il socket del server in stato di ascolto con la funzione listen. Il suo argomento backlog imposta quante connessioni in arrivo possono rimanere in coda prima di essere accettate.

Nel nostro esempio passiamo 0, che su molti sistemi limita di fatto il server a un client alla volta: ulteriori tentativi di connessione vengono rifiutati mentre è occupato. Un valore più alto (ad esempio 1 o più) consente a quel numero di client di attendere in coda; una volta che accept viene chiamato su un client, questo lascia la coda e lo slot si libera.

Se ometti l'argomento, si ricade su un valore predefinito di sistema (su Unix, visibile in /proc/sys/net/core/somaxconn).

# listen for incoming connections
    server.listen(0)
    print(f"Listening on {server_ip}:{port}")

Accettare le connessioni in arrivo

Poi, attendi e accetta le connessioni dei client in arrivo. Il metodo accept blocca il thread di esecuzione finché un client non si connette. Quindi restituisce una coppia di tuple (conn, address), dove address è una tupla con l'indirizzo IP e la porta del client, e conn è un nuovo oggetto socket che condivide una connessione con il client e può essere usato per comunicare con lui.

accept crea un nuovo socket per comunicare con il client invece di associare il socket in ascolto (chiamato server nel nostro esempio) all'indirizzo del client e usarlo per la comunicazione, perché il socket in ascolto deve continuare ad ascoltare altre connessioni da altri client, altrimenti rimarrebbe bloccato. Ovviamente, nel nostro caso gestiamo un solo client e rifiutiamo tutte le altre connessioni mentre lo facciamo, ma ciò sarà più rilevante quando arriveremo all'esempio del server multithread.

# accept incoming connections
    client_socket, client_address = server.accept()
    print(f"Accepted connection from {client_address[0]}:{client_address[1]}")

Creare il loop di comunicazione

Non appena una connessione con il client è stata stabilita (dopo la chiamata al metodo accept), avviamo un ciclo infinito di comunicazione. In questo ciclo, effettuiamo una chiamata al metodo recv dell'oggetto client_socket. Questo metodo riceve il numero di byte specificato dal client: nel nostro caso 1024.

1024 byte è solo una convenzione comune per la dimensione del payload, poiché è una potenza di due, potenzialmente migliore per scopi di ottimizzazione rispetto ad altri valori arbitrari. Sei comunque libero di modificare questo valore come preferisci.

Poiché i dati ricevuti dal client nella variabile request sono in forma binaria grezza, li trasformiamo da sequenza di byte a stringa usando la funzione decode.

Poi abbiamo un'istruzione if, che esce dal ciclo di comunicazione nel caso in cui riceviamo un messaggio ”close”. Questo significa che non appena il nostro server riceve la stringa ”close” nella request, invia la conferma al client e termina la connessione. Altrimenti, stampiamo il messaggio ricevuto in console. La conferma, nel nostro caso, consiste semplicemente nell'invio della stringa ”closed” al client.

Nota che il metodo lower che usiamo sulla stringa request nell'if la converte semplicemente in minuscolo. In questo modo non ci interessa se la stringa close fosse stata scritta in maiuscolo o minuscolo.

# receive data from the client
    while True:
        request = client_socket.recv(1024)
        request = request.decode("utf-8") # convert bytes to string
        
        # if we receive "close" from the client, then we break
        # out of the loop and close the connection
        if request.lower() == "close":
            # send response to the client which acknowledges that the
            # connection should be closed and break out of the loop
            client_socket.send("closed".encode("utf-8"))
            break

        print(f"Received: {request}")

Inviare la risposta al client

Ora dobbiamo gestire la risposta normale del server al client (cioè quando il client non vuole chiudere la connessione). All'interno del ciclo while, subito dopo print(f"Received: {request}"), aggiungi le seguenti righe, che convertiranno una stringa di risposta (nel nostro caso ”accepted”) in byte e la invieranno al client. In questo modo, ogni volta che il server riceve un messaggio dal client che non è ”close”, invierà la stringa ”accepted” in risposta:

response = "accepted".encode("utf-8") # convert string to bytes
# convert and send accept response to the client
client_socket.send(response)

Liberare le risorse

Una volta usciti dal ciclo while infinito, la comunicazione con il client è completa, quindi chiudiamo il socket del client usando il metodo close per rilasciare le risorse di sistema. Chiudiamo anche il socket del server usando lo stesso metodo, il che in pratica spegne il nostro server. In uno scenario reale, probabilmente vorremmo che il server continuasse ad ascoltare altri client e non si spegnesse dopo aver comunicato con uno solo, ma non preoccuparti, arriveremo a un altro esempio più avanti.

Per ora, aggiungi le seguenti righe dopo il ciclo while infinito:

# close connection socket with the client
    client_socket.close()
    print("Connection to client closed")
    # close server socket
    server.close()

Nota: non dimenticare di chiamare la funzione run_server alla fine del file server.py. Usa semplicemente la seguente riga di codice:

run_server()

Esempio completo di codice del server socket

Ecco il codice sorgente completo di server.py:

import socket

def run_server():
    # create a socket object
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    server_ip = "127.0.0.1"
    port = 8000

    # bind the socket to a specific address and port
    server.bind((server_ip, port))
    # listen for incoming connections
    server.listen(0)
    print(f"Listening on {server_ip}:{port}")

    # accept incoming connections
    client_socket, client_address = server.accept()
    print(f"Accepted connection from {client_address[0]}:{client_address[1]}")

    # receive data from the client
    while True:
        request = client_socket.recv(1024)
        request = request.decode("utf-8") # convert bytes to string
        
        # if we receive "close" from the client, then we break
        # out of the loop and close the conneciton
        if request.lower() == "close":
            # send response to the client which acknowledges that the
            # connection should be closed and break out of the loop
            client_socket.send("closed".encode("utf-8"))
            break

        print(f"Received: {request}")

        response = "accepted".encode("utf-8") # convert string to bytes
        # convert and send accept response to the client
        client_socket.send(response)

    # close connection socket with the client
    client_socket.close()
    print("Connection to client closed")
    # close server socket
    server.close()

run_server()

Nota che, per non complicare questo esempio di base, abbiamo omesso la gestione degli errori. Vorrai ovviamente aggiungere blocchi try‑except e assicurarti di chiudere sempre i socket nella clausola finally. Continua a leggere e vedremo un esempio più avanzato.

Creare un socket client in Python

Dopo aver configurato il server, il passo successivo è impostare un client che si connetta e invii richieste al server. Quindi, iniziamo con i passaggi seguenti:

Creare un file Python con un po' di boilerplate

  • Crea un nuovo file chiamato client.py
  • Importa la libreria socket:
import socket
  • Definisci la funzione run_client dove inseriremo tutto il nostro codice:
def run_client():
    # your code will go here

Istanziare l'oggetto socket

Quindi, usa la funzione socket.socket() per creare un oggetto socket TCP che funge da punto di contatto del client con il server.

# create a socket object
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

Connessione al socket del server

Specifica l'indirizzo IP e la porta del server per poterti connettere. Questi devono corrispondere all'indirizzo IP e alla porta che hai impostato prima in server.py.

server_ip = "127.0.0.1"  # replace with the server's IP address
server_port = 8000  # replace with the server's port number

Stabilisci una connessione con il server usando il metodo connect sull'oggetto socket del client. Nota che non abbiamo associato il socket del client a nessun indirizzo IP o porta. Questo è normale per il client, perché connect sceglierà automaticamente una porta libera e selezionerà un indirizzo IP che fornisce il percorso migliore verso il server dalle interfacce di rete del sistema (127.0.0.1 nel nostro caso) e vi assocerà il socket del client.

# establish connection with server
client.connect((server_ip, server_port))

Creare il loop di comunicazione

Dopo aver stabilito una connessione, avviamo un ciclo di comunicazione infinito per inviare più messaggi al server. Otteniamo l'input dall'utente usando la funzione integrata di Python input, poi lo codifichiamo in byte e lo limitiamo a un massimo di 1024 byte. Dopo di che inviamo il messaggio al server usando client.send.

while True:
   # input message and send it to the server
   msg = input("Enter message: ")
   client.send(msg.encode("utf-8")[:1024])

Gestire la risposta del server

Una volta che il server riceve un messaggio dal client, risponde. Ora, nel nostro codice client, vogliamo ricevere la risposta del server. A tal fine, nel loop di comunicazione, usiamo il metodo recv per leggere al massimo 1024 byte. Poi convertiamo la risposta da byte a stringa usando decode e verifichiamo se è uguale al valore ”closed”. In tal caso, usciamo dal ciclo il che, come vedremo, terminerà la connessione del client. Altrimenti, stampiamo la risposta del server in console.

# receive message from the server
response = client.recv(1024)
response = response.decode("utf-8")

# if server sent us "closed" in the payload, we break out of the loop and close our socket
if response.lower() == "closed":
   break

print(f"Received: {response}")

Liberare le risorse

Infine, dopo il ciclo while, chiudi la connessione del socket client usando il metodo close. Questo garantisce che le risorse vengano rilasciate correttamente e che la connessione sia terminata (cioè quando riceviamo il messaggio “closed” e usciamo dal ciclo).

# close client socket (connection to the server)
client.close()
print("Connection to server closed")

Nota: Anche qui, non dimenticare di chiamare la funzione run_client, che abbiamo implementato sopra, alla fine del file come segue:

run_client()

Esempio completo di codice del client socket

Ecco il codice completo di client.py:

import socket

def run_client():
    # create a socket object
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    server_ip = "127.0.0.1"  # replace with the server's IP address
    server_port = 8000  # replace with the server's port number
    # establish connection with server
    client.connect((server_ip, server_port))

    while True:
        # input message and send it to the server
        msg = input("Enter message: ")
        client.send(msg.encode("utf-8")[:1024])

        # receive message from the server
        response = client.recv(1024)
        response = response.decode("utf-8")

        # if server sent us "closed" in the payload, we break out of the loop and close our socket
        if response.lower() == "closed":
            break

        print(f"Received: {response}")

    # close client socket (connection to the server)
    client.close()
    print("Connection to server closed")

run_client()

Testare client e server

Per testare l'implementazione di server e client che abbiamo scritto sopra, procedi così:

  • Apri due finestre di terminale contemporaneamente.
  • In una finestra di terminale, vai nella directory in cui si trova il file server.py ed esegui il seguente comando per avviare il server:
python server.py

Questo assocerà il socket del server all'indirizzo localhost (127.0.0.1) sulla porta 8000 e inizierà ad ascoltare le connessioni in arrivo.

  • Nell'altra finestra di terminale, vai nella directory in cui si trova il file client.py ed esegui il seguente comando per avviare il client:
python client.py

Ti verrà richiesto un input. Puoi quindi digitare il tuo messaggio e premere Invio. Questo trasferirà il tuo input al server e lo visualizzerà nella sua finestra di terminale. Il server invierà la sua risposta al client e quest'ultimo ti chiederà nuovamente l'input. Questo continuerà finché non invierai la stringa ”close” al server.

Lavorare con più client – Multithreading

Nel precedente esempio abbiamo visto come un server risponde alle richieste di un singolo client; tuttavia, in molte situazioni pratiche, numerosi client potrebbero dover connettersi a un singolo server contemporaneamente. Qui entra in gioco il multithreading. Il multithreading si usa quando devi gestire più attività (ad es. eseguire più funzioni) in parallelo (allo stesso tempo).

L'idea è generare un thread, che è un insieme indipendente di istruzioni gestibile dal processore. I thread sono molto più leggeri dei processi, perché vivono all'interno di un processo stesso e non richiedono l'allocazione di molte risorse.

Limitazioni del multithreading in Python

Nella build CPython predefinita, un solo thread esegue bytecode Python alla volta, a causa del Global Interpreter Lock (GIL). Per la programmazione di socket va bene — il GIL viene rilasciato durante l'I/O di rete, quindi i thread possono realmente attendere connessioni in parallelo. Da Python 3.13 (ottobre 2024), CPython offre anche una build free‑threaded (PEP 703) che rimuove il GIL, e asyncio è la scelta moderna più comune per codice socket ad alta concorrenza.

Esempio di server multithread

Vediamo l'esempio seguente su come aggiungere il multithreading al tuo server per gestire un gran numero di client. Nota che questa volta aggiungeremo anche una gestione degli errori di base usando i blocchi try‑except‑finally. Per iniziare, segui i passaggi qui sotto:

Creare la funzione del server che genera thread

Nel tuo file Python, importa i moduli socket e threading per poter lavorare sia con i socket che con i thread:

import socket
import threading

Definisci la funzione run_server che, come nell'esempio sopra, creerà un socket server, lo assocerà e ascolterà le connessioni in arrivo. Poi chiama accept in un ciclo while infinito. Questo manterrà sempre l'ascolto di nuove connessioni. Dopo che accept riceve una connessione in arrivo e restituisce, crea un thread usando il costruttore threading.Thread. Questo thread eseguirà la funzione handle_client, che definiremo più avanti, e le passerà client_socket e addr come argomenti (la tupla addr contiene indirizzo IP e porta del client connesso). Dopo aver creato il thread, chiama start per avviarne l'esecuzione.

Ricorda che la chiamata a accept è bloccante, quindi alla prima iterazione del ciclo while, quando raggiungiamo la riga con accept, ci fermiamo e aspettiamo una connessione del client senza eseguire altro. Non appena il client si connette, il metodo accept restituisce, e continuiamo l'esecuzione: generiamo un thread che gestirà quel client e passiamo all'iterazione successiva, dove ci fermeremo di nuovo alla chiamata a accept in attesa che si connetta un altro client.

Alla fine della funzione, abbiamo un po' di gestione degli errori che garantisce che il socket del server venga sempre chiuso se succede qualcosa di inatteso.

def run_server():
    server_ip = "127.0.0.1"  # server hostname or IP address
    port = 8000  # server port number
    # create a socket object
    try:
        server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        # bind the socket to the host and port
        server.bind((server_ip, port))
        # listen for incoming connections
        server.listen()
        print(f"Listening on {server_ip}:{port}")

        while True:
            # accept a client connection
            client_socket, addr = server.accept()
            print(f"Accepted connection from {addr[0]}:{addr[1]}")
            # start a new thread to handle the client
            thread = threading.Thread(target=handle_client, args=(client_socket, addr,))
            thread.start()
    except Exception as e:
        print(f"Error: {e}")
    finally:
        server.close()

Nota che il server nel nostro esempio verrà interrotto solo in caso di errore inatteso. Altrimenti, ascolterà i client indefinitamente e dovrai chiudere il terminale per fermarlo.

Creare la funzione di gestione del client da eseguire in un thread separato

Ora, sopra la funzione run_server, definiscine un'altra chiamata handle_client. Questa funzione sarà eseguita in un thread separato per ogni connessione del client. Riceve come argomenti l'oggetto socket del client e la tupla addr.

All'interno di questa funzione facciamo quanto già fatto nell'esempio a singolo thread, più un po' di gestione degli errori: avviamo un ciclo per ottenere i messaggi dal client usando recv.

Poi verifichiamo se abbiamo ricevuto un messaggio di chiusura. In tal caso, rispondiamo con la stringa ”closed” e chiudiamo la connessione uscendo dal ciclo. Altrimenti, stampiamo la stringa della richiesta del client in console e procediamo alla successiva iterazione per ricevere il messaggio successivo del client.

Alla fine di questa funzione, abbiamo una gestione degli errori per i casi imprevisti (clausola except) e anche una clausola finally in cui rilasciamo client_socket usando close. Questa clausola finally verrà sempre eseguita comunque vada, garantendo che il socket del client venga sempre rilasciato correttamente.

def handle_client(client_socket, addr):
    try:
        while True:
            # receive and print client messages
            request = client_socket.recv(1024).decode("utf-8")
            if request.lower() == "close":
                client_socket.send("closed".encode("utf-8"))
                break
            print(f"Received: {request}")
            # convert and send accept response to the client
            response = "accepted"
            client_socket.send(response.encode("utf-8"))
    except Exception as e:
        print(f"Error when handling client: {e}")
    finally:
        client_socket.close()
        print(f"Connection to client ({addr[0]}:{addr[1]}) closed")

Quando handle_client termina, il thread che la esegue verrà automaticamente rilasciato.

Nota: Non dimenticare di chiamare la funzione run_server alla fine del file.

Esempio completo di codice del server multithread

Ora mettiamo insieme il codice completo del server multithread:

import socket
import threading

def handle_client(client_socket, addr):
    try:
        while True:
            # receive and print client messages
            request = client_socket.recv(1024).decode("utf-8")
            if request.lower() == "close":
                client_socket.send("closed".encode("utf-8"))
                break
            print(f"Received: {request}")
            # convert and send accept response to the client
            response = "accepted"
            client_socket.send(response.encode("utf-8"))
    except Exception as e:
        print(f"Error when hanlding client: {e}")
    finally:
        client_socket.close()
        print(f"Connection to client ({addr[0]}:{addr[1]}) closed")

def run_server():
    server_ip = "127.0.0.1"  # server hostname or IP address
    port = 8000  # server port number
    # create a socket object
    try:
        server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        # bind the socket to the host and port
        server.bind((server_ip, port))
        # listen for incoming connections
        server.listen()
        print(f"Listening on {server_ip}:{port}")

        while True:
            # accept a client connection
            client_socket, addr = server.accept()
            print(f"Accepted connection from {addr[0]}:{addr[1]}")
            # start a new thread to handle the client
            thread = threading.Thread(target=handle_client, args=(client_socket, addr,))
            thread.start()
    except Exception as e:
        print(f"Error: {e}")
    finally:
        server.close()

run_server()

Nota: Nel codice reale, per prevenire possibili problemi come race condition o inconsistenze dei dati quando si lavora con server multithread, è fondamentale considerare tecniche di sicurezza dei thread e sincronizzazione. Nel nostro semplice esempio, tuttavia, non è un problema.

Esempio di client con gestione degli errori di base

Ora che abbiamo un'implementazione del server in grado di gestire più client in parallelo, possiamo usare la stessa implementazione del client vista sopra nei primi esempi di base per avviare la connessione, oppure possiamo aggiornarla leggermente aggiungendo un po' di gestione degli errori. Qui sotto trovi il codice, identico al precedente esempio di client con l'aggiunta dei blocchi try‑except:

import socket

def run_client():
    # create a socket object
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    server_ip = "127.0.0.1"  # replace with the server's IP address
    server_port = 8000  # replace with the server's port number
    # establish connection with server
    client.connect((server_ip, server_port))

    try:
        while True:
            # get input message from user and send it to the server
            msg = input("Enter message: ")
            client.send(msg.encode("utf-8")[:1024])

            # receive message from the server
            response = client.recv(1024)
            response = response.decode("utf-8")

            # if server sent us "closed" in the payload, we break out of
            # the loop and close our socket
            if response.lower() == "closed":
                break

            print(f"Received: {response}")
    except Exception as e:
        print(f"Error: {e}")
    finally:
        # close client socket (connection to the server)
        client.close()
        print("Connection to server closed")

run_client()

Testare l'esempio multithreading

Se vuoi testare l'implementazione multi‑client, apri diverse finestre di terminale per i client e una per il server. Avvia prima il server con python server.py. Dopo avvia un paio di client usando python client.py. Nella finestra del terminale del server vedrai come nuovi client si connettono al server. Ora puoi procedere inviando messaggi da client diversi inserendo testo nei rispettivi terminali: tutti verranno gestiti e stampati in console lato server.

Applicazioni della programmazione di socket nella data science

Sebbene ogni applicazione di rete usi socket creati dal sistema operativo dietro le quinte, esistono numerosi sistemi che fanno forte affidamento specificamente sulla programmazione di socket, sia per particolari casi d'uso, sia per migliorare le prestazioni. Ma in che modo la programmazione di socket è utile nel contesto della data science? Beh, gioca sicuramente un ruolo significativo quando c'è bisogno di ricevere o inviare grandi quantità di dati velocemente. Quindi, la programmazione di socket è usata principalmente per la raccolta dati e l'elaborazione in tempo reale, il calcolo distribuito e la comunicazione tra processi. Vediamo però più da vicino alcune applicazioni specifiche nel campo della data science.

Raccolta dati in tempo reale

I socket sono ampiamente usati per raccogliere dati in tempo reale da diverse fonti per un'elaborazione successiva, l'inoltro a un database o a una pipeline di analytics, ecc. Ad esempio, un socket può essere usato per ricevere istantaneamente dati da un sistema finanziario o da un'API di social media per la successiva elaborazione da parte dei data scientist.

Calcolo distribuito

I data scientist possono usare la connettività via socket per distribuire l'elaborazione e il calcolo di set di dati enormi su più macchine. La programmazione di socket è comunemente usata in Apache Spark e in altri framework di calcolo distribuito per la comunicazione tra nodi.

Deployment dei modelli

La programmazione di socket può essere usata quando si servono modelli di machine learning agli utenti, consentendo la consegna istantanea di previsioni e suggerimenti. Per facilitare il decision making in tempo reale, i data scientist possono usare applicazioni server performanti basate su socket che acquisiscono grandi quantità di dati, li elaborano con modelli addestrati per fornire previsioni e poi restituiscono rapidamente i risultati al client.

Comunicazione inter‑processo (IPC)

I socket possono essere usati per l'IPC, che consente a diversi processi in esecuzione sulla stessa macchina di comunicare tra loro e scambiarsi dati. Questo è utile nella data science per distribuire calcoli complessi e ad alta intensità di risorse su più processi. In effetti, la libreria subprocessing di Python è spesso usata a questo scopo: genera più processi per utilizzare più core della CPU e aumentare le prestazioni dell'applicazione durante calcoli pesanti. La comunicazione tra tali processi può essere implementata tramite socket IPC.

Collaborazione e comunicazione

La programmazione di socket consente la comunicazione e la collaborazione in tempo reale tra data scientist. Per favorire una collaborazione efficace e la condivisione della conoscenza, si usano app di chat basate su socket o piattaforme collaborative di analisi dei dati.

Vale la pena dire che, in molte delle applicazioni sopra, i data scientist potrebbero non essere coinvolti direttamente nel lavoro con i socket. Tipicamente userebbero librerie, framework e sistemi che astraggono tutti i dettagli di basso livello della programmazione di socket. Tuttavia, sotto il cofano, tutte queste soluzioni si basano sulla comunicazione via socket e utilizzano la programmazione di socket.

Sfide e best practice della programmazione di socket

Poiché i socket sono un concetto di basso livello per la gestione delle connessioni, gli sviluppatori che ci lavorano devono implementare tutta l'infrastruttura necessaria attorno ad essi per creare applicazioni robuste e affidabili. Questo comporta, ovviamente, molte sfide. Tuttavia, esistono alcune best practice e linee guida generali che si possono seguire per superare questi problemi. Di seguito sono riportati alcuni dei problemi più frequenti con la programmazione di socket, insieme a consigli generali:

Gestione delle connessioni

Lavorare con molte connessioni contemporaneamente; gestire più client e garantire un trattamento efficiente delle richieste concorrenti può sicuramente essere impegnativo e non banale. Richiede un'attenta gestione delle risorse e coordinamento per evitare colli di bottiglia

Best practice

  • Tieni traccia delle connessioni attive usando strutture dati come liste o dizionari. Oppure usa tecniche avanzate come il connection pooling, che aiuta anche con la scalabilità.
  • Usa tecniche di threading o programmazione asincrona per gestire più connessioni client contemporaneamente.
  • Chiudi correttamente le connessioni per rilasciare le risorse ed evitare memory leak.

Gestione degli errori

Gestire errori come fallimenti di connessione, timeout e problemi di trasmissione dei dati è fondamentale. Gestire questi errori e fornire un feedback appropriato ai client può essere impegnativo, specialmente nella programmazione di socket di basso livello.

Best practice

  • Usa blocchi try‑except‑finally per intercettare e gestire tipi specifici di errori.
  • Fornisci messaggi di errore informativi e valuta l'uso del logging per facilitare la risoluzione dei problemi.

Scalabilità e prestazioni

Garantire prestazioni ottimali e minimizzare la latenza sono preoccupazioni chiave quando si gestiscono flussi di dati ad alto volume o applicazioni in tempo reale.

Best practice

  • Ottimizza il tuo codice per le prestazioni minimizzando l'elaborazione dei dati non necessaria e l'overhead di rete.
  • Implementa tecniche di buffering per gestire in modo efficiente trasferimenti di dati di grandi dimensioni.
  • Considera tecniche di bilanciamento del carico per distribuire le richieste dei client su più istanze server.

Sicurezza e autenticazione

Proteggere la comunicazione basata su socket e implementare meccanismi di autenticazione adeguati può essere difficile. Garantire la privacy dei dati, prevenire accessi non autorizzati e proteggere da attività malevole richiede una valutazione attenta e l'implementazione di protocolli sicuri.

Best practice

  • Utilizza protocolli di sicurezza SSL/TLS per garantire la trasmissione sicura dei dati tramite crittografia.
  • Garantisci l'identità del client implementando metodi di autenticazione sicuri come autenticazione basata su token, crittografia a chiave pubblica o username/password.
  • Assicurati che i dati riservati, come password o chiavi API, siano protetti e crittografati o idealmente non memorizzati affatto (se necessario, solo i loro hash).

Affidabilità e resilienza della rete

Gestire interruzioni di rete, banda fluttuante e connessioni inaffidabili può rappresentare una sfida. Mantenere una connessione stabile, gestire le disconnessioni in modo elegante e implementare meccanismi di riconnessione sono essenziali per applicazioni di rete robuste.

Best practice

  • Usa messaggi keep‑alive per rilevare connessioni inattive o interrotte.
  • Implementa timeout per evitare blocchi indefiniti e garantire una gestione tempestiva delle risposte.
  • Implementa una logica di riconnessione con backoff esponenziale per ristabilire la connessione se viene persa.

Manutenibilità del codice

Ultima, ma non meno importante, la manutenibilità del codice. A causa della natura di basso livello della programmazione di socket, gli sviluppatori si ritrovano a scrivere più codice. Questo può trasformarsi rapidamente in spaghetti code ingestibile, quindi è essenziale organizzarlo e strutturarlo il prima possibile e dedicare uno sforzo extra alla progettazione dell'architettura del codice.

Best practice

  • Spezza il tuo codice in classi o funzioni che idealmente non dovrebbero essere troppo lunghe.
  • Scrivi i test unitari fin da subito, simulando le implementazioni di client e server
  • Valuta l'uso di librerie più di alto livello per gestire le connessioni, a meno che tu non debba assolutamente usare la programmazione di socket.

Conclusioni: programmazione di socket in Python

I socket sono una parte integrante di tutte le applicazioni di rete. In questo articolo, abbiamo visto la programmazione di socket in Python. Ecco i punti chiave da ricordare:

  • I socket sono interfacce che astraggono la gestione delle connessioni.
  • I socket abilitano la comunicazione tra processi diversi (di solito un client e un server) in locale o su rete.
  • In Python, si lavora con i socket tramite la libreria socket che, tra le altre cose, fornisce un oggetto socket con vari metodi come recv, send, listen, close.
  • La programmazione di socket ha varie applicazioni utili nella data science, tra cui raccolta dati, comunicazione tra processi e calcolo distribuito.
  • Le sfide nella programmazione di socket includono gestione delle connessioni, integrità dei dati, scalabilità, gestione degli errori, sicurezza e manutenibilità del codice.

Con competenze nella programmazione di socket, gli sviluppatori possono creare applicazioni di rete efficienti e in tempo reale. Padroneggiando concetti e best practice, possono sfruttare appieno il potenziale della programmazione di socket per sviluppare soluzioni affidabili e scalabili.

Tuttavia, la programmazione di socket è una tecnica molto di basso livello, difficile da usare perché gli ingegneri applicativi devono considerare ogni minimo dettaglio della comunicazione dell'applicazione.

Oggi, molto spesso non abbiamo bisogno di lavorare direttamente con i socket poiché sono tipicamente gestiti da librerie e framework di livello superiore, a meno che non ci sia la necessità di spremere davvero le prestazioni dell'applicazione o di scalarla.

Tuttavia, comprendere i socket e avere qualche insight su come funzionano le cose sotto il cofano porta a una maggiore consapevolezza complessiva come sviluppatore o data scientist ed è sempre una buona idea.

Per saperne di più sul ruolo di Python nell'analisi delle reti, dai un'occhiata al nostro corso Intermediate Network Analysis in Python. Puoi anche seguire il nostro percorso di competenze Python Programming per migliorare le tue abilità di programmazione in Python.

Serhii Orlivskyi

Autore

Serhii Orlivskyi

Serhii è un ingegnere del software con 3 anni di esperienza, principalmente in tecnologie backend come DBMS, linguaggi specifici per database, Python, Node.js ecc. Ha anche esperienza con framework frontend come Vue.js e React.js.

FAQs

A cosa serve la programmazione di socket in Python?

La programmazione di socket in Python consente alle applicazioni di scambiarsi dati su una rete o tra processi locali. Gli usi comuni includono app di chat, giochi multiplayer, comunicazione IoT, calcolo distribuito e serving di modelli di machine learning. La libreria integrata socket gestisce sia il codice client sia quello server.

Qual è la differenza tra socket TCP e UDP in Python?

I socket TCP (socket.SOCK_STREAM) offrono consegna affidabile e ordinata con controllo degli errori — ideali per app di chat, trasferimenti di file e web server. I socket UDP (socket.SOCK_DGRAM) sono più veloci ma inaffidabili, “spara e dimentica”, adatti a streaming video, gaming e sensori IoT.

Dovrei usare i socket Python o asyncio per la programmazione di rete?

Usa i raw socket quando ti serve un controllo fine o stai imparando come funziona il networking. Usa asyncio per la maggior parte del codice in produzione — gestisce migliaia di connessioni concorrenti più efficientemente del threading. FastAPI e aiohttp sono entrambe basate su asyncio.

Come gestisco più client con i socket Python?

L'approccio più semplice è il multithreading — genera un nuovo thread per connessione con threading.Thread. Per una concorrenza più elevata, usa asyncio per gestire molti client in un singolo thread. Per la produzione, il modulo socketserver della standard library fornisce classi pronte all'uso come ThreadingTCPServer e ForkingTCPServer.

Le connessioni socket Python sono sicure?

I socket Python grezzi trasmettono dati non cifrati per impostazione predefinita — chi intercetta il traffico può leggerli. Per una comunicazione sicura, avvolgi il tuo socket con TLS usando il modulo integrato ssl (ssl.SSLContext.wrap_socket()). Usa sempre TLS in produzione per qualsiasi dato sensibile.

Argomenti

Correlato