Retour au cours

backend / python

Asyncio : event loop, coroutines et tasks

Leçon 211 exercice

Explication

Ce que vous allez apprendre

  • Comprendre la différence entre la concurrence coopérative d'asyncio et le threading/multiprocessing
  • Créer une coroutine avec async def et comprendre pourquoi elle ne s'exécute pas toute seule
  • Lancer plusieurs coroutines en concurrence avec asyncio.create_task() et asyncio.gather()
  • Repérer pourquoi du code bloquant synchrone dans une coroutine casse toute la concurrence
  • Utiliser asyncio.timeout() pour éviter qu'une coroutine bloque indéfiniment

Dans quel contexte ?

Un développeur construit une API FastAPI qui doit interroger trois services externes (météo, devises, actualités) avant de répondre à une requête. En écrivant await appel_meteo(); await appel_devises(); await appel_actualites(), chaque appel attend la fin du précédent : trois secondes au total si chacun prend une seconde. En les lançant avec asyncio.gather(appel_meteo(), appel_devises(), appel_actualites()), les trois attentes se chevauchent et la réponse arrive en environ une seconde — un gain direct pour l'utilisateur final de l'API.

Une troisième forme de concurrence

Après les threads et les processus, voici une approche radicalement différente : la concurrence coopérative. Au lieu de laisser le système d'exploitation décider quand interrompre chaque tâche, chaque coroutine décide elle-même, explicitement, du moment où elle accepte de céder la main via le mot-clé await. C'est un modèle à un seul thread, mais capable de gérer des milliers de tâches en attente simultanément, typiquement des connexions réseau.

Pourquoi une coroutine ne fait rien toute seule

Définir une fonction avec async def ne l'exécute pas : cela crée un objet coroutine, qui reste inerte tant qu'on ne l'exécute pas via await ou qu'on ne le confie pas à l'event loop avec asyncio.run(). C'est une erreur fréquente chez les débutants d'oublier ce détail et de se demander pourquoi rien ne se produit.

ApprocheTrois appels d'1 seconde chacun
await séquentiels~3 secondes
asyncio.gather(...)~1 seconde
asyncio.create_task() x3 puis await~1 seconde

Séquentiel contre concurrent : où se joue le gain

Enchaîner deux await l'un après l'autre reste séquentiel : le temps s'additionne. Pour obtenir un vrai gain, il faut lancer plusieurs tâches en même temps avec asyncio.create_task() ou asyncio.gather(), qui les laisse s'exécuter de façon entrelacée pendant que chacune attend son tour. C'est ce qui explique que deux attentes d'une seconde chacune, lancées en concurrence, ne prennent qu'une seconde au total plutôt que deux.

La règle absolue à ne jamais enfreindre

Un code bloquant synchrone comme time.sleep() glissé par erreur dans une coroutine bloque tout l'event loop, empêchant toutes les autres tâches d'avancer pendant ce temps. C'est l'erreur la plus destructrice en asyncio : il faut toujours utiliser l'équivalent asynchrone, ou déléguer explicitement le code bloquant à un thread via asyncio.to_thread.

Piège fréquent

Écrire time.sleep(2) au lieu de await asyncio.sleep(2) dans une coroutine ne lève aucune erreur, mais bloque tout l'event loop pendant deux secondes : toutes les autres coroutines en attente sont gelées, pas seulement celle qui contient l'erreur. C'est l'un des bugs de performance les plus insidieux d'asyncio, car le code "fonctionne" sans planter.

Commandes & code

Asyncio : event loop, coroutines et tasks

La concurrence coopérative pour du code massivement I/O-bound.

python
import asyncio
import time

# Une coroutine : definie avec async def, ne s'execute que si on l'attend
async def dire_bonjour(nom, delai):
    await asyncio.sleep(delai)     # suspend CETTE coroutine, libere l'event loop
    print(f"Bonjour {nom} (apres {delai}s)")
    return f"resultat de {nom}"

# Lancer une coroutine : asyncio.run() cree l'event loop et l'execute
async def main_sequentiel():
    debut = time.perf_counter()
    await dire_bonjour("Alice", 1)
    await dire_bonjour("Bob", 1)
    print(f"Sequentiel : {time.perf_counter() - debut:.2f}s")    # ~2s

