Python asyncio est le système de concurrence asynchrone intégré à Python depuis la version 3.4, et mature en 2026 grâce à des années de raffinements. Il permet d’exécuter des milliers d’opérations I/O concurrentes (requêtes HTTP, lectures base de données, appels à des API) dans un seul thread, sans les surcoûts mémoire des threads traditionnels. Ce guide pratique vous emmène de la compréhension de l’event loop aux patterns de production avancés : gather, semaphores, queues, et debugging. Que vous construisiez une API FastAPI ou un scraper haute performance, asyncio est un outil indispensable.
L’event loop : le cœur d’asyncio
L’event loop est le chef d’orchestre central d’asyncio. C’est une boucle infinie qui surveille des handles asynchrones (fichiers, sockets, timers) et exécute les callbacks associés quand un événement se produit. En Python 3.10+, la gestion de l’event loop est largement automatisée : `asyncio.run(main())` crée une nouvelle event loop, exécute la coroutine `main` jusqu’à sa complétion, puis ferme proprement la loop. Cette API haut niveau remplace avantageusement les anciennes méthodes `get_event_loop()` et `loop.run_until_complete()` qui introduisaient des subtilités complexes sur la gestion du cycle de vie de la loop.
Il est crucial de comprendre qu’asyncio est mono-thread par nature. L’event loop ne fait qu’une chose à la fois : elle exécute un callback, puis passe au suivant. La « concurrence » asyncio ne vient pas d’une exécution en parallèle (comme avec les threads ou multiprocessing), mais d’une suspension intelligente : quand une coroutine attend une opération I/O (`await`), elle suspend son exécution et rend le contrôle à l’event loop, qui peut alors exécuter d’autres coroutines en attente. Ce modèle de concurrence coopérative est extrêmement efficace pour les charges I/O-bound mais inutile pour les charges CPU-bound.
La distinction entre I/O-bound et CPU-bound est fondamentale pour décider d’utiliser asyncio. Un serveur web qui fait beaucoup d’appels HTTP vers des APIs externes est I/O-bound : les 99% du temps sont passés à attendre des réponses réseau. Asyncio excelle dans ce cas. En revanche, un script qui traite des images avec Pillow ou effectue des calculs scientifiques avec NumPy est CPU-bound : le processeur est saturé et asyncio n’apporte rien. Pour les charges CPU-bound, `multiprocessing` ou `concurrent.futures.ProcessPoolExecutor` sont les bonnes solutions. Mélanger les deux (asyncio + ProcessPoolExecutor) permet de combiner concurrence I/O et parallélisme CPU dans la même application.
Coroutines, async/await et awaitables
Une coroutine est une fonction définie avec `async def` qui peut suspendre son exécution avec `await`. Contrairement à une fonction normale qui s’exécute jusqu’à la fin quand on l’appelle, une coroutine crée un objet coroutine paresseux qui ne s’exécute que quand on l’`await` ou qu’on la soumet à l’event loop. C’est une source classique de confusion pour les débutants : oublier le `await` devant un appel de coroutine ne produit pas d’erreur immédiate mais crée un objet coroutine non exécuté, souvent signalé par un warning Python « coroutine was never awaited ».
`await` ne peut s’utiliser que dans des fonctions `async def`. Il accepte tout objet « awaitable » : les coroutines, les objets `asyncio.Task`, les `asyncio.Future`, et tout objet implémentant la méthode `__await__`. Quand on `await` quelque chose, Python suspend la coroutine courante, rend le contrôle à l’event loop, et reprendra la coroutine quand l’awaitable sera résolu. Cette mécanique est la base de toute la puissance d’asyncio : des milliers de coroutines peuvent être en attente simultanément, et l’event loop les reprend au moment précis où leurs données sont disponibles, sans gaspiller de ressources à faire du polling actif.
Les générateurs asynchrones (`async def` + `yield`) et les itérateurs asynchrones (`async for`, `async with`) étendent le modèle coroutine à des cas d’usage avancés. Un générateur asynchrone peut produire des résultats progressivement depuis une source de données asynchrone (flux HTTP, résultats de base de données paginés) sans charger tout en mémoire. Les context managers asynchrones (`async with`) permettent de gérer proprement l’acquisition et la libération de ressources asynchrones comme des connexions de base de données ou des verrous distribués. Ces constructions rendent le code asyncio aussi expressif que son équivalent synchrone, mais avec la puissance de la concurrence non bloquante.
asyncio.gather et asyncio.TaskGroup : concurrence parallèle
`asyncio.gather()` est la primitive fondamentale pour exécuter plusieurs coroutines en concurrence et attendre leurs résultats. Il prend plusieurs awaitables en arguments, les lance tous simultanément et retourne une liste de résultats dans le même ordre que les arguments, quels que soient leurs temps d’exécution réels. Si une coroutine lève une exception et que `return_exceptions=False` (défaut), gather annule les autres coroutines et propage l’exception. Avec `return_exceptions=True`, les exceptions sont retournées comme résultats normaux, permettant de traiter les succès et les échecs séparément.
Python 3.11 a introduit `asyncio.TaskGroup`, une API plus robuste et idiomatique que `gather` pour la gestion de groupes de tâches. Utilisé comme context manager `async with`, il garantit que toutes les tâches du groupe sont terminées (avec succès ou exception) avant de sortir du bloc. Si une tâche échoue, les autres sont annulées et `TaskGroup` collecte toutes les exceptions dans un `ExceptionGroup`. Cette sémantique de « fail-fast avec cleanup propre » est supérieure à `gather` pour la robustesse, et la syntaxe `async with TaskGroup() as tg: tg.create_task(…)` est plus lisible que `gather`.
Le choix entre gather et TaskGroup dépend de la version Python et du cas d’usage. Pour Python 3.11+, TaskGroup est à privilégier pour sa gestion d’erreurs plus propre. Pour des appels parallèles simples où on veut récupérer tous les résultats même en cas d’erreur partielle, gather avec `return_exceptions=True` reste pratique. Un pattern courant est de lancer 10 requêtes HTTP en parallèle avec gather, puis de filtrer les exceptions des résultats valides : `results = await asyncio.gather(*[fetch(url) for url in urls], return_exceptions=True); good = [r for r in results if not isinstance(r, Exception)]`.
import asyncio
import aiohttp
async def fetch(session, url, semaphore):
async with semaphore:
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
resp.raise_for_status()
return await resp.json()
except aiohttp.ClientError as e:
return {"error": str(e), "url": url}
async def main():
urls = [f"https://api.example.com/data/{i}" for i in range(100)]
semaphore = asyncio.Semaphore(10) # max 10 connexions simultanees
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url, semaphore) for url in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
successes = [r for r in results if not isinstance(r, Exception)]
errors = [r for r in results if isinstance(r, Exception)]
print(f"{len(successes)} OK, {len(errors)} erreurs")
return successes
asyncio.run(main())
Semaphores et contrôle du débit
Lancer des milliers de tâches asyncio en parallèle sans contrôle peut saturer des ressources : connexions base de données épuisées, API tierces qui rate-limitent, mémoire consommée par trop de réponses en transit simultanément. `asyncio.Semaphore` résout ce problème en limitant le nombre de coroutines s’exécutant simultanément. On crée un sémaphore avec `sem = asyncio.Semaphore(10)` puis on entoure chaque tâche critique d’un `async with sem:`. Quand 10 tâches ont acquis le sémaphore, les suivantes attendent automatiquement qu’une se libère avant de continuer.
Le pattern scraper avec sémaphore est un classique asyncio. On crée une liste de milliers d’URLs à scraper, on définit un sémaphore à 50 (connexions simultanées max), et on lance `asyncio.gather(*[scrape_with_sem(url) for url in urls])`. Sans sémaphore, ouvrir 10 000 connexions HTTP simultanément ferait planter le serveur distant et épuiserait les descripteurs de fichiers de la machine locale. Avec le sémaphore, le débit est contrôlé, la mémoire reste stable et les APIs tierces ne rate-limitent pas. C’est la différence entre un scraper qui fonctionne et un qui crashe.
Pour les cas où on veut un débit précis en requêtes par seconde (pas juste un nombre de connexions simultanées), on peut combiner un sémaphore avec `asyncio.sleep`. Une approche plus sophistiquée est le « token bucket » implémenté avec un générateur asynchrone qui produit des tokens à intervalle régulier. Des bibliothèques comme `aiolimiter` encapsulent ces patterns en une API simple : `async with rate_limiter: await api_call()`. Le contrôle du débit est crucial quand on interagit avec des APIs qui facturent à la requête ou qui appliquent des limites strictes comme Google, Twitter ou AWS.
aiohttp : client et serveur HTTP asynchrones
aiohttp est la bibliothèque HTTP asynchrone de référence pour Python. Elle fournit un client HTTP non bloquant et un serveur web ASGI/WSGI. Pour les requêtes sortantes, la session aiohttp doit être créée une seule fois et réutilisée pour toutes les requêtes, plutôt que recréée à chaque appel. Une session aiohttp maintient un pool de connexions persistantes (keep-alive) qui élimine l’overhead d’établissement TCP répété. Créer une session par requête est une erreur classique qui dégrade les performances et épuise les ports disponibles sous forte charge.
La gestion des erreurs avec aiohttp demande de distinguer les exceptions réseau (`aiohttp.ClientError`) des erreurs HTTP (status codes 4xx/5xx). `response.raise_for_status()` lève une exception pour les codes d’erreur HTTP, mais pas pour les problèmes réseau comme un timeout ou un refus de connexion. Un pattern robuste combine les deux : un `try/except aiohttp.ClientError` global pour les erreurs réseau et un `response.raise_for_status()` pour les erreurs applicatives. Les timeouts doivent être explicitement configurés via `aiohttp.ClientTimeout(total=30)` car aiohttp n’a pas de timeout par défaut, ce qui peut laisser des coroutines bloquées indéfiniment.
Pour construire des APIs asynchrones performantes avec Python, FastAPI est devenu le standard en 2026. Il repose sur Starlette (framework ASGI) et supporte nativement les handlers `async def`. Chaque requête HTTP est gérée dans l’event loop asyncio, permettant à FastAPI de gérer des milliers de connexions simultanées avec un seul processus. La combinaison FastAPI + asyncpg (PostgreSQL async) + Redis async est une stack courante pour des APIs à forte charge. Avec uvicorn comme serveur ASGI, cette stack peut dépasser 50 000 requêtes par seconde sur un seul cœur, bien au-delà de ce qu’un serveur Flask synchrone peut atteindre.
Queues, workers et pipelines de traitement
`asyncio.Queue` est la primitive de communication entre coroutines productrices et consommatrices. Elle implémente une queue FIFO thread-safe pour l’event loop asyncio, avec `put()` et `get()` asynchrones. Le pattern producteur/consommateur asyncio est idéal pour les pipelines de traitement : des producteurs poussent des tâches dans la queue, des workers les consomment au rythme qu’ils peuvent soutenir. Le paramètre `maxsize` limite la taille de la queue, bloquant automatiquement les producteurs si la queue est pleine, créant un backpressure naturel qui évite la saturation mémoire.
Créer un pool de workers asyncio est un pattern puissant pour le traitement parallèle avec contrôle du débit. On lance N coroutines worker qui consomment la même queue : `await asyncio.gather(*[worker(queue) for _ in range(N)])`. Chaque worker boucle en faisant `item = await queue.get()`, traite l’item et appelle `queue.task_done()`. Le producteur principal remplit la queue puis appelle `await queue.join()` pour attendre que tous les items soient traités. Ce pattern est supérieur à un sémaphore simple quand les tâches ont des temps de traitement très variables : les workers rapides consomment plus d’items, les lents moins, sans ralentir l’ensemble.
Pour les pipelines multi-étapes (fetch → parse → store), on chaîne plusieurs queues et pools de workers. Une queue intermédiaire sépare chaque étape, permettant à chaque phase de s’exécuter à son propre rythme. Si le parsing est plus lent que le fetching, on alloue plus de workers au parsing. Cette architecture de pipeline asynchrone est très efficace pour les ETL (Extract, Transform, Load) de données web : elle maximise l’utilisation des ressources réseau et CPU disponibles, et la queue fournit un buffer naturel entre les étapes, rendant le pipeline robuste aux variations de débit entre les phases.
Debugging asyncio et erreurs courantes
Le debugging des applications asyncio est plus complexe que le code synchrone à cause du caractère non-linéaire de l’exécution. Le mode debug asyncio (`asyncio.run(main(), debug=True)` ou variable d’environnement `PYTHONASYNCIODEBUG=1`) active des vérifications supplémentaires : détection des coroutines jamais awaitées, avertissement pour les coroutines bloquantes appelées depuis l’event loop, et logging des callbacks qui prennent plus de 100ms. Ces avertissements pointent directement vers les problèmes de design les plus fréquents et sont indispensables en développement.
Les « coroutines bloquantes » sont l’ennemi numéro un de la performance asyncio. Appeler une fonction synchrone bloquante comme `time.sleep()`, `requests.get()` ou des opérations fichier synchrones depuis une coroutine asyncio bloque l’event loop entière : toutes les autres coroutines sont suspendues pendant ce temps. La règle absolue est de ne jamais bloquer l’event loop. Pour les opérations fichier et les bibliothèques synchrones, utiliser `asyncio.to_thread()` (Python 3.9+) qui exécute la fonction dans un thread pool sans bloquer la loop. Pour les accès réseau, utiliser exclusivement des bibliothèques async-native.
Les exceptions non gérées dans les tâches asyncio créées avec `asyncio.create_task()` peuvent disparaître silencieusement si personne n’`await` la tâche. Python 3.11+ génère un warning dans ce cas, mais les versions antérieures peuvent swallower silencieusement les exceptions. La bonne pratique est toujours d’`await` les tâches, ou d’utiliser `task.add_done_callback()` pour logger les exceptions non gérées. Pour un debugging avancé, `asyncio.current_task()` et `asyncio.all_tasks()` permettent d’inspecter l’état courant de l’event loop, et des outils comme `aiomonitor` fournissent une console interactive pour inspecter les tâches en cours d’exécution en production.
Commentaires (0)
Laisser un commentaire
Les commentaires sont modérés. Questions WordPress, cybersécurité ou dev web bienvenues.