Définition
Un programme qui va chercher cinquante pages sur Internet passe l'essentiel de son temps à attendre. Il envoie une demande, attend la réponse, recommence, et pendant ce temps le processeur ne fait rien.
asyncio est le module de la bibliothèque standard qui récupère ce temps mort. Il fournit la boucle d'événements, ce chef d'orchestre qui décide quelle coroutine avance et laquelle attend, ainsi que les outils pour les lancer de front.
Les mots-clés async et await ne font qu'inscrire une intention dans le code ; sans asyncio pour l'exécuter, ils ne produisent rien. La démonstration tient en quelques lignes : deux salutations lancées ensemble.
import asyncio
async def saluer(nom, delai):
await asyncio.sleep(delai) # laisse la boucle s'occuper des autres
print("Bonjour", nom)
async def main():
await asyncio.gather(
saluer("Alice", 2),
saluer("Bob", 1),
)
asyncio.run(main()) # ouvre la boucle, la referme à la finCe programme se termine en deux secondes et non en trois : pendant que la première salutation patiente, la seconde progresse. La durée totale n'est plus la somme des attentes mais la plus longue d'entre elles.
La boucle d'événements
La boucle est un guichet unique. Elle fait avancer une seule tâche à la fois et reprend la main dès que celle-ci rencontre une attente : elle passe à la suivante, et y reviendra quand la réponse arrivera.
Il n'y a donc qu'un seul fil d'exécution, jamais deux lignes au même instant. Cela a un bon côté : aucune instruction n'est interrompue en cours de route, donc pas de verrous à poser sur les variables partagées. La concurrence naît des seuls moments où une fonction rend la main volontairement, à chaque await.
Un seul appel ouvre et referme cette boucle, asyncio.run(), et tout le reste vit à l'intérieur. D'où une erreur classique : un await écrit au niveau du fichier lève une SyntaxError, faute de boucle pour l'accueillir.
Le mot async ne s'arrête pas aux fonctions : posé devant un with ou devant une boucle for, il donne un gestionnaire de contexte et un itérateur capables d'attendre à leur tour.
Lancer plusieurs choses de front
Premier essai, première déception : écrire deux await à la suite ne fait rien gagner. Le mot veut dire « attendre ici », donc le second appel ne démarre qu'une fois le premier fini.
Le chevauchement se demande explicitement. Voici les écritures qui servent au quotidien, et ce que chacune change.
| Écriture | Ce qu'elle produit |
|---|---|
await a() puis await b() | Deux attentes bout à bout, aucun gain |
asyncio.gather(a(), b()) | Les deux avancent ensemble, résultats dans une liste |
asyncio.create_task(a()) | Le travail part en fond, il sera attendu plus tard |
asyncio.wait_for(a(), 5) | Une limite de temps, et une exception au-delà |
asyncio.TaskGroup() | Un groupe qui annule tout dès qu'un membre échoue |
Le TaskGroup, arrivé avec Python 3.11, est la ligne à retenir : il garantit qu'aucune tâche ne survit à la sortie du bloc, alors qu'une tâche créée à la main puis jamais attendue disparaît en silence à la fermeture de la boucle.
asyncio ne rend pas asynchrone une bibliothèque qui ne l'est pas : il faut un client prévu pour, comme httpx ou aiohttp à la place de requests.
Le piège de l'appel bloquant
Tout repose sur une discipline : chaque tâche doit rendre la main régulièrement. Une ligne synchrone qui prend son temps gèle la boucle entière, et toutes les autres tâches font la queue derrière elle.
Les deux lignes qui suivent se ressemblent et ne font pas du tout la même chose.
import time
async def mesurer():
time.sleep(3) # met en pause le fil entier, boucle comprise
await asyncio.sleep(3) # rend la main : les autres tâches avancentLa première met le programme en sommeil, boucle comprise ; la seconde prévient la boucle qu'elle peut aller voir ailleurs. Le même écart vaut pour une requête réseau synchrone, la lecture d'un gros fichier ou un calcul lourd, avec asyncio.to_thread() pour sortie de secours.
Un appel bloquant ne lève aucune erreur et n'écrit rien dans les journaux. Le programme rend les bons résultats, simplement aussi lentement qu'une version synchrone : la cause peut coûter des heures à trouver.
Reste le choix de fond. asyncio excelle sur les attentes réseau, là où le programme ne fait que patienter des centaines de fois, comme un serveur web bâti sur FastAPI. Pour du calcul pur il n'apporte rien, faute d'attente à récupérer, et threading ou les processus séparés restent les bonnes réponses.
Questions fréquentes
asyncio rend-il un programme plus rapide ?
Seulement s'il passe son temps à attendre : appels réseau, requêtes en base, lectures distantes. Le gain est proportionnel au temps mort que la boucle peut réutiliser. Sur un calcul qui occupe le processeur, le programme tourne à la même vitesse, avec du code plus difficile à relire.
Pourquoi mes tâches s'arrêtent-elles avant la fin ?
Parce que asyncio.run() ferme la boucle dès que la coroutine principale se termine, sans attendre celles qui tournent encore. Une tâche lancée avec create_task n'est pas une promesse d'aller au bout : elle doit être attendue par un await, ou rangée dans un TaskGroup.
Comment rattraper une erreur levée dans une tâche ?
Avec un try autour du await, et non autour de la ligne qui lance la tâche : l'erreur ne remonte qu'au moment où le résultat est réclamé. Sur un gather, l'option return_exceptions=True range les erreurs parmi les résultats au lieu d'interrompre le groupe. La formation Python consacre un chapitre à ces mécaniques d'attente.