Programmazione concorrente: thread, processi e async

La programmazione concorrente è difficile perché il nostro cervello pensa sequenzialmente (“prima A, poi B”). Ma i computer moderni hanno più core e devono fare tante cose insieme.

Concorrenza vs Parallelismo

CONCORRENZA: Fare tante cose a turno (1 CPU)
  ├─ Task A: 100ms
  ├─ Task B: 100ms
  ├─ Task A: 100ms
  └─ Total: 300ms (non risparmi tempo, ma "sembra" parallelo)

PARALLELISMO: Fare tante cose davvero insieme (N CPU)
  ├─ Core 1: Task A (200ms)
  └─ Core 2: Task B (200ms)
     Total: 200ms (risparmi tempo davvero)

Thread in Python

import threading
import time

def scarica_file():
    print("Inizio download...")
    time.sleep(2)
    print("Download completo!")

# Thread = esecuzione concorrente
thread1 = threading.Thread(target=scarica_file)
thread2 = threading.Thread(target=scarica_file)

thread1.start()
thread2.start()

thread1.join()  # Aspetta che finisca
thread2.join()

print("Entrambi finiti!")

# Output:
# Inizio download...
# Inizio download...
# Download completo!
# Download completo!
# (Tempo: ~2 secondi, non 4)

Multiprocessing: Parallelismo vero

from multiprocessing import Process
import time

def calcolo_pesante(n):
    risultato = sum(i**2 for i in range(n*1000000))
    print(f"Calcolato: {risultato}")

# Processo = vero parallelismo (core separati)
p1 = Process(target=calcolo_pesante, args=(100,))
p2 = Process(target=calcolo_pesante, args=(100,))

p1.start()
p2.start()

p1.join()
p2.join()

print("Fatto!")

# Sequenziale: ~4 secondi
# Parallelo (2 core): ~2 secondi

Async/Await: I/O-bound

import asyncio
import aiohttp

async def scarica(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            data = await response.text()
            print(f"Scaricato {len(data)} bytes")

# Scaricare 5 URL in "concorrenza"
async def main():
    urls = ['http://...'] * 5
    await asyncio.gather(*[scarica(url) for url in urls])

asyncio.run(main())

# Sequenziale: 5 richieste * 2s = 10s
# Async: ~2s (non aspetta, processa il prossimo)

Quando usare cosa?

I/O-bound (Network, file):
  ├─ Thread → Semplice
  └─ Async → Efficiente, ma più complesso

CPU-bound (Calcoli):
  ├─ Multiprocessing → Vero parallelismo
  └─ Thread → NON funziona (GIL in Python)

GIL (Global Interpreter Lock) in Python:
  ├─ Solo 1 thread esegue codice Python alla volta
  ├─ Thread è concorrente ma non parallelo per CPU
  └─ Soluzione: Multiprocessing

Problemi comuni

Race Condition

# SBAGLIATO: 2 thread modificano stessa variabile
counter = 0

def incrementa():
    global counter
    for _ in range(1000):
        counter += 1  # NON atomic!

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

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

print(counter)  # Potrebbe essere < 2000 !!

Soluzione: Lock

import threading

counter = 0
lock = threading.Lock()

def incrementa():
    global counter
    for _ in range(1000):
        with lock:  # Accesso esclusivo
            counter += 1

t1 = threading.Thread(target=incrementa)
t2 = threading.Thread(target=incrementa)
t1.start(); t2.start()
t1.join(); t2.join()

print(counter)  # Adesso sempre 2000 ✓

Deadlock

# SBAGLIATO: Circolo di aspettative
lock1 = threading.Lock()
lock2 = threading.Lock()

def task1():
    with lock1:
        time.sleep(0.1)  # Dai tempo a task2
        with lock2:      # Aspettiamo lock2... ma task2 l'ha!
            pass

def task2():
    with lock2:
        time.sleep(0.1)
        with lock1:      # Aspettiamo lock1... ma task1 l'ha!
            pass

t1 = threading.Thread(target=task1)
t2 = threading.Thread(target=task2)
t1.start()
t2.start()
t1.join()  # DEADLOCK: aspetta infinitamente!

Concorrenza è potente ma pericolosa. Usa sempre locks per variabili condivise.