Kodokon kodokon.com

Asynchrone avec asyncio : coroutines, await, gather

Comprenez la concurrence coopérative d'asyncio : coroutines paresseuses, points de suspension, gather, TaskGroup et pièges des appels bloquants.

10 min · 3 questions

Ouvrir cette leçon dans Kodokon

asyncio implémente de la concurrence coopérative sur un seul thread : une boucle d'événements orchestre des coroutines qui rendent volontairement la main à chaque await. Point fondamental, souvent mal compris : appeler une fonction async def n'exécute rien. Cela fabrique un objet coroutine inerte, dont le corps n'avance que lorsqu'il est attendu avec await ou planifié comme tâche sur la boucle.

PYTHON
import asyncio

async def greet() -> str:
    return "hello"

coro = greet()
print(type(coro).__name__)

async def main() -> None:
    print(await coro)

asyncio.run(main())
L'appel greet() crée un objet coroutine ; rien ne s'exécute avant await.

await marque un point de suspension : la coroutine s'y gèle, la boucle reprend la main et fait progresser une autre tâche prête. Entre deux await, votre code s'exécute sans interruption - pas besoin de verrous pour la plupart des structures. Pour lancer plusieurs coroutines en parallèle (au sens I/O), enveloppez-les dans asyncio.gather : trois attentes d'une seconde chacune se terminent en une seconde au total, pas trois.

PYTHON
import asyncio
import time

async def fetch(name: str, delay: float) -> str:
    await asyncio.sleep(delay)
    return f"{name} done"

async def main() -> None:
    start = time.perf_counter()
    results = await asyncio.gather(
        fetch("a", 1.0),
        fetch("b", 1.0),
        fetch("c", 1.0),
    )
    elapsed = time.perf_counter() - start
    print(results)
    print(f"total: {elapsed:.1f} s")

asyncio.run(main())
Trois attentes concurrentes : environ 1 seconde au total.

Par défaut, gather propage la première exception levée mais laisse les autres tâches continuer en arrière-plan - rarement ce que vous voulez. Avec return_exceptions=True, les exceptions sont renvoyées comme résultats, à trier ensuite. Depuis Python 3.11, préférez asyncio.TaskGroup : à la première erreur, toutes les tâches sœurs sont annulées proprement, et les éventuelles exceptions multiples remontent groupées dans un ExceptionGroup, à intercepter avec except*.

PYTHON
import asyncio
import time

def blocking_io() -> str:
    time.sleep(1)
    return "report written"

async def main() -> None:
    results = await asyncio.gather(
        asyncio.to_thread(blocking_io),
        asyncio.sleep(1, result="tick"),
    )
    print(results)

asyncio.run(main())
to_thread isole le code bloquant : la boucle reste réactive.

Quiz de validation

Vérifiez que vous avez bien retenu les points clés de cette leçon.

  1. Que produit l'appel fetch('a', 1.0) écrit seul, sans await ni create_task ?
    • Un objet coroutine inerte : aucune ligne du corps de la fonction n'est exécutée
    • L'exécution immédiate et synchrone de la coroutine
    • Une tâche automatiquement planifiée sur la boucle d'événements
    • Une SyntaxError, car await est obligatoire
  2. Trois asyncio.sleep(1) lancés ensemble via asyncio.gather : quelle durée totale ?
    • Environ 3 secondes, car un seul thread exécute le code
    • Impossible à prévoir sans connaître le nombre de cœurs
    • Environ 1 seconde : les attentes se recouvrent, la boucle passe d'une tâche à l'autre
  3. Pourquoi time.sleep(2) est-il proscrit dans une coroutine ?
    • Il lève une exception dans un contexte asynchrone
    • Il bloque le thread de la boucle d'événements : aucune autre tâche ne progresse pendant 2 secondes
    • Il est simplement moins précis que asyncio.sleep
    • Il consomme deux fois plus de mémoire