Python asyncio : async et await
Apprenez Python asyncio : coroutines, boucle d'événements, tâches, gather, délais et files — avec des exemples exécutables et des explications claires.
Le module asyncio de Python vous permet d'écrire du code concurrent dans un seul thread grâce aux mots-clés async et await. Au lieu de bloquer en attendant des réponses réseau ou des lectures de fichiers, un programme asyncio suspend la tâche en attente et passe immédiatement à un autre travail — reprenant l'exécution quand le résultat est prêt. C'est l'outil idéal pour les programmes liés aux entrées/sorties tels que les scrapers web, les clients API et les serveurs de messagerie.
Ce chapitre couvre :
- Ce que sont les fonctions
async(coroutines) et comment elles diffèrent des fonctions ordinaires - La boucle d'événements et la façon dont asyncio planifie le travail
- L'attente de résultats, l'exécution de tâches en parallèle avec
asyncio.gatheretasyncio.create_task - La gestion des exceptions et des délais d'attente dans le code async
- L'
asyncio.Queuepour les modèles producteur-consommateur - Quand utiliser asyncio et quand préférer le threading
Pourquoi asyncio existe
Considérez un programme qui appelle deux API l'une après l'autre :
import time
def fetch(name, delay):
time.sleep(delay) # blocks the whole program
return f'data from {name}'
start = time.perf_counter()
r1 = fetch('API A', 1)
r2 = fetch('API B', 1)
print(f'Done in {time.perf_counter() - start:.1f}s')
# Done in 2.0sLes deux appels s'exécutent séquentiellement — 2 secondes au total, alors que chaque appel ne nécessite qu'1 seconde d'attente. Avec asyncio, le programme met en pause fetch('API A', ...) pendant qu'il attend, démarre immédiatement fetch('API B', ...), et les deux se terminent en environ 1 seconde :
import asyncio
import time
async def fetch(name, delay):
await asyncio.sleep(delay) # suspends only this coroutine
return f'data from {name}'
async def main():
start = time.perf_counter()
r1, r2 = await asyncio.gather(fetch('API A', 1), fetch('API B', 1))
print(f'Done in {time.perf_counter() - start:.1f}s')
# Done in 1.0s
asyncio.run(main())Coroutines : async def et await
Une fonction définie avec async def est appelée une fonction coroutine. L'appeler n'exécute pas immédiatement le corps — elle renvoie un objet coroutine qui doit être piloté par la boucle d'événements.
async def greet(name):
print(f'Hello, {name}!')
# Calling it returns a coroutine object, nothing is printed yet
coro = greet('World')
print(type(coro)) # <class 'coroutine'>
# Run it properly
import asyncio
asyncio.run(greet('World'))
# Hello, World!À l'intérieur d'une coroutine, await suspend l'exécution jusqu'à ce que l'objet awaitable (une autre coroutine, une Task ou un Future) produise un résultat. La boucle d'événements est libre d'exécuter d'autres coroutines pendant qu'une est suspendue.
import asyncio
async def step_one():
print('Step 1: start')
await asyncio.sleep(1) # suspend for 1 second
print('Step 1: end')
return 'result-1'
async def main():
value = await step_one() # wait for step_one to finish
print(value)
asyncio.run(main())
# Step 1: start
# Step 1: end
# result-1Ce que vous pouvez attendre avec await
- Une autre coroutine
async def - Un
asyncio.Task(créé avecasyncio.create_task) - Un
asyncio.Future - Tout objet possédant une méthode
__await__
Vous ne pouvez pas utiliser await en dehors d'une fonction async def.
La boucle d'événements
La boucle d'événements est le planificateur d'asyncio. Elle maintient une file de coroutines et de tâches, exécute chacune jusqu'à ce qu'elle atteigne un await, puis passe à l'élément suivant prêt à s'exécuter. Il y a généralement une boucle d'événements par thread.
asyncio.run(coro) est le point d'entrée standard pour les programmes asyncio. Il crée une nouvelle boucle d'événements, exécute la coroutine donnée jusqu'à sa complétion, ferme la boucle et retourne le résultat :
import asyncio
async def compute():
await asyncio.sleep(0) # yield control once
return 6 * 7
result = asyncio.run(compute())
print(result) # 42Pour la plupart des applications, vous n'avez jamais besoin de gérer la boucle directement — asyncio.run gère la création et la destruction.
Exécuter des tâches en parallèle
asyncio.gather
asyncio.gather(*coroutines) planifie l'exécution simultanée de toutes les coroutines fournies et retourne leurs résultats dans le même ordre :
import asyncio
async def fetch_data(name, delay):
print(f'Start fetching {name}')
await asyncio.sleep(delay)
print(f'Done fetching {name}')
return f'data from {name}'
async def main():
results = await asyncio.gather(
fetch_data('API A', 1),
fetch_data('API B', 2),
fetch_data('API C', 1),
)
print(results)
asyncio.run(main())
# Start fetching API A
# Start fetching API B
# Start fetching API C
# Done fetching API A
# Done fetching API C
# Done fetching API B
# ['data from API A', 'data from API B', 'data from API C']Les trois coroutines démarrent immédiatement. Le temps total écoulé correspond à la coroutine la plus lente (2 s), pas à la somme (4 s).
asyncio.create_task
asyncio.create_task(coro) enveloppe une coroutine dans une Task et la planifie pour s'exécuter prochainement. Contrairement à gather, la création d'une tâche la lance en arrière-plan pendant que la coroutine courante continue de s'exécuter :
import asyncio
async def background_job(name, delay):
print(f'{name}: start')
await asyncio.sleep(delay)
print(f'{name}: end')
return f'{name} done'
async def main():
t1 = asyncio.create_task(background_job('Task A', 1))
t2 = asyncio.create_task(background_job('Task B', 2))
# Both tasks are already scheduled; await collects their results
result1 = await t1
result2 = await t2
print(result1, result2)
asyncio.run(main())
# Task A: start
# Task B: start
# Task A: end
# Task B: end
# Task A done Task B doneUtilisez create_task lorsque vous souhaitez qu'une tâche démarre immédiatement et que vous prévoyez de récupérer son résultat (ou de l'annuler) plus tard. Utilisez gather lorsque vous voulez lancer un groupe fixe de coroutines et attendre qu'elles se terminent toutes ensemble.
Sortie entrelacée
Une façon utile de voir la boucle d'événements en action est d'observer comment les tâches s'entrelacent :
import asyncio
async def count_down(name, seconds):
for i in range(seconds, 0, -1):
print(f'{name}: {i}')
await asyncio.sleep(1)
print(f'{name}: done!')
async def main():
await asyncio.gather(
count_down('Task A', 3),
count_down('Task B', 2),
)
asyncio.run(main())
# Task A: 3
# Task B: 2
# Task A: 2
# Task B: 1
# Task A: 1
# Task B: done!
# Task A: done!Les deux tâches partagent un seul thread ; la boucle d'événements alterne entre elles à chaque await asyncio.sleep(1).
Gestion des exceptions
Les exceptions levées dans une coroutine se propagent via await exactement comme dans le code synchrone. Utilisez un bloc try/except classique :
import asyncio
async def risky_task():
await asyncio.sleep(0.1)
raise ValueError('something went wrong')
async def main():
try:
await risky_task()
except ValueError as e:
print(f'Caught: {e}')
asyncio.run(main())
# Caught: something went wrongLors de l'utilisation d'asyncio.gather, si une coroutine lève une exception, les autres ne sont pas annulées par défaut, mais l'exception est relancée quand vous await l'appel à gather. Passez return_exceptions=True pour collecter les exceptions comme valeurs de retour à la place :
import asyncio
async def good():
return 'ok'
async def bad():
raise RuntimeError('oops')
async def main():
results = await asyncio.gather(good(), bad(), return_exceptions=True)
for r in results:
if isinstance(r, Exception):
print(f'Error: {r}')
else:
print(f'Result: {r}')
asyncio.run(main())
# Result: ok
# Error: oopsDélais d'attente avec asyncio.wait_for
asyncio.wait_for(coro, timeout) exécute une coroutine et l'annule si elle ne se termine pas dans le nombre de secondes imparti, en levant asyncio.TimeoutError :
import asyncio
async def slow_operation():
await asyncio.sleep(5)
return 42
async def main():
try:
result = await asyncio.wait_for(slow_operation(), timeout=1.0)
print(result)
except asyncio.TimeoutError:
print('Timed out — operation cancelled')
asyncio.run(main())
# Timed out — operation cancelledC'est important pour le code réseau en production où un serveur bloqué bloquerait autrement une tâche indéfiniment.
asyncio.Queue pour les modèles producteur-consommateur
asyncio.Queue est une file thread-safe et compatible async. Elle est idéale pour découpler les producteurs (code qui génère du travail) des consommateurs (code qui le traite) :
import asyncio
async def producer(queue):
for i in range(1, 4):
print(f'Produced item {i}')
await queue.put(i)
await asyncio.sleep(0.1)
await queue.put(None) # sentinel to signal consumers to stop
async def consumer(queue):
while True:
item = await queue.get()
if item is None:
break
print(f'Consumed item {item}')
async def main():
q = asyncio.Queue()
await asyncio.gather(producer(q), consumer(q))
asyncio.run(main())
# Produced item 1
# Consumed item 1
# Produced item 2
# Consumed item 2
# Produced item 3
# Consumed item 3Pour plusieurs consommateurs, utilisez queue.task_done() et queue.join() pour savoir quand tous les éléments ont été traités.
asyncio vs threading
asyncio et le module threading de Python permettent tous deux d'effectuer du travail en parallèle, mais de manière différente :
| asyncio | threading | |
|---|---|---|
| Modèle de concurrence | Coopératif (les coroutines cèdent le contrôle à await) | Préemptif (l'OS commute les threads) |
| Idéal pour | De nombreuses tâches liées aux E/S (réseau, disque) | Tâches liées aux E/S utilisant des bibliothèques bloquantes |
| Travail lié au CPU | Peu utile — toujours un seul thread | Peu utile — le GIL limite le vrai parallélisme |
| Surcharge | Très faible (pas de threads OS) | Plus élevée (chaque thread utilise des ressources OS) |
| État partagé | Sûr dans une boucle d'événements | Nécessite des verrous pour éviter les courses aux données |
Utilisez asyncio lorsque vous contrôlez le code d'E/S et pouvez utiliser des bibliothèques compatibles async (par ex. aiohttp, asyncpg). Utilisez threading lorsque vous vous appuyez sur des bibliothèques bloquantes tierces qui ne peuvent pas être rendues async.
Pour le vrai parallélisme CPU, tournez-vous plutôt vers multiprocessing ou concurrent.futures.ProcessPoolExecutor.
Pièges courants
Oublier await : Appeler une fonction async sans await retourne un objet coroutine et ne fait rien. Python émet un RuntimeWarning: coroutine '...' was never awaited pour aider à détecter cette erreur.
async def main():
asyncio.sleep(1) # BUG: returns a coroutine, does not sleep
await asyncio.sleep(1) # correctBloquer la boucle d'événements : L'exécution de code synchrone lent (une boucle intensive, un appel réseau bloquant, time.sleep) dans une coroutine gèle toute la boucle d'événements. Enveloppez les appels bloquants avec asyncio.to_thread (Python 3.9+) pour les exécuter dans un pool de threads sans bloquer :
import asyncio
import time
def blocking_task():
time.sleep(2) # simulates a slow blocking operation
return 'done'
async def main():
result = await asyncio.to_thread(blocking_task)
print(result)
asyncio.run(main())
# doneUtiliser asyncio.run dans une boucle déjà en cours : Les notebooks Jupyter exécutent déjà une boucle d'événements. Utilisez directement await coro dans les cellules du notebook, ou installez nest_asyncio pour autoriser les boucles imbriquées.
Récapitulatif rapide
| Modèle | Quand l'utiliser |
|---|---|
asyncio.run(main()) | Démarrer la boucle d'événements depuis du code synchrone |
await coro | Exécuter une coroutine et attendre son résultat |
asyncio.gather(*coros) | Exécuter plusieurs coroutines en parallèle, collecter tous les résultats |
asyncio.create_task(coro) | Planifier une coroutine comme tâche en arrière-plan |
asyncio.wait_for(coro, timeout=N) | Ajouter une limite de temps à une coroutine |
asyncio.Queue | Découpler les producteurs des consommateurs |
asyncio.to_thread(fn) | Exécuter une fonction bloquante sans geler la boucle |