Trois requêtes HTTP en synchrone : 6 secondes. Les mêmes en async/await : à peine plus que la plus longue. Pas de magie, juste une loop qui arrête d’attendre bêtement et distribue le temps pendant les I/O. On va écrire des coroutines, les lancer en task, récupérer les résultats avec gather, et voir où ça part en vrille sur les exceptions et les timeouts.
Qu’est-ce que async et await en Python ?
async transforme une fonction en coroutine. await suspend cette coroutine jusqu’à ce que l’awaitable soit résolu. asyncio orchestre la loop qui exécute les coroutines, crée des tasks et supervise les futures.
Définition simple d’une coroutine
Une coroutine est une fonction déclarée avec async def. Appeler cette fonction renvoie un objet coroutine, un awaitable qui ne s’exécute pas tant qu’on ne l’await ou qu’on ne le convertit en task. Une coroutine renvoie un résultat via return comme une fonction classique.
Pourquoi asyncio est nécessaire
asyncio est la bibliothèque standard qui fournit la boucle d’événements, les primitives futures, les outils pour créer des task et des moyens pour attendre des opérations non bloquantes. Sans asyncio, écrire des coroutines avec async/await n’aurait pas de moteur pour exécuter la concurrence logique.
Dans quels cas utiliser l’asynchrone
Async, c’est pour les I/O : requêtes réseau, fichiers async, attente d’événements. Sur du calcul CPU pur, ça n’apporte rien, on reste sur multithreading ou multiprocessing.
Premier script async/await prêt à copier
Un exemple minimal qui couvre async, await, asyncio.sleep, return et asyncio.run.
import asyncio
import time
async def worker(name):
print(f"{name} start")
await asyncio.sleep(1)
print(f"{name} after sleep")
return f"{name} done"
async def main():
print("main before")
result = await worker("A")
print("main after await", result)
if __name__ == "__main__":
start = time.monotonic()
asyncio.run(main())
print("elapsed", time.monotonic() - start)
Code complet commenté ligne par ligne
import asyncio: on importe asyncio pour accéder à la loop, sleep, run, create_task, gather.import time: mesure de durée et comparaison avec time.sleep.async def worker(name):: définition d’une coroutine. C’est une coroutine function qui renvoie une coroutine lorsque appelée.print(f"{name} start"): print montre l’ordre d’exécution.await asyncio.sleep(1): await suspend la coroutine worker pendant 1 seconde ; la loop peut exécuter d’autres coroutines pendant ce temps.return f"{name} done": la coroutine renvoie une valeur à l’appelant après l’exécution.asyncio.run(main()): lance la boucle, exécute main, ferme la loop proprement à la fin.
Que renvoie la fonction et dans quel ordre s’exécute le code
La fonction worker renvoie une chaîne via return. Lorsqu’on await worker("A"), la coroutine s’exécute et renvoie sa valeur au moment du return. L’ordre des print est déterminé par l’ordre des await. Le print("main after await", result) s’exécute seulement après que worker ait fait son return.
Sortie attendue à l’écran
La sortie attendue ressemble à :
- main before
- A start
- A after sleep
- main after await A done
- elapsed 1.00…
Pourquoi await suspend la coroutine
await marque un point d’attente : la coroutine cède la main à la loop. La loop marque la coroutine comme “en attente” et peut exécuter d’autres coroutines, démarrer des task, vérifier des futures. L’effet de await est local : il suspend la coroutine qui l’appelle, pas l’ensemble du programme.
Le rôle de la boucle d’événements
La loop est le moteur. Elle planifie l’exécution des coroutines, réveille les futures prêtes, gère les sockets asynchrones et distribue le temps entre les task.
Que fait la loop pendant l’attente
Quand une coroutine fait await asyncio.sleep(1), la loop met la coroutine en sommeil et passe à une autre coroutine prête. La loop appelle ensuite les callbacks qui deviennent prêts et complète les futures.
Différence entre coroutine, task et future
- coroutine : objet awaitable issu d’une
async def. - task : wrapper qui planifie l’exécution d’une coroutine sur la loop, créé via
asyncio.create_task. - future : primitive qui représente une valeur qui sera disponible plus tard ; une task est une future particulière.
Schéma textuel du cycle d’exécution
- Appel de la coroutine (on obtient un objet coroutine).
- Création d’une task si on veut exécuter en arrière-plan.
- La loop exécute jusqu’au premier
await; si l’attente est sur une future, elle passe à autre chose. - Quand la future est prête, la loop reprend la coroutine et collecte le résultat.
- La coroutine finit et la task/future renvoie la valeur via
result().
Pourquoi transformer une coroutine en task
Créer une task avec create_task permet d’exécuter la coroutine en parallèle logique sans attendre son return immédiatement. Une task devient une future gérable : on peut la canceler, l’attendre avec await task, la passer à gather, ou l’observer pour les exceptions.
Lancer plusieurs tâches en parallèle logique
Exemple minimal : lancer trois worker en parallèle et attendre leurs résultats avec gather.
async def main():
t1 = asyncio.create_task(worker("A"))
t2 = asyncio.create_task(worker("B"))
t3 = asyncio.create_task(worker("C"))
results = await asyncio.gather(t1, t2, t3)
print("results", results)
Ici, create_task crée trois task, la loop exécute les coroutines de façon concurrente et gather attend les trois futures. Le temps total est proche du maximum d’un sleep individuel, pas de la somme.
Rassembler les résultats avec gather
asyncio.gather permet d’attendre plusieurs awaitables et de retourner une liste de résultats dans l’ordre des awaitables fournis. Par défaut, si une task lève une exception, gather relèvera l’exception et annulera le reste, sauf si return_exceptions=True est passé.
Quand utiliser task_queue pour organiser le flux
Pour contrôler le débit (rate limiting) ou l’ordre, on peut implémenter une task_queue qui gère un pool de workers. La pattern simple : une queue asyncio, un certain nombre de workers qui prennent des items et traitent des coroutines. Cela permet d’éviter de créer trop de task simultanées et de saturer le système.
Version synchrone bloquante avec time.sleep
import time
def blocking():
print("start")
time.sleep(2)
print("after sleep")
Ici time.sleep(2) bloque le thread principal et par conséquent toute loop éventuelle. En synchrone, trois appels avec time.sleep(2) prennent environ 6 secondes si exécutés séquentiellement.
Version asynchrone avec asyncio.sleep
async def non_blocking():
print("start")
await asyncio.sleep(2)
print("after sleep")
asyncio.sleep(2) ne bloque pas la loop ; il ne consomme pas le thread CPU pendant l’attente et la loop peut exécuter d’autres coroutines.
Comparaison du temps d’exécution
Trois tâches de 2 secondes avec time.sleep prennent environ 6,01 secondes. Les mêmes avec await asyncio.sleep(2) lancées en parallèle : ~2 secondes. time.sleep bloque la loop, asyncio.sleep rend la main.
Exemple pratique de requêtes HTTP asynchrones
I/O réseau est le cas d’usage où asyncio brille. Avec aiohttp, on peut lancer des requêtes en parallèle logique et recueillir des réponses sans créer des threads.
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
text = await resp.text()
print("fetched", url)
return text
async def main(urls):
async with aiohttp.ClientSession() as session:
tasks = [asyncio.create_task(fetch(session, u)) for u in urls]
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
Créer plusieurs appels en parallèle
Créer des task pour chaque appel HTTP permet à la loop de gérer les I/O en parallèle. Les task restent gérables : on peut cancel, monitorer exceptions, appliquer timeout par task.
Lire et exploiter le résultat
gather renvoie une liste de réponses. Si une task rencontre une exception, return_exceptions=True permet de récupérer l’exception comme résultat et de continuer à collecter les autres réponses.
Pourquoi ce cas d’usage améliore la performance
Les requêtes HTTP passent la majorité du temps en attente réseau. await sur l’I/O n’occupe pas la CPU et la loop peut multiplexe plusieurs sockets. L’effet concret : latence per-URL inchangée, mais temps total de l’ensemble réduit.
Capturer une exception dans une coroutine
La gestion d’exception est identique à la gestion classique : on entoure l’await d’un try/except. Si une task lève une exception et qu’on attend la task avec await task, l’exception remonte à l’appelant.
async def fragile():
try:
await asyncio.sleep(0.1)
raise ValueError("boom")
except Exception as e:
print("caught", e)
return "handled"
Utiliser timeout pour limiter l’attente
asyncio.wait_for permet d’appliquer un timeout à un awaitable. Si le timeout est dépassé, une asyncio.TimeoutError est levée. Cela évite d’attendre indéfiniment une future bloquée.
Que se passe-t-il avec une task annulée
task.cancel() envoie une CancelledError dans la coroutine. Un try/except asyncio.CancelledError autour du cleanup évite de laisser des ressources en rade. gather propage ces annulations selon ses paramètres.
Coroutine vs fonction classique
def f(): s’exécute et renvoie. async def f(): renvoie un objet coroutine qui ne fera rien tant qu’on ne l’await pas (ou qu’on n’en fait pas une task). Un awaitable, c’est tout ce qui se glisse derrière await : coroutine, future, task. Une task, c’est la version exécutable d’une coroutine sur la loop, avec un état, cancel(), result() et consorts.
Erreurs fréquentes et bonnes pratiques avec async/await
Oublier await : appeler une coroutine sans await renvoie un objet coroutine non exécuté. Résultat : warnings coroutine was never awaited et futures qui se baladent dans la nature.
Bloquer la loop avec time.sleep dans une coroutine : toute la loop se fige, tous les workers se figent avec elle. await asyncio.sleep à la place.
Ignorer CancelledError ou TimeoutError : les sessions aiohttp et connexions DB restent ouvertes. Un finally qui ferme proprement évite les leaks.
Confondre async et multithreading : async, c’est de la concurrence logique, pas du parallélisme CPU. Pour du calcul intensif, on passe à multiprocessing.
Autres réflexes : une queue pour limiter le nombre de task concurrentes, asyncio.Semaphore pour le throttling, asyncio.run plutôt que de jouer avec la loop à la main.
Questions fréquentes
Quand utiliser async et await en Python ?
Pour des opérations I/O concurrentes : requêtes réseau, accès base, sockets. Si l’application passe beaucoup de temps en attente, async réduit le temps total.
Quelle est la différence entre coroutine et task ?
Une coroutine est un objet awaitable créé par async def. Une task exécute une coroutine sur la loop et expose une future avec des méthodes pour cancel, result, add_done_callback.
Pourquoi asyncio est-il important ?
asyncio est la bibliothèque standard qui fournit la loop, les futures et les outils pour écrire du code asynchrone en python. Il rend possible la concurrence d’I/O sans multithreading.
Comment exécuter un programme asynchrone ?
Démarrez la boucle avec asyncio.run(votre_coroutine()), ou créez des task avec asyncio.create_task et coordonnez-les avec await, gather ou wait.
Le code async pondu par un copilot mérite une relecture sérieuse, on l’a détaillé sur /securite-code-genere-par-ia/. Quand l’API que tu exposes se fait crawler agressivement, l’angle budget de crawl est traité sur /crawl-budget-optimiser-grand-site/. Et pour une migration synchrone → async dans un audit plus large, la checklist est là : /audit-seo-technique-checklist/.
Votre recommandation sur python async/await
Quelques questions rapides pour adapter la recommandation à votre cas.