Programmation asynchrone#
Le problème des I/O bloquants#
La quasi-totalité des programmes réels passent la majorité de leur temps à attendre : attendre qu’une requête HTTP reçoive une réponse, qu’une requête SQL se termine, qu’un fichier soit lu depuis le disque, qu’un message arrive sur un socket réseau. Dans un modèle d’exécution classique (synchrone et mono-thread), ce temps d’attente est du temps perdu : le fil d’exécution est bloqué, il ne peut rien faire d’autre.
La solution habituelle est le multithreading : créer un thread par tâche pour que plusieurs attentes se déroulent en parallèle. Mais cette approche a ses limites. Les threads sont coûteux en mémoire (chaque thread consomme une pile de plusieurs mégaoctets) et la coordination entre eux — verrous, conditions, sections critiques — est source de bugs difficiles à reproduire. De plus, à cause du GIL (que nous verrons au chapitre suivant), plusieurs threads Python ne peuvent pas exécuter du bytecode Python simultanément.
La programmation asynchrone propose une alternative élégante : un seul thread d’exécution, mais qui peut suspendre une tâche en attente d’I/O et reprendre une autre tâche pendant ce temps. L’ordonnancement est coopératif : les tâches elles-mêmes indiquent les points où elles acceptent d’être suspendues (avec le mot-clé await). Le composant qui gère cet ordonnancement s’appelle la boucle d’événements (event loop).
Définition 35 (Boucle d’événements)
La boucle d’événements (event loop) est le composant central de la programmation asynchrone. Elle maintient une file de tâches prêtes à s’exécuter. Quand une tâche atteint un point d’attente (await), elle se suspend et rend le contrôle à la boucle. La boucle vérifie alors quelles opérations I/O sont terminées (via select, epoll ou kqueue selon l’OS), marque les tâches correspondantes comme prêtes, et reprend leur exécution. Ce cycle se répète aussi longtemps qu’il y a des tâches en cours.
Coroutines#
Une coroutine est une fonction qui peut être suspendue et reprise. En Python, on la définit avec async def. Une coroutine retourne un objet coroutine qui n’est pas exécuté immédiatement : il doit être attendu avec await, ou planifié par la boucle d’événements.
Définition 36 (Coroutine)
Une coroutine est définie avec async def. Appelée, elle retourne un objet coroutine. Le mot-clé await expr suspend la coroutine courante jusqu’à ce que expr (qui doit être un awaitable — coroutine, Task ou Future) soit terminée, puis reprend l’exécution et fournit le résultat. Contrairement aux générateurs qui utilisent yield, await est conçu spécifiquement pour l’asynchronisme.
import asyncio
async def saluer(nom: str, delai: float) -> str:
print(f" Début : saluer({nom})")
await asyncio.sleep(delai) # suspend la coroutine, libère la boucle
message = f"Bonjour, {nom} !"
print(f" Fin : saluer({nom})")
return message
async def principale():
# Exécution séquentielle (attend la fin de chaque appel)
msg1 = await saluer("Alice", 0.1)
msg2 = await saluer("Bob", 0.1)
print(msg1)
print(msg2)
asyncio.run(principale())
---------------------------------------------------------------------------
RuntimeError Traceback (most recent call last)
Cell In[2], line 17
14 print(msg1)
15 print(msg2)
---> 17 asyncio.run(principale())
File /usr/lib/python3.13/asyncio/runners.py:191, in run(main, debug, loop_factory)
161 """Execute the coroutine and return the result.
162
163 This function runs the passed coroutine, taking care of
(...) 187 asyncio.run(main())
188 """
189 if events._get_running_loop() is not None:
190 # fail fast with short traceback
--> 191 raise RuntimeError(
192 "asyncio.run() cannot be called from a running event loop")
194 with Runner(debug=debug, loop_factory=loop_factory) as runner:
195 return runner.run(main)
RuntimeError: asyncio.run() cannot be called from a running event loop
La différence essentielle entre une coroutine et un générateur tient à leur sémantique : un générateur yield produit des valeurs à la demande, tandis qu’une coroutine await délègue l’exécution à une autre opération asynchrone et attend son résultat. Bien qu’ils reposent sur le même mécanisme bas niveau (objets send/throw), leur usage est distinct.
asyncio#
Le module asyncio de la bibliothèque standard fournit la boucle d’événements, les primitives de haut niveau et l’outillage complet pour écrire du code asynchrone.
asyncio.run et asyncio.create_task#
asyncio.run(coro) crée une nouvelle boucle d’événements, exécute la coroutine passée en argument jusqu’à sa conclusion, puis ferme la boucle. C’est le point d’entrée standard d’un programme asynchrone.
asyncio.create_task(coro) planifie immédiatement une coroutine comme une tâche (Task) dans la boucle courante, sans attendre son résultat. Les tâches s’exécutent de façon concurrente : pendant qu’une tâche est suspendue sur await, les autres peuvent progresser.
import asyncio
import time
async def telecharger(url: str, duree: float) -> str:
print(f" Début téléchargement : {url}")
await asyncio.sleep(duree) # simule un I/O réseau
print(f" Fin téléchargement : {url}")
return f"Contenu de {url}"
async def principale_taches():
debut = time.perf_counter()
# Créer les tâches : elles démarrent immédiatement en concurrence
t1 = asyncio.create_task(telecharger("https://example.com/a", 0.15))
t2 = asyncio.create_task(telecharger("https://example.com/b", 0.10))
t3 = asyncio.create_task(telecharger("https://example.com/c", 0.12))
# Attendre chaque tâche dans l'ordre de création
res1 = await t1
res2 = await t2
res3 = await t3
duree = time.perf_counter() - debut
print(f"\nDurée totale : {duree:.3f}s (séquentiel aurait pris ~0.37s)")
return [res1, res2, res3]
resultats = asyncio.run(principale_taches())
asyncio.gather#
asyncio.gather(*coroutines) planifie toutes les coroutines concurremment et attend qu’elles soient toutes terminées, en retournant leurs résultats dans l’ordre d’entrée.
import asyncio
async def tache(nom: str, duree: float) -> str:
await asyncio.sleep(duree)
return f"{nom} terminé"
async def toutes_les_taches():
resultats = await asyncio.gather(
tache("Alpha", 0.1),
tache("Beta", 0.05),
tache("Gamma", 0.08),
return_exceptions=True # les exceptions ne propagent pas immédiatement
)
for r in resultats:
print(r)
asyncio.run(toutes_les_taches())
Primitives de synchronisation#
Même en mono-thread, plusieurs coroutines peuvent accéder à une ressource partagée (une connexion à la base de données, un compteur, un fichier). asyncio fournit des primitives de synchronisation qui fonctionnent sans bloquer la boucle.
import asyncio
async def demo_lock():
verrou = asyncio.Lock()
compteur = {"valeur": 0}
async def incrementer(nom: str):
async with verrou: # section critique
valeur_actuelle = compteur["valeur"]
await asyncio.sleep(0) # point de suspension (simule un I/O)
compteur["valeur"] = valeur_actuelle + 1
print(f" {nom} → compteur = {compteur['valeur']}")
await asyncio.gather(
incrementer("A"), incrementer("B"), incrementer("C")
)
print(f"Valeur finale : {compteur['valeur']}") # Toujours 3
asyncio.run(demo_lock())
Définition 37 (Primitives asyncio de synchronisation)
asyncio.Lock: verrou d’exclusion mutuelle. Une seule coroutine peut détenir le verrou à la fois. Utilisezasync with lock:pour l’acquisition/libération automatique.asyncio.Event: drapeau binaire. Les coroutines peuventawait event.wait()jusqu’à ce qu’une autre coroutine appelleevent.set().asyncio.Semaphore(n): permet à au plusncoroutines de détenir la ressource simultanément. Idéal pour limiter le nombre de connexions concurrentes.asyncio.Queue: file de communication producteur/consommateur.await queue.put(item)etawait queue.get()sont tous deux non bloquants pour la boucle.
import asyncio
async def demo_semaphore():
"""Limiter à 2 téléchargements simultanés."""
semaphore = asyncio.Semaphore(2)
async def telecharger(i: int):
async with semaphore:
print(f" Téléchargement {i} en cours...")
await asyncio.sleep(0.1)
print(f" Téléchargement {i} terminé")
await asyncio.gather(*[telecharger(i) for i in range(5)])
asyncio.run(demo_semaphore())
I/O asynchrones#
asyncio brille particulièrement dans les applications à forte intensité I/O : serveurs réseau, clients HTTP, accès à des bases de données.
Sockets et connexions réseau#
import asyncio
async def client_echo():
reader, writer = await asyncio.open_connection("localhost", 8888)
writer.write(b"Bonjour\n")
await writer.drain()
data = await reader.readline()
print(f"Reçu : {data.decode().strip()}")
writer.close()
await writer.wait_closed()
Fichiers avec aiofiles#
La bibliothèque aiofiles fournit des opérations de lecture/écriture de fichiers qui ne bloquent pas la boucle d’événements :
import aiofiles
async def lire_fichier(chemin: str) -> str:
async with aiofiles.open(chemin, encoding="utf-8") as f:
contenu = await f.read()
return contenu
HTTP avec httpx#
import httpx
import asyncio
async def recuperer_plusieurs_urls(urls: list[str]) -> list[str]:
async with httpx.AsyncClient() as client:
taches = [client.get(url) for url in urls]
reponses = await asyncio.gather(*taches)
return [r.text for r in reponses]
Itérateurs et gestionnaires de contexte asynchrones#
Python étend les protocoles classiques à l’asynchronisme.
async for et __aiter__ / __anext__#
Un itérateur asynchrone implémente __aiter__ (qui retourne l’itérateur lui-même) et __anext__ (qui retourne un awaitable). La syntaxe async for est réservée aux contextes async def.
import asyncio
class CompteARebours:
def __init__(self, debut: int):
self._n = debut
def __aiter__(self):
return self
async def __anext__(self) -> int:
if self._n < 0:
raise StopAsyncIteration
await asyncio.sleep(0) # point de suspension
valeur = self._n
self._n -= 1
return valeur
async def demo_aiter():
async for n in CompteARebours(4):
print(n, end=" ")
print()
asyncio.run(demo_aiter())
async with et __aenter__ / __aexit__#
Un gestionnaire de contexte asynchrone implémente __aenter__ et __aexit__ comme des coroutines. C’est indispensable pour les ressources dont l’acquisition ou la libération est elle-même une opération asynchrone (connexion à une base de données, session HTTP, transaction).
class ConnexionDB:
async def __aenter__(self):
self._conn = await ouvrir_connexion()
return self._conn
async def __aexit__(self, *exc_info):
await self._conn.fermer()
return False
async def interroger():
async with ConnexionDB() as conn:
résultat = await conn.executer("SELECT 1")
Bonnes pratiques#
Remarque 35
Quand utiliser async ? La programmation asynchrone est bénéfique lorsque le programme est I/O-bound : il passe plus de temps à attendre des entrées/sorties qu’à calculer. Pour les tâches CPU-bound (calcul intensif, compression, cryptographie), asyncio n’apporte rien — utilisez multiprocessing à la place. Une bonne heuristique : si vous faites plus de dix requêtes réseau ou accès disque concurrents, asyncio vous fera économiser du temps et de la mémoire par rapport aux threads.
Les pièges courants à éviter :
Appeler une coroutine sans
await:saluer("Alice")crée un objet coroutine sans l’exécuter. Python 3.11+ émet un avertissementRuntimeWarning: coroutine 'saluer' was never awaited.Appeler
awaithors d’une coroutine :awaitne peut s’utiliser qu’à l’intérieur d’une fonctionasync def.Bloquer la boucle avec du code synchrone : appeler
time.sleep(5)au lieu deawait asyncio.sleep(5)bloque l’intégralité de la boucle — aucune autre coroutine ne peut s’exécuter pendant ce temps.Mélanger les bibliothèques synchrones et asynchrones : si une bibliothèque n’offre pas d’API
async, utilisezloop.run_in_executor()pour exécuter les appels bloquants dans un thread pool sans bloquer la boucle.
import asyncio
async def exemple_run_in_executor():
"""Exécuter du code bloquant sans bloquer la boucle d'événements."""
import time
def operation_bloquante(n: int) -> int:
time.sleep(0.05) # opération synchrone bloquante
return n * n
boucle = asyncio.get_event_loop()
resultat = await boucle.run_in_executor(None, operation_bloquante, 7)
print(f"Résultat : {resultat}") # 49
asyncio.run(exemple_run_in_executor())
Résumé#
Dans ce chapitre, nous avons exploré la programmation asynchrone en Python :
Les I/O bloquants paralysent un thread entier pendant l’attente. La boucle d’événements d”
asynciopermet à un seul thread de gérer des milliers d’opérations I/O concurrentes en suspendant et reprenant les coroutines.Une coroutine se définit avec
async defet se suspend avecawait. Elle ne s’exécute qu’au sein d’une boucle d’événements, démarrée avecasyncio.run().asyncio.create_tasketasyncio.gatherpermettent de lancer plusieurs tâches concurrentes et d’attendre leurs résultats.Les primitives de synchronisation (
Lock,Event,Semaphore,Queue) protègent les ressources partagées entre coroutines sans bloquer la boucle.Les protocoles asynchrones (
async for,async with) étendent les protocoles classiques aux ressources dont l’acquisition ou la libération est asynchrone.La règle d’or :
asynciopour les programmes I/O-bound,multiprocessingpour les programmes CPU-bound, etloop.run_in_executor()pour appeler du code synchrone bloquant depuis une coroutine.
Dans le chapitre suivant, nous abordons la concurrence et le parallélisme : le GIL, threading, multiprocessing et concurrent.futures.