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 def et l’utiliser avec await
  • 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 await et utiliser time.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 def définit une coroutine
  • await signifie : « attends ce résultat, et pendant ce temps, laisse le programme faire autre chose »
  • await ne peut s’utiliser que dans une fonction async def
  • asyncio.run(main()) démarre le programme : c’est le point d’entrée, on l’écrit une seule fois
  • asyncio.create_task(...) lance une coroutine en tâche de fond, sans l’attendre tout de suite
  • asyncio.sleep() est la version asynchrone de time.sleep() : elle simule une attente

Sans battement(), ce programme ne serait pas plus rapide que la version avec time.sleep() : avec une seule coroutine, il n’y a rien d’autre à faire pendant l’attente. L’intérêt de await apparaî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é comme 0 : un 0 fausserait 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_task en détail : récupérer le résultat d’une tâche de fond, ou l’annuler
  • asyncio.Queue : faire communiquer plusieurs tâches entre elles
  • async with et async for : les versions asynchrones de with et for
  • Une vraie requête web asynchrone avec aiohttp ou httpx

TD

Le TD de la séance est disponible ici.


This site uses Just the Docs, a documentation theme for Jekyll.