Programmation asynchrone avec asyncio
Objectifs
À la fin de cette séance, vous serez capables de :
- Expliquer pourquoi un programme qui attend perd du temps
- Écrire une coroutine avec
async defet l’utiliser avecawait - Lancer plusieurs tâches en même temps avec
asyncio.gather - Expliquer la différence entre une coroutine et un thread
- Éviter les deux pièges classiques : oublier
awaitet utilisertime.sleep() - Gérer un capteur qui ne répond pas avec un timeout
Introduction
Un programme passe souvent plus de temps à attendre qu’à calculer : une réponse du réseau, la lecture d’un capteur, l’accès à une base de données. Pendant ce temps, le programme ne fait rien d’autre.
Imaginez un cuisinier qui prépare trois plats :
- Approche séquentielle : il attend que le premier plat soit cuit avant de lancer le deuxième
- Approche asynchrone : il lance les trois cuissons, puis s’occupe de chaque plat dès qu’il est prêt
La programmation asynchrone consiste à faire la deuxième approche. Elle ne rend pas un programme plus rapide en général : elle aide quand le programme attend.
1. Le problème : attendre en séquence
Ce programme lit trois capteurs l’un après l’autre. Chaque lecture est simulée par une attente (time.sleep) et renvoie une valeur.
import time
def lire_capteur(nom, duree):
print(f"{nom} : démarrage")
time.sleep(duree)
print(f"{nom} : terminé")
return 20 + duree # valeur simulée
debut = time.perf_counter()
lire_capteur("T1", 2)
lire_capteur("T2", 1)
lire_capteur("T3", 3)
print(f"Durée totale : {time.perf_counter() - debut:.1f} s")
Résultat :
T1 : démarrage
T1 : terminé
T2 : démarrage
T2 : terminé
T3 : démarrage
T3 : terminé
Durée totale : 6.0 s
Les trois lectures ne dépendent pas les unes des autres, pourtant la durée totale est la somme des attentes : 2 + 1 + 3 = 6 secondes.
2. Les coroutines : async def et await
Une coroutine est une fonction qui peut être mise en pause pendant qu’elle attend. On la définit avec async def. Quand elle rencontre un await, elle se met en pause et rend la main : le programme peut alors faire avancer autre chose, puis revient à elle quand l’attente est terminée.
Pour le voir, ajoutons une deuxième coroutine, battement(), qui affiche « tic » toutes les 0,5 seconde pendant que le capteur est lu.
import asyncio
async def lire_capteur(nom, duree):
print(f"{nom} : démarrage")
await asyncio.sleep(duree) # pause : on rend la main
print(f"{nom} : terminé")
return 20 + duree
async def battement():
for i in range(4):
print("tic")
await asyncio.sleep(0.5) # pause : on rend la main
async def main():
tache_fond = asyncio.create_task(battement()) # battement() tourne en tâche de fond
valeur = await lire_capteur("T1", 2) # pendant cette attente, les "tic" continuent
print(f"Valeur lue : {valeur}")
asyncio.run(main())
Résultat :
T1 : démarrage
tic
tic
tic
tic
T1 : terminé
Valeur lue : 22
Les « tic » s’affichent pendant la lecture du capteur : à chaque await asyncio.sleep(...), une coroutine rend la main et l’autre en profite.
À retenir :
async defdéfinit une coroutineawaitsignifie : « attends ce résultat, et pendant ce temps, laisse le programme faire autre chose »awaitne peut s’utiliser que dans une fonctionasync defasyncio.run(main())démarre le programme : c’est le point d’entrée, on l’écrit une seule foisasyncio.create_task(...)lance une coroutine en tâche de fond, sans l’attendre tout de suiteasyncio.sleep()est la version asynchrone detime.sleep(): elle simule une attente
Sans
battement(), ce programme ne serait pas plus rapide que la version avectime.sleep(): avec une seule coroutine, il n’y a rien d’autre à faire pendant l’attente. L’intérêt deawaitapparaît dès qu’il y a plusieurs choses en cours.
Cas classique de bug : oublier await
async def main():
valeur = lire_capteur("T1", 2) # await oublié
print(f"Valeur lue : {valeur}")
Ce code ne plante pas, mais n’affiche pas ce qu’on attend :
Valeur lue : <coroutine object lire_capteur at 0x7f0253301150>
RuntimeWarning: coroutine 'lire_capteur' was never awaited
Appeler une coroutine sans await ne l’exécute pas : on obtient seulement un objet coroutine. Si vous voyez was never awaited, il manque un await.
3. Faire plusieurs choses en même temps : asyncio.gather
asyncio.gather lance plusieurs coroutines ensemble et attend qu’elles soient toutes terminées.
import asyncio
import time
async def lire_capteur(nom, duree):
print(f"{nom} : démarrage")
await asyncio.sleep(duree)
print(f"{nom} : terminé")
return 20 + duree
async def main():
debut = time.perf_counter()
resultats = await asyncio.gather(
lire_capteur("T1", 2),
lire_capteur("T2", 1),
lire_capteur("T3", 3),
)
print(resultats)
print(f"Durée totale : {time.perf_counter() - debut:.1f} s")
asyncio.run(main())
Résultat :
T1 : démarrage
T2 : démarrage
T3 : démarrage
T2 : terminé
T1 : terminé
T3 : terminé
[22, 21, 23]
Durée totale : 3.0 s
Observez trois choses :
- Les trois capteurs démarrent presque en même temps
- Ils terminent dans l’ordre de leur durée (T2, T1, T3)
- La liste de résultats est dans l’ordre des arguments (T1, T2, T3), pas dans l’ordre d’arrivée
| Version | Durée | Pourquoi |
|---|---|---|
await l’un après l’autre |
6 s | la somme des attentes |
asyncio.gather |
3 s | la plus longue des attentes |
Il n’y a pas de vrai parallélisme : le programme exécute une seule chose à la fois, mais dès qu’une tâche attend, il passe à une autre. La différence avec les threads est expliquée un peu plus bas.
Le piège de time.sleep()
Que se passe-t-il si on garde time.sleep() dans une coroutine ?
async def lire_capteur(nom, duree):
print(f"{nom} : démarrage")
time.sleep(duree) # bloquant !
print(f"{nom} : terminé")
return 20 + duree
T1 : démarrage
T1 : terminé
T2 : démarrage
T2 : terminé
T3 : démarrage
T3 : terminé
[22, 21, 23]
Durée totale : 6.0 s
On retrouve 6 secondes. time.sleep() bloque tout le programme : aucune autre tâche ne peut avancer pendant ce temps. Dans une coroutine, utilisez toujours await asyncio.sleep() ou une bibliothèque conçue pour l’asynchrone.
Coroutines et threads : quelle différence ?
Vous avez peut-être déjà entendu parler des threads (fils d’exécution). Ils répondent au même besoin, faire plusieurs choses en même temps, mais fonctionnent très différemment. Dans notre cuisine :
- avec des coroutines, il y a un seul cuisinier qui passe d’un plat à l’autre quand l’un d’eux attend
- avec des threads, il y a plusieurs cuisiniers dans la même cuisine
Voici nos trois capteurs avec des threads :
import threading
import time
def lire_capteur(nom, duree):
print(f"{nom} : démarrage")
time.sleep(duree)
print(f"{nom} : terminé")
debut = time.perf_counter()
fils = []
for nom, duree in [("T1", 2), ("T2", 1), ("T3", 3)]:
fil = threading.Thread(target=lire_capteur, args=(nom, duree))
fil.start()
fils.append(fil)
for fil in fils:
fil.join()
print(f"Durée totale : {time.perf_counter() - debut:.1f} s")
T1 : démarrage
T2 : démarrage
T3 : démarrage
T2 : terminé
T1 : terminé
T3 : terminé
Durée totale : 3.0 s
Le résultat est le même : 3 secondes. Mais regardez le code : une fonction ordinaire, sans async ni await, et time.sleep() ne pose plus de problème, car il ne bloque que son thread. start() lance le thread, join() attend qu’il soit terminé.
Coroutine (asyncio) |
Thread (threading) |
|
|---|---|---|
| Fils d’exécution | un seul | plusieurs |
| Qui décide de changer de tâche ? | le programme, à chaque await |
le système d’exploitation, à n’importe quel moment |
time.sleep() |
bloque tout le programme | ne bloque que son thread |
| Écriture | async def et await partout |
fonctions ordinaires |
| Poids | très léger : on peut en lancer des milliers | plus lourd : quelques dizaines |
| Risque principal | oublier un await |
deux threads qui modifient la même variable en même temps |
Le dernier point est important. Comme le système peut interrompre un thread n’importe où, deux threads qui modifient la même variable peuvent produire un résultat faux, et ce genre de bug est difficile à reproduire. Avec des coroutines, le changement de tâche n’a lieu qu’aux await : le code est plus prévisible.
En Python standard, un verrou appelé GIL empêche deux threads d’exécuter du code Python au même instant. Les threads, comme asyncio, aident donc quand le programme attend, mais pas pour accélérer un calcul lourd. Pour répartir un calcul sur plusieurs cœurs, on utilise multiprocessing (plusieurs processus).
| Besoin | Outil |
|---|---|
| Beaucoup d’attentes, avec des bibliothèques asynchrones | asyncio |
| Attentes avec du code ou une bibliothèque non asynchrone | threading |
| Calculs lourds sur plusieurs cœurs | multiprocessing |
4. Quand utiliser asyncio ?
| Situation | asyncio utile ? |
Pourquoi |
|---|---|---|
| Interroger plusieurs capteurs ou serveurs | Oui | on attend des réponses |
| Faire plusieurs requêtes web ou appels d’API | Oui | on attend le réseau |
| Calcul lourd (traitement d’image, grosse boucle) | Non | le processeur travaille, il n’attend pas |
| Script qui lit un seul fichier | Inutile | rien d’autre à faire en même temps |
Vous croiserez l’asynchrone dans des outils réels : Home Assistant, les bibliothèques réseau comme aiohttp, le Bluetooth avec bleak, le MQTT avec aiomqtt, ou les serveurs web FastAPI.
5. Gérer un capteur qui ne répond pas : le timeout
Dans la vraie vie, un capteur peut ne jamais répondre. Sans protection, le programme attendrait indéfiniment.
asyncio.wait_for(coroutine, timeout) limite l’attente. Si le délai est dépassé, elle lève une erreur asyncio.TimeoutError que l’on attrape avec try / except, comme dans l’exemple de gestion d’erreur de la séance 2.
import asyncio
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
async def lire_capteur(nom, duree):
await asyncio.sleep(duree)
return 20 + duree
async def lire_capteur_securise(nom, duree, timeout=2):
try:
return await asyncio.wait_for(lire_capteur(nom, duree), timeout)
except asyncio.TimeoutError:
logger.warning(f"{nom} : pas de réponse après {timeout} s")
return None
async def main():
resultats = await asyncio.gather(
lire_capteur_securise("T1", 1),
lire_capteur_securise("T2", 1.5),
lire_capteur_securise("T3", 10),
)
print(resultats)
valides = []
for valeur in resultats:
if valeur is not None:
valides.append(valeur)
logger.info(f"Moyenne sur {len(valides)} capteurs : {sum(valides) / len(valides):.1f}")
asyncio.run(main())
Résultat :
2026-11-02 20:13:54,693 - WARNING - T3 : pas de réponse après 2 s
[21, 21.5, None]
2026-11-02 20:13:54,694 - INFO - Moyenne sur 2 capteurs : 21.2
Deux points importants :
- Le programme ne dure que 2 secondes, pas 10 : le timeout a coupé l’attente de T3
- Le capteur absent est écarté de la moyenne (
None), et non compté comme0: un0fausserait le résultat
6. Pour aller plus loin (optionnel)
Cette partie n’est pas couverte en classe : à explorer en autonomie si vous finissez les exercices en avance, ou après la séance.
Pour continuer
asyncio.create_tasken détail : récupérer le résultat d’une tâche de fond, ou l’annulerasyncio.Queue: faire communiquer plusieurs tâches entre ellesasync withetasync for: les versions asynchrones dewithetfor- Une vraie requête web asynchrone avec
aiohttpouhttpx
TD
Le TD de la séance est disponible ici.