Corso Python / Thread, processi e asyncio: scelta, errori e cancellazione

Thread, processi e asyncio permettono di far progredire più attività con modelli diversi. Scegli in base al tempo dedicato ai calcoli, alle attese e al supporto delle librerie; poi misura il carico reale, senza presumere che aggiungere concorrenza renda automaticamente più veloce un programma.

Distinguere concorrenza e parallelismo

La concorrenza organizza il progresso di più attività; il parallelismo ne esegue parti nello stesso momento. Un programma può gestire molte attese in modo concorrente senza eseguire contemporaneamente diversi calcoli Python.

Metodo Modello Costi e condizioni
Thread Memoria condivisa nello stesso processo Sincronizzazione dello stato e comportamento delle librerie
Processi Spazi di memoria separati Avvio, serializzazione e trasferimento dei dati
asyncio Cooperazione nei punti di attesa Librerie asincrone e codice che non blocchi l’event loop

Nel normale CPython con GIL, il Global Interpreter Lock limita l’esecuzione simultanea del bytecode Python da parte dei thread. Per lavoro CPU in puro Python valuta i processi; per I/O bloccante i thread; per librerie con I/O asincrono asyncio. Estensioni che rilasciano il GIL e build free-threaded richiedono valutazioni diverse. La documentazione di threading sul GIL descrive queste condizioni.

Confrontare le interfacce con un programma completo

Salva il file UTF-8 seguente come concurrency.py in una cartella di esercitazione e avvialo con python concurrency.py nell’ambiente selezionato. Usa un file .py: notebook e sessioni interattive possono avere condizioni diverse per l’avvio dei processi. Servono soltanto moduli standard:

import asyncio
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def square(value):
    return value * value

async def async_square(value):
    await asyncio.sleep(0.01)
    return square(value)

async def gather_values():
    return await asyncio.gather(*(async_square(x) for x in [1, 2, 3]))

def main():
    with ThreadPoolExecutor(max_workers=2) as pool:
        print(list(pool.map(square, [1, 2, 3])))
    with ProcessPoolExecutor(max_workers=2) as pool:
        print(list(pool.map(square, [1, 2, 3])))
    print(asyncio.run(gather_values()))

if __name__ == "__main__":
    main()

L’uscita attesa è:

[1, 4, 9]
[1, 4, 9]
[1, 4, 9]

La prima lista arriva dal pool di thread, la seconda dal pool di processi e la terza dalle coroutine. Tutte calcolano gli stessi quadrati. map restituisce i risultati nell’ordine degli ingressi; ciò non impone lo stesso ordine di conclusione delle attività. Anche gather raccoglie qui i valori secondo l’ordine degli awaitable forniti.

Il blocco if __name__ == "__main__" impedisce di riavviare il programma quando un processo figlio importa il modulo. La funzione square, definita al livello del modulo, è accessibile ai worker. Non spostare indiscriminatamente l’avvio del pool fuori da questo ingresso. La documentazione di concurrent.futures specifica requisiti, risultati e chiusura degli executor.

Gestire attesa cooperativa, cancellazione ed errori

asyncio.sleep cede il controllo durante l’attesa; non è una richiesta HTTP. Una funzione async che esegue un lungo calcolo o una chiamata di rete sincrona senza cedere può bloccare l’event loop. La lezione sulle richieste HTTP e sui loro errori permette di individuare il confine delle attese prima di scegliere come renderle concorrenti.

Per osservare la cancellazione, salva questo programma completo come cancel.py ed esegui python cancel.py:

import asyncio
async def worker():
    try:
        await asyncio.sleep(60)
    finally:
        print("worker cleanup")
async def main():
    task = asyncio.create_task(worker())
    await asyncio.sleep(0)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("cancel confirmed")
if __name__ == "__main__":
    asyncio.run(main())

Il risultato previsto è:

worker cleanup
cancel confirmed

Il primo await asyncio.sleep(0) permette al worker di iniziare ed entrare nel proprio try. cancel() richiede la cancellazione; attendendo il task, il chiamante osserva CancelledError dopo la pulizia. Non occorre aspettare tutti i 60 secondi. Eliminare il primo punto di cessione può cancellare il task prima che inizi, quindi non dimostrerebbe più la stessa pulizia. La documentazione della cancellazione dei task ne descrive la propagazione.

Un’attività inviata a un executor può fallire anche se l’invio è riuscito. Salva l’esempio completo come future_failure.py ed esegui python future_failure.py:

from concurrent.futures import ThreadPoolExecutor
def calculate(value):
    if value < 0:
        raise ValueError("negative input")
    return value * value
with ThreadPoolExecutor(max_workers=2) as pool:
    futures = [pool.submit(calculate, value) for value in [2, -1]]
    for future in futures:
        try:
            print(future.result(timeout=5))
        except ValueError as error:
            print("failed:", error)

Il risultato previsto è:

4
failed: negative input

Future.result restituisce il valore o rilancia l’eccezione del lavoro. Il parametro timeout limita l’attesa del chiamante, non interrompe forzatamente una funzione già in esecuzione. Anche uscendo dal contesto dell’executor si attende normalmente la fine dei lavori: un timeout non è una politica completa di arresto.

Misurare il carico reale e limitare il lavoro

Tre piccoli quadrati mostrano le API, non dimostrano quale metodo sia più veloce. Il costo di avviare i processi può superare quello dei calcoli. Per un confronto utile usa dati rappresentativi e registra interprete e build, numero di worker, quantità di attività, memoria e metodo di misura.

max_workers=2 limita i worker contemporanei, non l’intera coda di lavori inviati. Suddividi grandi ingressi in lotti limitati; per I/O asincrono puoi aggiungere un limite esplicito con un semaforo. Sincronizza le modifiche allo stato condiviso e progetta propagazione degli errori, cancellazione e chiusura. Non creare un numero illimitato di task per recuperare dati più in fretta.

asyncio.run non può essere richiamato dentro un event loop già attivo nello stesso thread: in un ambiente asincrono esistente attendi la coroutine con await secondo il modello dell’ambiente. Il progetto di raccolta API e SQLite separa rete e archiviazione; conserva quelle regole di completezza e transazione prima di aggiungere richieste concorrenti.