asyncio.run(main_sequentiel())

# --- Tasks : lancer des coroutines EN CONCURRENCE ---
async def main_concurrent():
    debut = time.perf_counter()
    tache1 = asyncio.create_task(dire_bonjour("Alice", 1))
    tache2 = asyncio.create_task(dire_bonjour("Bob", 1))
    # les deux taches s'executent "en meme temps" (entrelacees sur un seul thread)
    resultat1 = await tache1
    resultat2 = await tache2
    print(f"Concurrent : {time.perf_counter() - debut:.2f}s")    # ~1s, pas 2s !

asyncio.run(main_concurrent())

# asyncio.gather : attendre plusieurs coroutines et recuperer tous les resultats
async def main_gather():
    resultats = await asyncio.gather(
        dire_bonjour("Ana", 0.5),
        dire_bonjour("Bo", 0.3),
        dire_bonjour("Cy", 0.8),
    )
    print(resultats)

asyncio.run(main_gather())

# asyncio.TaskGroup (Python 3.11+) : gestion structuree, annule tout si une tache echoue
async def main_taskgroup():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(dire_bonjour("X", 0.2))
        tg.create_task(dire_bonjour("Y", 0.3))
    print("Toutes les taches du groupe sont terminees")

asyncio.run(main_taskgroup())

# Timeout : eviter qu'une coroutine bloque indefiniment
async def operation_lente():
    await asyncio.sleep(5)
    return "termine"

async def main_timeout():
    try:
        async with asyncio.timeout(1):    # Python 3.11+
            await operation_lente()
    except TimeoutError:
        print("Operation annulee : trop lente")

asyncio.run(main_timeout())

# Producteur/consommateur avec asyncio.Queue
async def producteur(queue):
    for i in range(5):
        await queue.put(i)
        await asyncio.sleep(0.1)
    await queue.put(None)          # signal de fin

async def consommateur(queue):
    while True:
        item = await queue.get()
        if item is None:
            break
        print(f"Traite : {item}")

async def main_queue():
    queue = asyncio.Queue()
    await asyncio.gather(producteur(queue), consommateur(queue))

asyncio.run(main_queue())

# Erreur classique : melanger du code bloquant synchrone dans une coroutine
async def mauvais_exemple():
    time.sleep(2)         # BLOQUE tout l'event loop, empeche la concurrence !
    return "fini"

async def bon_exemple():
    await asyncio.sleep(2)          # libere l'event loop pendant l'attente
    # pour du code bloquant existant (ex: librairie synchrone), deleguer a un thread :
    resultat = await asyncio.to_thread(time.sleep, 2)
    return "fini"

# asyncio.run_coroutine_threadsafe / semaphore pour limiter la concurrence
async def main_avec_limite():
    semaphore = asyncio.Semaphore(2)     # max 2 taches simultanees

    async def tache_limitee(n):
        async with semaphore:
            await asyncio.sleep(1)
            print(f"Tache {n} terminee")

    await asyncio.gather(*(tache_limitee(i) for i in range(5)))

asyncio.run(main_avec_limite())

Résumé

  • asyncio offre de la concurrence coopérative sur un seul thread via un event loop.
  • await suspend une coroutine et rend la main à l'event loop, sans bloquer les autres tâches.
  • asyncio.gather/TaskGroup exécutent plusieurs coroutines en concurrence.
  • Ne jamais utiliser du code bloquant synchrone (time.sleep) dans une coroutine : utiliser asyncio.to_thread.

Exercices pratiques

1 disponible
1

Mission : réparer une API qui répond en 3 secondes au lieu d'1

Objectif : Diagnostiquer une fausse concurrence due à des await séquentiels, corriger avec gather, et repérer un appel bloquant caché dans une coroutine.

Contexte

Une API FastAPI interroge trois services externes (météo, devises, actualités), chacun prenant environ une seconde à répondre. Le code écrit await appel_meteo(); await appel_devises(); await appel_actualites() et le développeur ne comprend pas pourquoi la réponse de l'API prend systématiquement trois secondes, alors qu'il pensait que async/await rendait son code automatiquement concurrent.

Tu dois corriger cette fausse concurrence, puis traquer un appel bloquant caché qui gèle silencieusement tout l'event loop.

Résoudre l’exercice →