Prima o poi ogni programmatore si trova davanti a un programma troppo lento perché fa una cosa alla volta: scarica cento pagine una dopo l'altra, elabora migliaia di immagini in sequenza, aspetta la risposta di un servizio prima di passare alla richiesta successiva. La soluzione è far procedere più attività insieme. Ma "insieme" può significare cose diverse, e scegliere lo strumento sbagliato può non portare alcun beneficio o, peggio, introdurre errori difficili da riprodurre.
Concorrenza e parallelismo
Due termini spesso confusi:
- concorrenza: gestire più attività che avanzano nello stesso periodo, alternandosi. Un cameriere che serve dieci tavoli è concorrente: non serve due tavoli nello stesso istante, ma non aspetta che il primo cliente finisca di mangiare prima di prendere l'ordine del secondo;
- parallelismo: eseguire più attività nello stesso istante, su più unità di calcolo. Dieci camerieri che servono dieci tavoli lavorano in parallelo.
La concorrenza riguarda la struttura del programma; il parallelismo riguarda l'esecuzione fisica. Si può avere concorrenza senza parallelismo, su un solo core, e i vantaggi possono essere comunque enormi.
La domanda decisiva: dove si perde il tempo?
Prima di scegliere uno strumento, bisogna capire che tipo di lavoro rallenta il programma.
- I/O-bound: il programma passa la maggior parte del tempo ad aspettare. Richieste di rete, query al database, lettura di file. La CPU è quasi inattiva.
- CPU-bound: il programma passa il tempo a calcolare. Elaborazione di immagini, compressione, calcoli numerici, analisi di grandi quantità di testo.
Per il lavoro I/O-bound serve concorrenza: mentre si aspetta una risposta, si avvia un'altra richiesta. Per il lavoro CPU-bound serve parallelismo: più core che calcolano contemporaneamente.
Il GIL di Python
Nell'implementazione standard di Python, CPython, esiste il Global Interpreter Lock: un meccanismo che permette a un solo thread alla volta di eseguire codice Python. La conseguenza pratica è che i thread non accelerano il codice Python puramente di calcolo. Durante le operazioni di I/O, invece, il lock viene rilasciato, quindi i thread funzionano bene per il lavoro di attesa.
Le versioni recenti di Python hanno introdotto una variante sperimentale senza GIL, ma al momento non è quella predefinita: per la maggior parte dei progetti la regola appena descritta resta valida.
Tre strumenti
Thread
I thread condividono la memoria del processo e sono adatti a lavori I/O-bound con librerie sincrone tradizionali. Il modo più semplice di usarli è un pool:
from concurrent.futures import ThreadPoolExecutor
import urllib.request
def scarica(url):
with urllib.request.urlopen(url, timeout=10) as r:
return len(r.read())
with ThreadPoolExecutor(max_workers=10) as pool:
dimensioni = list(pool.map(scarica, elenco_url))
Dieci download procedono insieme invece che in sequenza.
Processi
Ogni processo ha il suo interprete e la sua memoria, quindi il GIL non è un limite: più processi sfruttano davvero più core. Sono adatti al lavoro CPU-bound.
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor() as pool:
risultati = list(pool.map(elabora_immagine, percorsi))
Il prezzo è un costo di avvio più alto e la necessità di trasferire i dati tra processi, serializzandoli. Conviene quindi per compiti abbastanza pesanti da giustificare questo costo.
asyncio
asyncio permette di scrivere codice concorrente in un solo thread, usando async e await. Ogni await segna un punto in cui la funzione può sospendersi e lasciare lavorare le altre mentre aspetta.
import asyncio
async def controlla(servizio):
await asyncio.sleep(1) # simula una chiamata di rete
return f"{servizio}: ok"
async def main():
risultati = await asyncio.gather(*(controlla(s) for s in servizi))
È molto efficiente con migliaia di connessioni contemporanee, ma richiede librerie compatibili: una chiamata sincrona lenta dentro una funzione asincrona blocca tutto il ciclo di eventi.
Come scegliere
| Situazione | Strumento consigliato |
|---|---|
| Poche decine di richieste di rete con librerie sincrone | Thread |
| Migliaia di connessioni, server di rete, librerie asincrone disponibili | asyncio |
| Calcoli pesanti in Python puro | Processi |
| Calcoli numerici con librerie che rilasciano il GIL | Spesso bastano i thread o la libreria stessa |
I pericoli della memoria condivisa
Con i thread, più attività possono modificare gli stessi dati. Un'operazione apparentemente semplice come contatore += 1 non è atomica: due thread possono leggere lo stesso valore e scrivere lo stesso risultato, perdendo un incremento. Questi errori, chiamati race condition, sono intermittenti e difficili da riprodurre. Le difese principali:
- evitare di condividere dati modificabili, facendo restituire a ogni attività il proprio risultato, come nei pool visti sopra;
- usare strutture pensate per la concorrenza, come
queue.Queue; - proteggere le sezioni critiche con un
threading.Lockquando la condivisione è inevitabile.
In sintesi
- La concorrenza alterna più attività; il parallelismo le esegue nello stesso istante su più core.
- Prima di scegliere, capisci se il programma è limitato dall'attesa (I/O) o dal calcolo (CPU).
- In CPython il GIL impedisce ai thread di accelerare il calcolo puro, ma non l'I/O.
- Thread e asyncio per l'attesa, processi per il calcolo intensivo.
- Evita di condividere dati modificabili tra thread: è la fonte delle race condition.