Vai al contenuto principale

Programmazione asincrona in Python: la guida completa

Velocizza il tuo codice con la programmazione asincrona in Python. Una guida passo passo ad asyncio, concorrenza, richieste HTTP efficienti e integrazione con database.
Aggiornato 3 giu 2026  · 14 min leggi

Mentre il tuo script Python aspetta pazientemente le risposte delle API, le query al database o il completamento delle operazioni sui file, spesso quel tempo va sprecato. Con la programmazione asincrona in Python, il tuo codice può gestire più attività contemporaneamente. Così, mentre un'operazione è in attesa, le altre vanno avanti, trasformando i momenti morti in lavoro produttivo e riducendo spesso minuti di attesa a pochi secondi.

In questa guida ti insegnerò le basi della programmazione asincrona in Python lavorando su mini-progetti. Vedrai come coroutine, event loop e I/O asincrono possano rendere il tuo codice molto più reattivo.

Se vuoi imparare a creare web API asincrone, dai un'occhiata a questo corso su FastAPI.

Che cos'è la programmazione asincrona in Python?

Nel Python sincrono tradizionale, il codice viene eseguito una riga alla volta. Per esempio, quando chiami un'API, il programma si ferma e aspetta la risposta. Se ci vogliono due secondi, l'intero programma resta inattivo per due secondi. La programmazione asincrona consente al tuo codice di avviare una chiamata API e poi proseguire con altre attività.

Quando arriva la risposta, il codice riprende da dove si era fermato. Invece di aspettare che ogni operazione termini, puoi eseguirne più di una contemporaneamente. Questo è particolarmente importante quando il tuo codice passa tempo ad attendere sistemi esterni come database, API o file system. 

Per far funzionare tutto, il sistema async di Python usa alcuni concetti chiave:

  • Coroutine: funzioni definite con async def invece di def. Possono sospendere e riprendere l'esecuzione, rendendole perfette per operazioni che comportano attese.

  • await: questa parola chiave dice a Python: "metti in pausa questa coroutine finché questa operazione non termina, ma lascia che nel frattempo giri altro codice".

  • Event loop: il motore che gestisce tutte le coroutine, decidendo quale eseguire e quando passare da una all'altra.

  • Task: coroutine incapsulate per l'esecuzione concorrente. Le crei con asyncio.create_task() per eseguire più operazioni in parallelo.

Per evitare confusione su cosa la programmazione async può (e non può) fare, tieni a mente questo:

  • L'async funziona meglio con il lavoro I/O-bound come richieste HTTP, query a database e operazioni su file, dove il codice aspetta sistemi esterni.

  • L'async non aiuta con il lavoro CPU-bound come calcoli complessi o elaborazione dati, dove il codice calcola attivamente invece di aspettare.

Il modo migliore per interiorizzare questi concetti è scrivere vero codice async. Nella prossima sezione, creerai la tua prima funzione async e vedrai esattamente come coroutine ed event loop lavorano insieme.

La tua prima funzione async in Python

Prima di scrivere codice async, guardiamo una normale funzione sincrona che aspetta prima di fare qualcosa:

import time

def greet_after_delay():
    print("Starting...")
    time.sleep(2)  # Blocks for 2 seconds
    print("Hello!")

greet_after_delay()
Starting...
Hello!

La funzione funziona, ma time.sleep(2) blocca l'intero programma. In quei due secondi non può girare nient'altro.

Ecco la versione async:

import asyncio

async def greet_after_delay():
    print("Starting...")
    await asyncio.sleep(2)  # Pauses, but doesn't block
    print("Hello!")

asyncio.run(greet_after_delay())
Starting...
Hello!

L'output sembra identico, ma sotto il cofano succede qualcosa di diverso. Tre cambiamenti rendono questo codice async:

  1. async def invece di def dichiara una coroutine.

  2. await asyncio.sleep(2) invece di time.sleep(2) sospende senza bloccare.

  3. asyncio.run() avvia l'event loop ed esegue la coroutine.

Nota che asyncio.sleep() è a sua volta una funzione async, per questo richiede await. Questa è una regola chiave: ogni funzione async deve essere chiamata con await. Che sia una builtin come asyncio.sleep() o una scritta da te, dimenticare await significa che non verrà effettivamente eseguita.

Al momento, la versione async non sembra più veloce. Questo perché abbiamo solo un task. Il vero vantaggio emerge quando esegui più coroutine insieme, come vedremo nella prossima sezione.

Un'altra cosa importante da sapere: non puoi chiamare una funzione async direttamente come una funzione normale. Proviamo:

result = greet_after_delay()
print(result)
print(type(result))
<coroutine object greet_after_delay at 0x...>
<class 'coroutine'>

