Multithreading e Concorrenza

Esecuzione parallela e gestione dei thread in Python

🖥️ Programma vs Processo

Un programma è un’entità statica (codice oggetto salvato in file), mentre un processo è l’entità dinamica utilizzata dal sistema operativo per rappresentare una specifica esecuzione di un programma.

Il processo è caratterizzato dal codice eseguibile, dalla memoria indirizzata, dai thread, e dai descrittori delle risorse in uso.

Programma vs Processo

🧵 Cos’è un Thread?

Un Thread è un flusso separato di esecuzione che racchiude una parte del codice da eseguire in modo indipendente dal resto del programma.

I thread condividono memoria e stato del processo (codice e variabili globali), ma hanno anche variabili locali accessibili solo al singolo thread.

Thread

📦 Modulo threading in Python

Importazione e creazione

import threading

# Creare un thread
t = threading.Thread(target=nome_funzione)

# Avviare il thread
t.start()

Esempio base con due thread

import threading
import time

def funzione(num):
    print(f"Partenza del Thread {num}")
    print("Elaboro.......\n")
    time.sleep(2)
    print(f"Finito lavoro {num}")

def main():
    t1 = threading.Thread(target=funzione, args=("Primo",))
    t2 = threading.Thread(target=funzione, args=("Secondo",))
    
    t1.start()
    t2.start()
    
    print("Fine chiamata main\n")

if __name__ == "__main__":
    main()
Partenza del Thread Primo
Elaboro…….
Partenza del Thread Secondo
Elaboro…….
Fine chiamata main
Finito lavoro Primo
Finito lavoro Secondo

🏷️ Identificazione dei Thread

Thread senza nome

import threading
import time

def funzione():
    print("Partenza del", threading.current_thread().name)
    time.sleep(2)
    print("Finito", threading.current_thread().name)

t1 = threading.Thread(target=funzione)
t2 = threading.Thread(target=funzione)

t1.start()
t2.start()
Partenza del Thread-1
Partenza del Thread-2
Finito Thread-1
Finito Thread-2

Thread con nome

import threading
import time

def funzione():
    print("Partenza del", threading.current_thread().name)
    time.sleep(2)
    print("Finito", threading.current_thread().name)

t1 = threading.Thread(target=funzione, name="Primo")
t2 = threading.Thread(target=funzione, name="Secondo")

t1.start()
t2.start()
Partenza del Primo
Partenza del Secondo
Finito Primo
Finito Secondo

🔗 Sincronizzazione con join()

Invocando il metodo join() su un oggetto Thread, il programma principale aspetta che il thread associato termini prima di continuare.

import threading
import time

def funzione():
    print(f"Partenza del "+threading.current_thread().name)
    time.sleep(2)
    print(f"Finito lavoro "+threading.current_thread().name)

def main():
    t1 = threading.Thread(target=funzione, name="Primo")
    t2 = threading.Thread(target=funzione, name="Secondo")
    
    t1.start()
    t2.start()
    t2.join()  # Aspetta che t2 finisca
    
    print("Fine chiamata main\n")

if __name__ == "__main__":
    main()
Partenza del Primo
Partenza del Secondo
Finito lavoro Primo
Finito lavoro Secondo
Fine chiamata main

🔒 Sezione Critica con Lock

Un Lock garantisce che solo un thread alla volta possa accedere a una sezione critica del codice, prevenendo race condition.

import threading
import time

luc = threading.Lock()

def funzione():
    luc.acquire()  # Acquisisce il lock
    print(f"Partenza del ", threading.current_thread().name)
    time.sleep(2)
    print(f"Finito lavoro ", threading.current_thread().name)
    luc.release()  # Rilascia il lock

def main():
    t1 = threading.Thread(target=funzione, name="Primo")
    t2 = threading.Thread(target=funzione, name="Secondo")
    
    t1.start()
    t2.start()
    t2.join()

if __name__ == "__main__":
    main()

Differenze Con/Senza Lock

Caratteristica Senza Lock Con Lock
Accesso risorse Concorrente, nessuna protezione Controllato, un thread alla volta
Output I messaggi possono mischiarsi Esecuzione sequenziale ordinata
Tempo ≈2 secondi (parallelo) ≈4 secondi (sequenziale)
Uso Thread indipendenti Risorse condivise

✅ Quando usare il Lock

  • Accesso a risorse condivise
  • Mantenere l’ordine di esecuzione
  • Prevenire race condition
  • Proteggere variabili globali

❌ Quando evitare il Lock

  • Thread con dati indipendenti
  • Problemi di prestazioni
  • Strutture dati già thread-safe
  • Operazioni atomiche

🌐 Applicazione Client-Server con Thread

Server Multithread

import socket
import threading

print_lock = threading.Lock()
active_threads = []

def operazione(conn):
    while True:
        ricevi = conn.recv(4096).decode()
        
        print_lock.acquire()
        print(ricevi + " da ", threading.current_thread().name)
        print_lock.release()
        
        risp = input("Inserisci una risposta: ")
        conn.sendall(risp.encode())
        
        if ricevi == "exit":
            conn.close()
            print_lock.acquire()
            active_threads.remove(threading.current_thread())
            print_lock.release()
            break

