{ } Programmazione

Concorrenza in Python: thread, processi e asyncio a confronto

Thread, processi e asyncio spiegati: differenza tra concorrenza e parallelismo, lavori I/O-bound e CPU-bound, il GIL e come scegliere l'approccio giusto.

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:

  1. evitare di condividere dati modificabili, facendo restituire a ogni attività il proprio risultato, come nei pool visti sopra;
  2. usare strutture pensate per la concorrenza, come queue.Queue;
  3. proteggere le sezioni critiche con un threading.Lock quando 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.
MM
Marco Marzaduri

Marco Marzaduri si occupa di cybersecurity, informatica forense e sviluppo web. Laureato in informatica e in psicologia, unisce l'analisi tecnica alla comprensione del fattore umano.

Leggi anche