Chiamando greet_after_delay() ottieni un oggetto coroutine, non il risultato. La funzione non viene effettivamente eseguita. Devi usare asyncio.run() oppure await per eseguirla dentro un'altra funzione.

Come funziona l'event loop

L'event loop è il motore dietro la programmazione async. Gestisce le coroutine e decide cosa eseguire e quando. Ecco cosa succede passo dopo passo quando esegui la funzione async greet_after_delay():

  1. asyncio.run() crea un event loop.

  2. L'event loop avvia greet_after_delay().

  3. Viene stampato "Starting...".

  4. Si incontra await asyncio.sleep(2) → la coroutine si mette in pausa.

  5. L'event loop controlla: "Ci sono altri task da eseguire?" (al momento nessuno).

  6. Passano 2 secondi, lo sleep termina.

  7. L'event loop riprende greet_after_delay().

  8. Viene stampato "Hello!".

  9. La funzione finisce → l'event loop esce.

Motore dell'event loop nella programmazione async in Python spiegato

Il passo 5 è dove l'async diventa interessante. Con una sola coroutine, non c'è altro da fare. Ma quando hai più coroutine, l'event loop passa ad altro lavoro mentre una aspetta. Invece di restare inattivo durante due secondi di sleep, può eseguire altro codice.

Pensa all'event loop come a un vigile del traffico. Non rende più veloci le singole auto. Mantiene il traffico scorrevole lasciando passare altre auto mentre una è ferma.

Errore async comune: dimenticare await

Un errore frequente per chi inizia è dimenticare await quando si chiama una coroutine dentro un'altra funzione async:

import asyncio

async def get_message():
    await asyncio.sleep(1)
    return "Hello!"

async def main():
    message = get_message()  # Missing await!
    print(message)

asyncio.run(main())
<coroutine object get_message at 0x...>
RuntimeWarning: coroutine 'get_message' was never awaited

Senza await, ottieni l'oggetto coroutine invece del valore di ritorno. Python ti avvisa anche che la coroutine non è mai stata eseguita.

La correzione è semplice:

async def main():
    message = await get_message()  # Added await
    print(message)

asyncio.run(main())
Hello!

Quando vedi un RuntimeWarning su una coroutine non in attesa, controlla di aver usato await su ogni chiamata a funzione async.

Task async concorrenti in Python 

Nella sezione precedente abbiamo convertito una funzione sincrona in async. Ma non era più veloce. Questo perché abbiamo eseguito una sola coroutine. La vera potenza dell'async emerge quando esegui più coroutine contemporaneamente.

Perché await in sequenza resta comunque sequenziale

Potresti pensare che chiamare più funzioni async le faccia girare automaticamente in parallelo. Ma guarda cosa succede quando chiamiamo greet_after_delay() tre volte:

import asyncio
import time

async def greet_after_delay(name):
    print(f"Starting {name}...")
    await asyncio.sleep(2)
    print(f"Hello, {name}!")

async def main():
    start = time.perf_counter()
    
    await greet_after_delay("Alice")
    await greet_after_delay("Bob")
    await greet_after_delay("Charlie")
    
    elapsed = time.perf_counter() - start
    print(f"Total time: {elapsed:.2f} seconds")

asyncio.run(main())
Starting Alice...
Hello, Alice!
Starting Bob...
Hello, Bob!
Starting Charlie...
Hello, Charlie!
Total time: 6.01 seconds

Sei secondi per tre task da due secondi. Ogni await aspetta che la sua coroutine finisca prima di passare alla riga successiva. Il codice è async, ma sta girando in sequenza.

Esecuzione concorrente di task async con asyncio.gather() 

Per eseguire coroutine contemporaneamente, usa asyncio.gather(). Accetta più coroutine e le esegue in concorrenza:

async def main():
    start = time.perf_counter()
    
    await asyncio.gather(
        greet_after_delay("Alice"),
        greet_after_delay("Bob"),
        greet_after_delay("Charlie"),
    )
    
    elapsed = time.perf_counter() - start
    print(f"Total time: {elapsed:.2f} seconds")

asyncio.run(main())
Starting Alice...
Starting Bob...
Starting Charlie...
Hello, Alice!
Hello, Bob!
Hello, Charlie!
Total time: 2.00 seconds

Due secondi invece di sei. Tutte e tre le coroutine sono partite subito, hanno dormito in parallelo e sono terminate insieme. È un'accelerazione di 3 volte con un solo cambiamento.

Nota l'ordine dell'output: tutti e tre i messaggi "Starting..." compaiono prima di qualsiasi "Hello...". Questo mostra che tutte le coroutine stanno girando nella stessa finestra di due secondi anziché aspettarsi a vicenda.

asyncio.gather() restituisce una lista di risultati nello stesso ordine in cui hai passato le coroutine. Se le tue coroutine restituiscono valori, puoi catturarli:

