Limitare la concorrenza in Python con <strong>asyncio.Semaphore</strong>

by theArchitect
SHARE
Limitare la concorrenza in Python con <strong>asyncio.Semaphore</strong>
© Guida-HTML5.it

Introduzione

La programmazione asincrona permette a un programma Python di avviare più operazioni mentre attende il completamento di attività lente, come richieste HTTP, accessi a database o letture da servizi esterni. Tuttavia, eseguire molte operazioni contemporaneamente non significa che sia sempre una buona idea lanciarle tutte nello stesso momento.

Un numero eccessivo di richieste simultanee può sovraccaricare il servizio remoto, esaurire le connessioni disponibili o provocare errori come HTTP 429 Too Many Requests. Anche il computer locale può consumare troppa memoria se deve mantenere migliaia di task attivi.

Per controllare la concorrenza possiamo usare asyncio.Semaphore. Un semaforo mantiene un contatore di permessi: un task può entrare nella sezione protetta solo se è disponibile un permesso. Quando termina, lo restituisce agli altri task in attesa.

Questo strumento è diverso da asyncio.Lock: un lock consente l’accesso a un solo task alla volta, mentre un semaforo permette l’accesso contemporaneo a un numero configurabile di task.

Codice completo

import asyncio
import random
from dataclasses import dataclass


@dataclass
class Risultato:
    url: str
    stato: str
    durata: float


async def scarica_risorsa(
    url: str,
    semaforo: asyncio.Semaphore
) -> Risultato:
    """Simula il download di una risorsa remota."""

    # Il task attende qui se tutti i permessi sono già occupati.
    async with semaforo:
        print(f"Inizio richiesta: {url}")

        # Simuliamo una latenza di rete variabile.
        durata = random.uniform(0.5, 2.0)
        await asyncio.sleep(durata)

        # Simuliamo una risposta positiva o negativa.
        if random.random() < 0.9:
            stato = "OK"
        else:
            stato = "ERRORE"

        print(f"Fine richiesta: {url} ({stato})")
        return Risultato(url, stato, durata)


async def main() -> None:
    urls = [
        "https://api.example.com/utenti/1",
        "https://api.example.com/utenti/2",
        "https://api.example.com/utenti/3",
        "https://api.example.com/utenti/4",
        "https://api.example.com/utenti/5",
        "https://api.example.com/utenti/6",
        "https://api.example.com/utenti/7",
        "https://api.example.com/utenti/8",
    ]

    # Al massimo tre richieste saranno attive contemporaneamente.
    semaforo = asyncio.Semaphore(3)

    task = [
        asyncio.create_task(scarica_risorsa(url, semaforo))
        for url in urls
    ]

    risultati = await asyncio.gather(*task)

    print("nRiepilogo:")
    for risultato in risultati:
        print(
            f"{risultato.url}: {risultato.stato}, "
            f"{risultato.durata:.2f} secondi"
        )


if __name__ == "__main__":
    asyncio.run(main())

Spiegazione

Creare il semaforo

La riga asyncio.Semaphore(3) crea un semaforo con tre permessi iniziali. In pratica, soltanto tre coroutine possono eseguire contemporaneamente il codice contenuto nel blocco protetto.

semaforo = asyncio.Semaphore(3)

Il valore deve essere scelto in base al contesto. Per un’API con limiti severi potrebbe essere sufficiente un valore pari a 2 o 5. Per operazioni locali e leggere si potrebbe usare un numero maggiore. Non esiste un valore universale: è necessario osservare tempi di risposta, consumo di memoria ed eventuali limiti del servizio.

Acquisire e restituire un permesso

Nel codice viene usato il semaforo come gestore asincrono di contesto:

async with semaforo:
    await esegui_operazione()

All’ingresso, la coroutine prova ad acquisire un permesso. Se non ce ne sono disponibili, viene sospesa senza bloccare il thread dell’event loop. Quando un altro task esce dal blocco, il permesso viene restituito automaticamente.

L’uso di async with è preferibile alla gestione manuale con acquire() e release(), perché garantisce il rilascio anche se dentro il blocco viene sollevata un’eccezione.

Task creati in anticipo, esecuzione limitata

Nel programma vengono creati task per tutti gli URL. Questo non significa però che tutte le richieste partano contemporaneamente. I task vengono avviati dall’event loop, ma il semaforo consente di entrare nella parte operativa soltanto a tre coroutine per volta.

Questa tecnica è utile quando vogliamo preparare un insieme di lavori, mantenendo comunque un limite preciso sulle attività effettivamente attive.

Semaforo e risorse condivise

Un semaforo è particolarmente adatto per limitare:

  • il numero di richieste HTTP contemporanee;
  • le connessioni simultanee a un servizio esterno;
  • le operazioni costose di lettura o scrittura;
  • l’accesso concorrente a un numero limitato di risorse;
  • il lavoro eseguito tramite API con rate limit.

Non deve essere usato come sostituto di una coda quando serve anche gestire priorità, retry complessi o persistenza dei lavori. In quei casi, una combinazione tra coda asincrona e semaforo può risultare più adatta.

Best practice

  • Definire un limite esplicito: evitare di creare un numero illimitato di operazioni verso un servizio esterno.
  • Proteggere solo la parte necessaria: il blocco del semaforo dovrebbe contenere l’operazione realmente limitata, non elaborazioni indipendenti eseguite prima o dopo.
  • Usare sempre async with: riduce il rischio di dimenticare il rilascio del permesso.
  • Misurare le prestazioni: un limite troppo basso riduce la concorrenza; uno troppo alto può aumentare errori e latenza.
  • Separare il limite per servizio: se l’applicazione usa più API, può essere utile creare un semaforo distinto per ciascuna.
  • Gestire gli errori: una richiesta fallita deve terminare correttamente il blocco protetto, così il permesso torna disponibile.
  • Non confondere concorrenza e parallelismo: asyncio gestisce attività concorrenti soprattutto durante le attese, ma non rende automaticamente parallele le operazioni CPU-bound.

È anche possibile usare un semaforo con un wrapper riutilizzabile:

async def esegui_con_limite(
    operazione,
    semaforo,
    *args,
    **kwargs
):
    async with semaforo:
        return await operazione(*args, **kwargs)

In questo modo la regola di limitazione può essere applicata a più funzioni senza duplicare la stessa logica.

Riepilogo

asyncio.Semaphore consente di controllare quante coroutine possono eseguire contemporaneamente una determinata operazione. È una soluzione semplice ed efficace per evitare sovraccarichi, rispettare i limiti delle API e mantenere prevedibile il consumo di risorse.

Il modello fondamentale è:

semaforo = asyncio.Semaphore(numero_massimo)

async with semaforo:
    await operazione_asincrona()

La coroutine che non trova permessi disponibili non blocca l’intero programma: attende in modo asincrono e riprende quando un altro task libera una risorsa. Combinando semafori, task e funzioni asincrone ben organizzate è possibile costruire applicazioni efficienti e rispettose dei servizi con cui comunicano.

Approfondisci con risorse ufficiali

SHARE