Parliamo

project-cherry.dev

Avanzato17 min

Async/Await

Interroga decine di API in parallelo invece che in sequenza: da 100 secondi a 3 con la programmazione asincrona di Python.

Async/Await

Lo studio tecnico dell'Ing. Paolo Ferretti ha un incarico: raccogliere i dati urbanistici da 50 comuni diversi per una valutazione ambientale. Ogni comune espone un'API REST, e ogni richiesta impiega circa 2 secondi. In modo sequenziale, il programma impiegherebbe 100 secondi. Con la programmazione asincrona, tutte le richieste partono in parallelo e il risultato arriva in 2-3 secondi.

Il problema: richieste sequenziali lente

Ecco cosa succede senza async. Il programma resta bloccato ad aspettare ogni risposta prima di inviare la richiesta successiva.

python
import time

def richiedi_dati_comune(comune):
    """Simula una richiesta API che impiega 2 secondi."""
    print(f"Richiedo dati da {comune}...")
    time.sleep(2)  # simula attesa di rete
    return {"comune": comune, "abitanti": 15000, "zona": "B2"}

# 50 comuni in sequenziale: 50 x 2s = 100 secondi!
comuni = [f"Comune_{i}" for i in range(50)]
inizio = time.time()

risultati = []
for comune in comuni:
    dati = richiedi_dati_comune(comune)
    risultati.append(dati)

durata = time.time() - inizio
print(f"Completato in {durata:.1f}s")  # Completato in 100.0s

Il problema è chiaro: mentre aspettiamo la risposta di un comune, il programma non fa assolutamente nulla. È tempo sprecato.

La soluzione async

Con asyncio, le richieste partono tutte insieme. Mentre aspettiamo la risposta del Comune 1, il programma invia la richiesta al Comune 2, 3, 4 e così via.

python
import asyncio

async def richiedi_dati_comune(comune):
    """Versione asincrona: non blocca durante l'attesa."""
    print(f"Richiedo dati da {comune}...")
    await asyncio.sleep(2)  # simula attesa I/O senza bloccare
    return {"comune": comune, "abitanti": 15000, "zona": "B2"}

async def main():
    comuni = [f"Comune_{i}" for i in range(50)]
    # Tutte le richieste partono in parallelo
    risultati = await asyncio.gather(
        *[richiedi_dati_comune(c) for c in comuni]
    )
    print(f"Ricevuti dati da {len(risultati)} comuni")

import time
inizio = time.time()
asyncio.run(main())
durata = time.time() - inizio
print(f"Completato in {durata:.1f}s")  # Completato in 2.0s

Da 100 secondi a 2. La differenza è abissale.

Passo per passo: async def, await, asyncio.run()

Tre concetti fondamentali:

  • `async def` dichiara una funzione asincrona (detta coroutine)
  • `await` mette in pausa la coroutine mentre aspetta il risultato, liberando il loop per fare altro
  • `asyncio.run()` avvia il loop degli eventi e esegue la coroutine principale
python
# 1. Definisci la coroutine
async def scarica_documento(codice):
    print(f"Scaricamento documento {codice}...")
    await asyncio.sleep(1)  # durante questa attesa, il loop può fare altro
    return f"Contenuto del documento {codice}"

# 2. Crea la funzione principale asincrona
async def main():
    documento = await scarica_documento("URB-2024-001")
    print(documento)

# 3. Avvia il tutto
asyncio.run(main())

Batch di richieste con asyncio.gather

asyncio.gather() è il cuore della concorrenza. Accetta un numero qualsiasi di coroutine e le esegue contemporaneamente, restituendo i risultati nello stesso ordine.

python
async def analisi_multi_comune():
    """Raccoglie dati urbanistici da più comuni in parallelo."""
    comuni_target = ["Milano", "Roma", "Napoli", "Torino", "Bologna"]

    # Crea le coroutine
    tasks = [richiedi_dati_comune(comune) for comune in comuni_target]

    # Esegui tutte in parallelo
    risultati = await asyncio.gather(*tasks)

    # Elabora i risultati
    for dati in risultati:
        print(f"{dati['comune']}: zona {dati['zona']}, {dati['abitanti']} abitanti")

asyncio.run(analisi_multi_comune())

Richieste HTTP reali con aiohttp

Per fare richieste HTTP reali in modo asincrono, serve la libreria aiohttp (installabile con pip install aiohttp). La libreria standard requests è sincrona e blocca il loop.

python
import aiohttp
import asyncio

async def scarica_dati_comune(session, url_comune):
    """Scarica i dati urbanistici da un singolo comune."""
    async with session.get(url_comune) as risposta:
        dati = await risposta.json()
        return dati

async def raccogli_dati_urbanistici():
    """Interroga 50 API di comuni in parallelo."""
    base_url = "https://api.comuni.example.it/urbanistica"
    urls = [f"{base_url}?comune={i}" for i in range(50)]

    async with aiohttp.ClientSession() as session:
        tasks = [scarica_dati_comune(session, url) for url in urls]
        risultati = await asyncio.gather(*tasks)

    print(f"Raccolti dati da {len(risultati)} comuni")
    return risultati

asyncio.run(raccogli_dati_urbanistici())

La ClientSession gestisce il pool di connessioni e va riutilizzata per tutte le richieste della stessa sessione.

Semafori per rate limiting

Lanciare 50 richieste simultanee potrebbe sovraccaricare il server del comune e farci bloccare l'IP. Un semaforo limita quante richieste possono essere attive contemporaneamente.

python
async def scarica_con_limite(session, url, semaforo):
    """Scarica rispettando il limite di connessioni simultanee."""
    async with semaforo:
        # Massimo N richieste alla volta
        async with session.get(url) as risposta:
            return await risposta.json()

async def raccogli_con_rate_limit():
    """Interroga 50 comuni, massimo 10 richieste alla volta."""
    semaforo = asyncio.Semaphore(10)  # max 10 contemporanee
    base_url = "https://api.comuni.example.it/urbanistica"
    urls = [f"{base_url}?comune={i}" for i in range(50)]

    async with aiohttp.ClientSession() as session:
        tasks = [scarica_con_limite(session, url, semaforo) for url in urls]
        risultati = await asyncio.gather(*tasks)

    print(f"Completate {len(risultati)} richieste con rate limiting")
    return risultati

asyncio.run(raccogli_con_rate_limit())

Il semaforo funziona come un contatore: al massimo 10 coroutine possono entrare nel blocco async with semaforo contemporaneamente. Le altre aspettano il loro turno.

Da ricordare

  • Usa async/await quando il programma aspetta risposte di rete, file o database (operazioni I/O-bound)
  • asyncio.gather() esegue più coroutine in parallelo e restituisce i risultati ordinati
  • Per le richieste HTTP asincrone serve aiohttp, non requests
  • Usa asyncio.Semaphore per limitare le richieste contemporanee e non sovraccaricare i server
  • La programmazione asincrona non velocizza i calcoli CPU-intensive — per quelli serve multiprocessing
  • Ogni funzione async deve essere chiamata con await o passata a asyncio.gather()