async def fetch_number(n):
    await asyncio.sleep(1)
    return n * 10

async def main():
    results = await asyncio.gather(
        fetch_number(1),
        fetch_number(2),
        fetch_number(3),
    )
    print(results)

asyncio.run(main())
[10, 20, 30]

I risultati tornano in ordine [10, 20, 30], corrispondente all'ordine delle coroutine passate a gather().

Richieste HTTP async in Python con aiohttp

Finora abbiamo usato asyncio.sleep() per simulare ritardi. Ora facciamo vere richieste HTTP. Potresti pensare alla libreria requests, ma qui non funziona. requests è sincrona e blocca l'event loop, vanificando lo scopo dell'async.

Usa invece aiohttp, un client HTTP asincrono costruito proprio per questo.

Introduzione ad aiohttp

Ecco come recuperare un URL con aiohttp:

import aiohttp
import asyncio

async def fetch(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.text()

async def main():
    html = await fetch("https://example.com")
    print(f"Fetched {len(html)} characters")

asyncio.run(main())
Fetched 513 characters

Nota i due blocchi annidati async with. Ognuno gestisce una risorsa diversa, e capirne il ruolo è fondamentale per usare correttamente aiohttp.

Come funziona ClientSession in aiohttp

Ecco cosa succede passo dopo passo quando fai richieste con aiohttp:

  1. aiohttp.ClientSession() crea un connection pool (inizialmente vuoto).

  2. session.get(url) controlla il pool: "C'è una connessione aperta a questo host (il server del sito)?"

  3. Se non esiste una connessione, vengono creati una nuova connessione TCP (il protocollo di base per inviare dati su Internet) e un handshake SSL (la configurazione della cifratura per HTTPS).

  4. Viene inviata una richiesta HTTP e si attende per gli header della risposta.

  5. L'oggetto risposta trattiene la connessione.

  6. await response.text() legge il body dai dati di rete.

  7. Uscita dal blocco async with interno: la connessione torna al pool (resta aperta!).

  8. La richiesta successiva allo stesso host viene effettuata riutilizzando la connessione del pool (salta il punto 3).

  9. Uscita dal blocco async with esterno: tutte le connessioni nel pool vengono chiuse.

I passaggi 7 e 8 sono l'intuizione chiave. Il connection pool mantiene vive le connessioni tra una richiesta e l'altra. Quando fai un'altra richiesta allo stesso host, salti completamente gli handshake TCP e SSL.

Workflow del connection pooling in aiohttp: usare una sessione condivisa per più richieste

Questo conta perché stabilire una nuova connessione è lento. Un handshake TCP richiede un round-trip al server. Un handshake SSL ne richiede altri due. A seconda della latenza, sono 100-300 ms prima ancora di inviare il primo byte di dati.

Usare una sessione condivisa per tutte le richieste

Ora capisci perché creare una nuova sessione per ogni richiesta è un problema:

# Wrong: new session for each request
async def fetch_bad(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.text()

async def main():
    urls = ["https://example.com"] * 10
    results = await asyncio.gather(*[fetch_bad(url) for url in urls])

Ogni chiamata a fetch_bad() crea una nuova sessione con un pool vuoto. Ogni richiesta paga il costo completo degli handshake, anche se vanno tutte allo stesso host.

La soluzione è creare una sola sessione e passarla alla tua funzione di fetch:

# Right: reuse a single session
async def fetch_good(session, url):
    async with session.get(url) as response:
        return await response.text()

async def main():
    urls = ["https://example.com"] * 10
    async with aiohttp.ClientSession() as session:
        results = await asyncio.gather(*[fetch_good(session, url) for url in urls])

Con una sessione condivisa, la prima richiesta stabilisce la connessione e le restanti nove la riutilizzano. Un solo handshake invece di dieci.

Esempio di richiesta HTTP async: scraping di Hacker News

Mettiamo in pratica con la Hacker News API. Questa API è perfetta per dimostrare il comportamento asincrono perché recuperare le storie richiede più richieste. Se sei alle prime armi con le REST API in Python, consulta Python APIs: A Guide to Building and Using APIs per i concetti di base.

La struttura della Hacker News API:

  • https://hacker-news.firebaseio.com/v0/topstories.json restituisce una lista di ID di storie (solo numeri)

  • https://hacker-news.firebaseio.com/v0/item/{id}.json restituisce i dettagli di una singola storia

Per ottenere 10 storie, servono 11 richieste: una per la lista di ID, poi una per ogni storia. È esattamente qui che l'asincronia brilla.

Per prima cosa, vediamo cosa restituisce l'API se proviamo a recuperare la prima storia:

import aiohttp
import asyncio

HN_API = "https://hacker-news.firebaseio.com/v0"

async def main():
    async with aiohttp.ClientSession() as session:
        # Get top story IDs
        async with session.get(f"{HN_API}/topstories.json") as response:
            story_ids = await response.json()
        
        print(f"Found {len(story_ids)} stories")
        print(f"First 5 IDs: {story_ids[:5]}")
        
        # Fetch first story details
        first_id = story_ids[0]
        async with session.get(f"{HN_API}/item/{first_id}.json") as response:
            story = await response.json()
        
        print(f"\nStory structure:")
        for key, value in story.items():
            print(f"  {key}: {repr(value)[:50]}")

asyncio.run(main())
Found 500 stories
First 5 IDs: [46051449, 46055298, 46021577, 46053566, 45984864]

Story structure:
  by: 'mikeayles'
  descendants: 22
  id: 46051449
  kids: [46054027, 46053889, 46053275, 46053515, 46053002,
  score: 217
  text: 'I got DOOM running in KiCad by rendering it with 
  time: 1764108815
  title: 'Show HN: KiDoom – Running DOOM on PCB Traces'
  type: 'story'
  url: 'https://www.mikeayles.com/#kidoom'

L'API restituisce 500 ID di storie e ogni storia ha campi come title, url, score e by (l'autore). 

Recuperare più risultati in sequenza vs. in concorrenza

Ora recuperiamo 10 storie in sequenza:

import aiohttp
import asyncio
import time

HN_API = "https://hacker-news.firebaseio.com/v0"

async def fetch_story(session, story_id):
    async with session.get(f"{HN_API}/item/{story_id}.json") as response:
        return await response.json()

async def main():
    async with aiohttp.ClientSession() as session:
        async with session.get(f"{HN_API}/topstories.json") as response:
            story_ids = await response.json()
        
        start = time.perf_counter()
        stories = []
        for story_id in story_ids[:10]:
            story = await fetch_story(session, story_id)
            stories.append(story)
        elapsed = time.perf_counter() - start
        
        print(f"Sequential: Fetched {len(stories)} stories in {elapsed:.2f} seconds")

asyncio.run(main())
Sequential: Fetched 10 stories in 2.41 seconds

Ora recuperiamo le stesse storie in concorrenza:

async def main():
    async with aiohttp.ClientSession() as session:
        async with session.get(f"{HN_API}/topstories.json") as response:
            story_ids = await response.json()
        
        start = time.perf_counter()
        tasks = [fetch_story(session, story_id) for story_id in story_ids[:10]]
        stories = await asyncio.gather(*tasks)
        elapsed = time.perf_counter() - start
        
        print(f"Concurrent: Fetched {len(stories)} stories in {elapsed:.2f} seconds")
        print("\nTop 3 stories:")
        for story in stories[:3]:
            print(f"  - {story.get('title', 'No title')}")

asyncio.run(main())
Concurrent: Fetched 10 stories in 0.69 seconds

Top 3 stories:
  - Show HN: KiDoom – Running DOOM on PCB Traces
  - AWS is 10x slower than a dedicated server for the same price [video]
  - Surprisingly, Emacs on Android is pretty good

La versione concorrente è 3,5 volte più veloce. Invece di aspettare che ogni richiesta termini prima di avviare la successiva, tutte e 10 le richieste vengono eseguite contemporaneamente. È qui che la programmazione async ripaga davvero con l'I/O di rete reale.

Gestione errori e rate limiting in Python async

Quando recuperi dati in concorrenza, diverse cose possono andare storte. Potresti sovraccaricare il server con troppe richieste. Alcune richieste potrebbero bloccarsi per sempre. Altre potrebbero fallire subito. E quando ci sono errori, serve una strategia di recupero.

Questa sezione affronta ogni tema nell'ordine in cui si presenta: controllare quante richieste partono, impostare limiti di tempo, gestire i fallimenti e ritentare quando ha senso. Se ti serve un ripasso sui fondamenti della gestione eccezioni in Python, vedi Exception & Error Handling in Python. Useremo questa base per tutti gli esempi:

import aiohttp
import asyncio
import time

HN_API = "https://hacker-news.firebaseio.com/v0"

async def fetch_story(session, story_id):
    async with session.get(f"{HN_API}/item/{story_id}.json") as response:
        return await response.json()

Rate limiting con semafori

Nella sezione precedente, abbiamo lanciato 10 richieste in una volta. Ha funzionato. Ma cosa succede quando devi recuperare 500 storie? O fare scraping di 10.000 pagine?

La maggior parte delle API applica limiti di velocità. Potrebbero consentire 10 richieste al secondo o 100 connessioni concorrenti. Se superi questi limiti, verrai bloccato, rallentato o bannato. Anche se l'API non impone limiti, lanciare migliaia di richieste simultaneamente può sovraccaricare il tuo sistema o il server.

Ti serve un modo per controllare quante richieste sono "in volo" in un dato momento. Questo è proprio ciò che fa un semaforo.

Un semaforo funziona come un sistema di permessi. Immagina di avere tre permessi. Qualsiasi task che vuole fare una richiesta deve prima ottenere un permesso. Quando finisce, lo restituisce, così una nuova richiesta può usarlo. Se non ci sono permessi disponibili, il task aspetta finché uno non si libera.

Semaforo async per gestire la concorrenza con permessi.

Ecco come va con 3 permessi e 4 o più task:

  1. Ci sono tre permessi disponibili.

  2. Il task A prende un permesso (ne restano 2) e avvia la richiesta.

  3. Il task B prende un permesso (ne resta 1) e avvia la richiesta.

  4. Il task C prende un permesso (ne restano 0) e avvia la richiesta.

  5. Il task D vuole un permesso, ma non ce ne sono disponibili—aspetta.

  6. Il task A termina e restituisce il suo permesso (1 disponibile).

  7. Il task D prende quel permesso e avvia la richiesta.

  8. Si continua così finché tutti i task non sono completati.

L'attesa al punto 5 è efficiente. Il task non gira in un loop a controllare "si è liberato un permesso?". Si sospende e lascia girare altro codice. L'event loop lo risveglia solo quando un permesso diventa disponibile.

Passiamo al codice. In asyncio, crei un semaforo con asyncio.Semaphore(n), dove n è il numero di permessi. Per usarlo, incapsula il codice in async with semaphore:. Questo acquisisce un permesso entrando nel blocco e lo rilascia automaticamente in uscita:

async def fetch_story_limited(session, story_id, semaphore):
    async with semaphore:  # Acquire permit (or wait if none available)
        async with session.get(f"{HN_API}/item/{story_id}.json") as response:
            return await response.json()
    # Permit automatically released here

Confrontiamo il recupero di 30 storie con e senza semaforo:

async def main():
    async with aiohttp.ClientSession() as session:
        async with session.get(f"{HN_API}/topstories.json") as response:
            story_ids = (await response.json())[:30]

        # Without rate limiting: all 30 at once
        start = time.perf_counter()
        await asyncio.gather(*[fetch_story(session, sid) for sid in story_ids])
        print(f"No limit: {time.perf_counter() - start:.2f}s (30 concurrent)")

        # With Semaphore(5): max 5 at a time
        semaphore = asyncio.Semaphore(5)
        start = time.perf_counter()
        await asyncio.gather(*[fetch_story_limited(session, sid, semaphore) for sid in story_ids])
        print(f"Semaphore(5): {time.perf_counter() - start:.2f}s (5 concurrent)")

asyncio.run(main())
No limit: 0.62s (30 concurrent)
Semaphore(5): 1.50s (5 concurrent)

La versione con semaforo è più lenta perché elabora le richieste in gruppi da cinque. Ma questo è il compromesso: sacrificare velocità per un comportamento prevedibile e rispettoso del server.

Nota: un semaforo limita le richieste concorrenti, non le richieste per unità di tempo. Semaphore(10) significa "al massimo 10 richieste in volo contemporaneamente", non "10 richieste al secondo". Se ti serve un rate limiting rigoroso basato sul tempo (ad esempio esattamente 10 richieste al secondo), puoi combinare un semaforo con ritardi tra i batch o usare una libreria come aiolimiter.

Timeout con asyncio.wait_for()

Anche con concorrenza controllata, singole richieste possono bloccarsi. Un server potrebbe accettare la connessione ma non rispondere mai. Senza un timeout, il tuo programma aspetta all'infinito.

La funzione asyncio.wait_for() incapsula qualsiasi coroutine con una scadenza. Le passi la coroutine e un timeout in secondi. Se l'operazione non termina in tempo, solleva asyncio.TimeoutError:

async def slow_operation():
    print("Starting slow operation...")
    await asyncio.sleep(5)
    return "Done"

async def main():
    try:
        result = await asyncio.wait_for(slow_operation(), timeout=2.0)
        print(f"Success: {result}")
    except asyncio.TimeoutError:
        print("Operation timed out after 2 seconds")

asyncio.run(main())
Starting slow operation...
Operation timed out after 2 seconds

Quando il timeout scade, wait_for() annulla la coroutine. Puoi intercettare TimeoutError e decidere cosa fare: saltare la richiesta, restituire un valore di default o ritentare.

Per richieste concorrenti, incapsula ciascuna singolarmente. Ecco un helper che restituisce un dizionario di errore invece di sollevare un'eccezione:

async def fetch_story_with_timeout(session, story_id, timeout=5.0):
    try:
        coro = fetch_story(session, story_id)
        return await asyncio.wait_for(coro, timeout=timeout)
    except asyncio.TimeoutError:
        return {"error": f"Story {story_id} timed out"}

Quando una coroutine viene annullata (per timeout o altre ragioni), Python solleva asyncio.CancelledError al suo interno. Se la tua coroutine detiene risorse come file handle o connessioni, usa try/finally per assicurarti che la pulizia avvenga anche in caso di cancellazione:

async def fetch_with_cleanup(session, url):
    print("Starting fetch...")
    try:
        async with session.get(url) as response:
            return await response.text()
    finally:
        print("Cleanup complete")  # Runs even on cancellation

Gestione errori con asyncio.gather()

I timeout intercettano le richieste lente. Ma alcune richieste falliscono subito con un errore. Vediamo cosa succede quando una richiesta in un batch fallisce.

Per prima cosa, ci serve una versione di fetch_story() che sollevi un'eccezione su ID non validi:

async def fetch_story_strict(session, story_id):
    story = await fetch_story(session, story_id)
    if story is None:
        raise ValueError(f"Story not found: {story_id}")
    return story

Ora recuperiamo quattro storie valide più un ID non valido:

async def main():
    async with aiohttp.ClientSession() as session:
        async with session.get(f"{HN_API}/topstories.json") as response:
            story_ids = await response.json()

        ids_to_fetch = story_ids[:4] + [99999999999]  # 4 valid + 1 invalid

        try:
            stories = await asyncio.gather(
                *[fetch_story_strict(session, sid) for sid in ids_to_fetch]
            )
            print(f"Got {len(stories)} stories")
        except ValueError as e:
            print(f"ERROR: {e}")

asyncio.run(main())
ERROR: Story not found: 99999999999

Con un ID non valido, perdiamo tutti e quattro i risultati riusciti. Per impostazione predefinita, gather() usa un comportamento fail-fast: una sola eccezione cancella tutto e viene propagata.

Per mantenere risultati parziali, aggiungi return_exceptions=True. Questo modifica il comportamento di gather(): invece di sollevare eccezioni, le restituisce come elementi nella lista dei risultati insieme ai valori riusciti:

async def main():
    async with aiohttp.ClientSession() as session:
        async with session.get(f"{HN_API}/topstories.json") as response:
            story_ids = await response.json()

        ids_to_fetch = story_ids[:4] + [99999999999]

        results = await asyncio.gather(
            *[fetch_story_strict(session, sid) for sid in ids_to_fetch],
            return_exceptions=True  # Don't raise, return exceptions in list
        )

        # Separate successes from failures using isinstance()
        stories = [r for r in results if not isinstance(r, Exception)]
        errors = [r for r in results if isinstance(r, Exception)]

        print(f"Got {len(stories)} stories, {len(errors)} failed")

asyncio.run(main())
Got 4 stories, 1 failed

Il controllo isinstance(result, Exception) ti consente di separare i risultati riusciti dagli errori. Poi puoi elaborare ciò che ha funzionato e registrare o ritentare i fallimenti.

Logica di retry con backoff esponenziale

Alcuni errori sono temporanei. Un server potrebbe essere momentaneamente sovraccarico, o un intoppo di rete potrebbe interrompere la connessione. In questi casi, ha senso ritentare.

Ma ritentare subito può peggiorare le cose. Se un server è in difficoltà, martellarlo di retry aggrava il problema. Il backoff esponenziale lo risolve aspettando sempre più a lungo tra un tentativo e l'altro.

Lo schema usa 2 ** attempt per calcolare i tempi di attesa: il tentativo 0 aspetta un secondo (2⁰), il tentativo 1 due secondi (2¹), il tentativo 2 quattro secondi (2²) e così via. Questo dà al server sempre più tempo per riprendersi:

async def fetch_with_retry(session, story_id, max_retries=3):
    for attempt in range(max_retries):
        try:
            story = await fetch_story(session, story_id)
            if story is None:
                raise ValueError(f"Story {story_id} not found")
            return story
        except (aiohttp.ClientError, ValueError):  # Catch specific exceptions
            if attempt == max_retries - 1:
                print(f"Story {story_id}: Failed after {max_retries} attempts")
                return None

            backoff = 2 ** attempt  # 1s, 2s, 4s...
            print(f"Story {story_id}: Attempt {attempt + 1} failed, retrying in {backoff}s...")
            await asyncio.sleep(backoff)

Nota che intercettiamo eccezioni specifiche (aiohttp.ClientError, ValueError) invece di un generico except. Così ritentiamo solo su errori che potrebbero essere transitori. Un KeyError dovuto a codice errato non dovrebbe attivare retry.

Testiamo con un mix di ID validi e non validi:

async def main():
    async with aiohttp.ClientSession() as session:
        async with session.get(f"{HN_API}/topstories.json") as response:
            story_ids = await response.json()

        test_ids = [story_ids[0], 99999999999, story_ids[1], 88888888888, story_ids[2]]

        results = await asyncio.gather(*[fetch_with_retry(session, sid) for sid in test_ids])

        successful = [r for r in results if r is not None]
        print(f"\nSuccessful: {len(successful)}, Failed: {len(test_ids) - len(successful)}")

asyncio.run(main())
Story 99999999999: Attempt 1 failed, retrying in 1s...
Story 88888888888: Attempt 1 failed, retrying in 1s...
Story 99999999999: Attempt 2 failed, retrying in 2s...
Story 88888888888: Attempt 2 failed, retrying in 2s...
Story 99999999999: Failed after 3 attempts
Story 88888888888: Failed after 3 attempts

Successful: 3, Failed: 2

In produzione, aggiungeresti anche jitter (piccoli ritardi casuali) per evitare che più richieste fallite ritentino esattamente nello stesso momento. Inoltre, ritenteresti solo errori transitori (problemi di rete lato server, come 503) rinunciando subito su quelli permanenti (ad esempio 404 o 401).

Archiviazione su database async in Python con aiosqlite

Abbiamo recuperato storie da Hacker News con rate limiting, timeout e gestione degli errori adeguati. Ora salviamole in un database.

Usare una libreria di database sincrona come sqlite3 bloccherebbe l'event loop durante le query, vanificando lo scopo della programmazione asincrona. Mentre il tuo codice aspetta il database, nessun'altra coroutine può girare. Per applicazioni async, ti serve una libreria di database async.

aiosqlite incapsula la sqlite3 integrata in Python in un'interfaccia async. Esegue le operazioni sul database in un thread pool così da non bloccare l'event loop. SQLite non richiede setup di server—è solo un file—quindi puoi eseguire subito questo codice. Se sei nuovo ai database in Python, il corso Introduction to Databases in Python copre le basi sincrone su cui aiosqlite costruisce.

Configurazione del database

Lo schema dovrebbe suonarti familiare. Proprio come aiohttp.ClientSession, usi async with per gestire la connessione:

import aiosqlite

async def init_db(db_path):
    async with aiosqlite.connect(db_path) as db:
        await db.execute("""
            CREATE TABLE IF NOT EXISTS stories (
                id INTEGER PRIMARY KEY,
                title TEXT,
                url TEXT,
                score INTEGER,
                fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        """)
        await db.commit()

asyncio.run(init_db("stories.db"))

Le funzioni chiave:

  • aiosqlite.connect(path) apre (o crea) un file di database.

  • await db.execute(sql) esegue un'istruzione SQL.

  • await db.commit() salva le modifiche su disco.

Salvataggio delle storie

Ecco una funzione per salvare una singola storia:

async def save_story(db, story):
    await db.execute(
        "INSERT OR REPLACE INTO stories (id, title, url, score) VALUES (?, ?, ?, ?)",
        (story["id"], story.get("title", ""), story.get("url", ""), story.get("score", 0))
    )

I placeholder ? prevengono l'SQL injection—non usare mai f-string per inserire valori in SQL. INSERT OR REPLACE aggiorna le storie esistenti se le recuperiamo di nuovo.

Pipeline async completa in Python: fetch e store

Ora mettiamo insieme tutto quanto visto nel tutorial in una pipeline completa. Recupereremo 20 storie di Hacker News con rate limiting e le salveremo in un database:

import aiohttp
import aiosqlite
import asyncio

HN_API = "https://hacker-news.firebaseio.com/v0"

async def fetch_story(session, story_id):
    async with session.get(f"{HN_API}/item/{story_id}.json") as response:
        return await response.json()

async def fetch_story_limited(session, story_id, semaphore):
    async with semaphore:
        story = await fetch_story(session, story_id)
        if story:
            return story
        return None

async def save_story(db, story):
    await db.execute(
        "INSERT OR REPLACE INTO stories (id, title, url, score) VALUES (?, ?, ?, ?)",
        (story["id"], story.get("title", ""), story.get("url", ""), story.get("score", 0))
    )

async def main():
    # Initialize database
    async with aiosqlite.connect("hn_stories.db") as db:
        await db.execute("""
            CREATE TABLE IF NOT EXISTS stories (
                id INTEGER PRIMARY KEY,
                title TEXT,
                url TEXT,
                score INTEGER,
                fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        """)
        
        # Fetch stories
        async with aiohttp.ClientSession() as session:
            async with session.get(f"{HN_API}/topstories.json") as response:
                story_ids = await response.json()
            
            semaphore = asyncio.Semaphore(5)
            tasks = [fetch_story_limited(session, sid, semaphore) for sid in story_ids[:20]]
            stories = await asyncio.gather(*tasks)
        
        # Save to database
        for story in stories:
            if story:
                await save_story(db, story)
        await db.commit()
        
        # Query and display
        cursor = await db.execute("SELECT id, title, score FROM stories ORDER BY score DESC LIMIT 5")
        rows = await cursor.fetchall()
        
        print(f"Saved {len([s for s in stories if s])} stories. Top 5 by score:")
        for row in rows:
            print(f"  [{row[2]}] {row[1][:50]}")

asyncio.run(main())
Saved 20 stories. Top 5 by score:
  [671] Google Antigravity exfiltrates data via indirect p
  [453] Trillions spent and big software projects are stil
  [319] Ilya Sutskever: We're moving from the age of scali
  [311] Show HN: We built an open source, zero webhooks pa
  [306] FLUX.2: Frontier Visual Intelligence

La pipeline usa pattern di ogni sezione: ClientSession per il connection pooling, Semaphore(5) per il rate limiting, gather() per il fetch concorrente e ora aiosqlite per lo storage asincrono. Ogni componente gestisce la propria parte senza bloccare gli altri.

Ogni volta che esegui questo workflow, riceverai le top stories del giorno.

Conclusione

Questo tutorial ti ha portato dalla sintassi di base async/await a una pipeline dati completa. Hai imparato come le coroutine si sospendono e riprendono, come l'event loop gestisce i task concorrenti e come asyncio.gather() esegue più operazioni contemporaneamente. Hai aggiunto vere richieste HTTP con aiohttp, controllato la concorrenza con i semafori, gestito i fallimenti con timeout e retry e archiviato i risultati in un database con aiosqlite.

Usa l'async quando il tuo codice aspetta sistemi esterni: API HTTP, database, I/O su file o socket di rete. Per lavori pesanti sulla CPU come elaborazione dati o calcoli numerici, l'async non aiuta—valuta multiprocessing o concurrent.futures invece. Per approfondire, puoi esplorare la documentazione di asyncio e considerare FastAPI per creare web API asincrone. 

Se vuoi costruire su queste conoscenze e imparare a progettare applicazioni intelligenti, dai un'occhiata al career track Associate AI Engineer for Developers.

FAQ su Python Async

Qual è la differenza tra programmazione async e sync in Python?

Nella programmazione sincrona, il codice viene eseguito una riga alla volta e aspetta che ogni operazione termini. La programmazione async consente al tuo codice di avviare un'operazione e passare ad altro lavoro mentre aspetta, per poi riprendere quando il risultato è pronto. Il tutto è gestito da un event loop che passa da un task all'altro.

Quando dovrei usare la programmazione async invece del Python normale?

Usa l'async per task I/O-bound in cui il tuo codice aspetta sistemi esterni: richieste HTTP, query a database, operazioni su file o socket di rete. L'async non aiuta con lavori CPU-bound come elaborazione dati o calcoli—per quelli usa multiprocessing o concurrent.futures.

Perché ricevo l'avviso "coroutine was never awaited"?

Succede quando chiami una funzione async senza usare await. Chiamare una funzione async come get_data() restituisce un oggetto coroutine, non il risultato. Devi usare await get_data() per eseguirla davvero e ottenere il valore di ritorno.

Posso usare la libreria requests con asyncio?

No, la libreria requests è sincrona e blocca l'event loop, vanificando lo scopo dell'async. Usa invece aiohttp—è un client HTTP async progettato per richieste concorrenti. Ricorda di riutilizzare una singola ClientSession per il connection pooling.

Come limito le richieste concorrenti per non sovraccaricare un'API?

Usa asyncio.Semaphore per controllare quante richieste vengono eseguite simultaneamente. Crea un semaforo con il limite desiderato (ad esempio asyncio.Semaphore(5)) e incapsula ogni richiesta in async with semaphore. In questo modo solo quel numero di richieste sarà "in volo" contemporaneamente.


Bex Tuychiev's photo
Author
Bex Tuychiev
LinkedIn

Sono un creator di contenuti sulla data science con oltre 2 anni di esperienza e uno dei profili con più seguito su Medium. Mi piace scrivere articoli dettagliati su AI e ML con un pizzico di sarcasmo, perché qualcosa bisogna pur fare per renderli un po' meno noiosi. Ho pubblicato più di 130 articoli e anche un corso su DataCamp, con un altro in arrivo. I miei contenuti sono stati visti da oltre 5 milioni di occhi, e 20.000 di loro sono diventati follower sia su Medium che su LinkedIn. 

Argomenti

Corsi Python

Programma

Ingegnere AI associato per sviluppatori

26 h
Scopri come integrare l'intelligenza artificiale nelle applicazioni software utilizzando API e librerie open-source. Inizia oggi il tuo percorso per diventare un ingegnere AI!
Vedi dettagliRight Arrow
Inizia il corso
Mostra altroRight Arrow