def main():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind(("127.0.0.1", 8080))
    s.listen()
    
    try:
        while True:
            conn, address = s.accept()
            
            print_lock.acquire()
            print(f"Connesso con {address}")
            print_lock.release()
            
            t = threading.Thread(target=operazione, args=(conn,))
            t.start()
            
            print_lock.acquire()
            active_threads.append(t)
            print_lock.release()
                
    except KeyboardInterrupt:
        print("\nServer in arresto...")
        s.close()
        
        for t in active_threads:
            t.join()
        
        print("Server arrestato")

if __name__ == "__main__":
    main()

Client

import socket

def client():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(("127.0.0.1", 8080))
    
    while True:
        msg = input("Inserisci un messaggio: ")
        s.sendall(msg.encode())
        rice = s.recv(4096).decode()
        print(rice)
        
        if msg == "exit":
            s.close()
            break

def main():
    client()

if __name__ == "__main__":
    main()

🔢 Server per Calcolo Parallelo

Server

import socket
import threading

calculation_lock = threading.Lock()
active_threads = []

def handle_calculation(conn, num):
    quadrato = num * num
    cubo = num * num * num
    response = f"Quadrato: {quadrato}, Cubo: {cubo}"
    conn.sendall(response.encode())
    conn.close()
    
    calculation_lock.acquire()
    active_threads.remove(threading.current_thread())
    calculation_lock.release()

def start_server():
    host = '127.0.0.1'
    port = 12345
    
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.bind((host, port))
    server_socket.listen()
    print(f"Server calcoli su {host}:{port}")
    
    try:
        while True:
            conn, addr = server_socket.accept()
            data = conn.recv(1024).decode()
            num = int(data)
            
            t = threading.Thread(target=handle_calculation, args=(conn, num))
            t.start()
            
            calculation_lock.acquire()
            active_threads.append(t)
            calculation_lock.release()
                    
    except KeyboardInterrupt:
        print("\nAttesa completamento thread...")
        calculation_lock.acquire()
        for t in active_threads:
            t.join()
        calculation_lock.release()
        print("Server arrestato")
        server_socket.close()

if __name__ == "__main__":
    start_server()

Client

import socket

def main():
    host = '127.0.0.1'
    port = 12345
    
    while True:
        num = input("Inserisci un numero (o 'exit'): ")
        if num.lower() == 'exit':
            break
        
        client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        client_socket.connect((host, port))
        client_socket.sendall(num.encode())
        data = client_socket.recv(1024).decode()
        print(f"Risultati: {data}")
        client_socket.close()

if __name__ == "__main__":
    main()

⚠️ Problemi di Concorrenza

1. Race Condition

Due thread modificano la stessa variabile contemporaneamente, perdendo alcuni incrementi.

import threading

contatore = 0

def incrementa():
    global contatore
    for _ in range(100000):
        contatore += 1

t1 = threading.Thread(target=incrementa)
t2 = threading.Thread(target=incrementa)

t1.start()
t2.start()
t1.join()
t2.join()

print(f"Valore: {contatore}")  # Dovrebbe essere 200000
Valore finale: 132457 # Risultato inconsistente!

2. Deadlock

Due thread si bloccano a vicenda aspettando lock che l’altro possiede.

import threading
import time

lockA = threading.Lock()
lockB = threading.Lock()

def thread1():
    lockA.acquire()
    time.sleep(1)
    lockB.acquire()  # Bloccato!
    lockB.release()
    lockA.release()

def thread2():
    lockB.acquire()
    time.sleep(1)
    lockA.acquire()  # Bloccato!
    lockA.release()
    lockB.release()

t1 = threading.Thread(target=thread1)
t2 = threading.Thread(target=thread2)
t1.start()
t2.start()
# Il programma si blocca indefinitamente

3. Output Mescolato

Senza lock, le stampe dei thread si mescolano in modo imprevedibile.

import threading

def stampa(msg):
    print(f"Inizio: {msg}")
    print(f"Fine: {msg}")

t1 = threading.Thread(target=stampa, args=("A",))
t2 = threading.Thread(target=stampa, args=("B",))

t1.start()
t2.start()
Inizio: A
Inizio: B
Fine: A
Fine: B

📋 Pattern di Programmazione

🔷 Thread per Connessione

Ogni client viene gestito da un thread separato per consentire connessioni multiple simultanee.

🔷 Lock Condiviso

Protegge le variabili condivise tra thread per evitare race condition e mantenere la consistenza dei dati.

🔷 Chiusura Sicura

Le connessioni e le risorse vengono chiuse correttamente per evitare memory leak e porte bloccate.

🔷 Simple Protocol

Protocollo di comunicazione semplice basato su stringhe per facilitare il debug e la manutenzione.

🧪 Come Testare gli Esempi

1

Avviare il server in un terminale: python nome_server.py

2

Avviare uno o più client in terminali separati: python nome_client.py

3

Per testare il multithreading, apri più client contemporaneamente e osserva come il server gestisce le connessioni in parallelo

Multithreading

🚀 Altre Lezioni

Continua il tuo percorso sui socket in Python

📚 Percorso Formativo

Completa il tuo percorso sui socket in Python

Torna in alto