Programmation asynchrone#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

Le problème des I/O bloquants#

La quasi-totalité des programmes réels passent la majorité de leur temps à attendre : attendre qu’une requête HTTP reçoive une réponse, qu’une requête SQL se termine, qu’un fichier soit lu depuis le disque, qu’un message arrive sur un socket réseau. Dans un modèle d’exécution classique (synchrone et mono-thread), ce temps d’attente est du temps perdu : le fil d’exécution est bloqué, il ne peut rien faire d’autre.

La solution habituelle est le multithreading : créer un thread par tâche pour que plusieurs attentes se déroulent en parallèle. Mais cette approche a ses limites. Les threads sont coûteux en mémoire (chaque thread consomme une pile de plusieurs mégaoctets) et la coordination entre eux — verrous, conditions, sections critiques — est source de bugs difficiles à reproduire. De plus, à cause du GIL (que nous verrons au chapitre suivant), plusieurs threads Python ne peuvent pas exécuter du bytecode Python simultanément.

La programmation asynchrone propose une alternative élégante : un seul thread d’exécution, mais qui peut suspendre une tâche en attente d’I/O et reprendre une autre tâche pendant ce temps. L’ordonnancement est coopératif : les tâches elles-mêmes indiquent les points où elles acceptent d’être suspendues (avec le mot-clé await). Le composant qui gère cet ordonnancement s’appelle la boucle d’événements (event loop).

Définition 35 (Boucle d’événements)

La boucle d’événements (event loop) est le composant central de la programmation asynchrone. Elle maintient une file de tâches prêtes à s’exécuter. Quand une tâche atteint un point d’attente (await), elle se suspend et rend le contrôle à la boucle. La boucle vérifie alors quelles opérations I/O sont terminées (via select, epoll ou kqueue selon l’OS), marque les tâches correspondantes comme prêtes, et reprend leur exécution. Ce cycle se répète aussi longtemps qu’il y a des tâches en cours.

Coroutines#

Une coroutine est une fonction qui peut être suspendue et reprise. En Python, on la définit avec async def. Une coroutine retourne un objet coroutine qui n’est pas exécuté immédiatement : il doit être attendu avec await, ou planifié par la boucle d’événements.

Définition 36 (Coroutine)

Une coroutine est définie avec async def. Appelée, elle retourne un objet coroutine. Le mot-clé await expr suspend la coroutine courante jusqu’à ce que expr (qui doit être un awaitable — coroutine, Task ou Future) soit terminée, puis reprend l’exécution et fournit le résultat. Contrairement aux générateurs qui utilisent yield, await est conçu spécifiquement pour l’asynchronisme.

import asyncio

async def saluer(nom: str, delai: float) -> str:
    print(f"  Début : saluer({nom})")
    await asyncio.sleep(delai)   # suspend la coroutine, libère la boucle
    message = f"Bonjour, {nom} !"
    print(f"  Fin   : saluer({nom})")
    return message

async def principale():
    # Exécution séquentielle (attend la fin de chaque appel)
    msg1 = await saluer("Alice", 0.1)
    msg2 = await saluer("Bob",   0.1)
    print(msg1)
    print(msg2)

asyncio.run(principale())
---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
Cell In[2], line 17
     14     print(msg1)
     15     print(msg2)
---> 17 asyncio.run(principale())

File /usr/lib/python3.13/asyncio/runners.py:191, in run(main, debug, loop_factory)
    161 """Execute the coroutine and return the result.
    162 
    163 This function runs the passed coroutine, taking care of
   (...)    187     asyncio.run(main())
    188 """
    189 if events._get_running_loop() is not None:
    190     # fail fast with short traceback
--> 191     raise RuntimeError(
    192         "asyncio.run() cannot be called from a running event loop")
    194 with Runner(debug=debug, loop_factory=loop_factory) as runner:
    195     return runner.run(main)

RuntimeError: asyncio.run() cannot be called from a running event loop

La différence essentielle entre une coroutine et un générateur tient à leur sémantique : un générateur yield produit des valeurs à la demande, tandis qu’une coroutine await délègue l’exécution à une autre opération asynchrone et attend son résultat. Bien qu’ils reposent sur le même mécanisme bas niveau (objets send/throw), leur usage est distinct.

asyncio#

Le module asyncio de la bibliothèque standard fournit la boucle d’événements, les primitives de haut niveau et l’outillage complet pour écrire du code asynchrone.

asyncio.run et asyncio.create_task#

asyncio.run(coro) crée une nouvelle boucle d’événements, exécute la coroutine passée en argument jusqu’à sa conclusion, puis ferme la boucle. C’est le point d’entrée standard d’un programme asynchrone.

asyncio.create_task(coro) planifie immédiatement une coroutine comme une tâche (Task) dans la boucle courante, sans attendre son résultat. Les tâches s’exécutent de façon concurrente : pendant qu’une tâche est suspendue sur await, les autres peuvent progresser.

import asyncio
import time

async def telecharger(url: str, duree: float) -> str:
    print(f"  Début téléchargement : {url}")
    await asyncio.sleep(duree)   # simule un I/O réseau
    print(f"  Fin   téléchargement : {url}")
    return f"Contenu de {url}"

async def principale_taches():
    debut = time.perf_counter()

    # Créer les tâches : elles démarrent immédiatement en concurrence
    t1 = asyncio.create_task(telecharger("https://example.com/a", 0.15))
    t2 = asyncio.create_task(telecharger("https://example.com/b", 0.10))
    t3 = asyncio.create_task(telecharger("https://example.com/c", 0.12))

    # Attendre chaque tâche dans l'ordre de création
    res1 = await t1
    res2 = await t2
    res3 = await t3

    duree = time.perf_counter() - debut
    print(f"\nDurée totale : {duree:.3f}s (séquentiel aurait pris ~0.37s)")
    return [res1, res2, res3]

resultats = asyncio.run(principale_taches())

asyncio.gather#

asyncio.gather(*coroutines) planifie toutes les coroutines concurremment et attend qu’elles soient toutes terminées, en retournant leurs résultats dans l’ordre d’entrée.

import asyncio

async def tache(nom: str, duree: float) -> str:
    await asyncio.sleep(duree)
    return f"{nom} terminé"

async def toutes_les_taches():
    resultats = await asyncio.gather(
        tache("Alpha", 0.1),
        tache("Beta",  0.05),
        tache("Gamma", 0.08),
        return_exceptions=True   # les exceptions ne propagent pas immédiatement
    )
    for r in resultats:
        print(r)

asyncio.run(toutes_les_taches())

Primitives de synchronisation#

Même en mono-thread, plusieurs coroutines peuvent accéder à une ressource partagée (une connexion à la base de données, un compteur, un fichier). asyncio fournit des primitives de synchronisation qui fonctionnent sans bloquer la boucle.

import asyncio

async def demo_lock():
    verrou = asyncio.Lock()
    compteur = {"valeur": 0}

    async def incrementer(nom: str):
        async with verrou:   # section critique
            valeur_actuelle = compteur["valeur"]
            await asyncio.sleep(0)   # point de suspension (simule un I/O)
            compteur["valeur"] = valeur_actuelle + 1
            print(f"  {nom} → compteur = {compteur['valeur']}")

    await asyncio.gather(
        incrementer("A"), incrementer("B"), incrementer("C")
    )
    print(f"Valeur finale : {compteur['valeur']}")   # Toujours 3

asyncio.run(demo_lock())

Définition 37 (Primitives asyncio de synchronisation)

  • asyncio.Lock : verrou d’exclusion mutuelle. Une seule coroutine peut détenir le verrou à la fois. Utilisez async with lock: pour l’acquisition/libération automatique.

  • asyncio.Event : drapeau binaire. Les coroutines peuvent await event.wait() jusqu’à ce qu’une autre coroutine appelle event.set().

  • asyncio.Semaphore(n) : permet à au plus n coroutines de détenir la ressource simultanément. Idéal pour limiter le nombre de connexions concurrentes.

  • asyncio.Queue : file de communication producteur/consommateur. await queue.put(item) et await queue.get() sont tous deux non bloquants pour la boucle.

import asyncio

async def demo_semaphore():
    """Limiter à 2 téléchargements simultanés."""
    semaphore = asyncio.Semaphore(2)

    async def telecharger(i: int):
        async with semaphore:
            print(f"  Téléchargement {i} en cours...")
            await asyncio.sleep(0.1)
            print(f"  Téléchargement {i} terminé")

    await asyncio.gather(*[telecharger(i) for i in range(5)])

asyncio.run(demo_semaphore())

I/O asynchrones#

asyncio brille particulièrement dans les applications à forte intensité I/O : serveurs réseau, clients HTTP, accès à des bases de données.

Sockets et connexions réseau#

import asyncio

async def client_echo():
    reader, writer = await asyncio.open_connection("localhost", 8888)
    writer.write(b"Bonjour\n")
    await writer.drain()
    data = await reader.readline()
    print(f"Reçu : {data.decode().strip()}")
    writer.close()
    await writer.wait_closed()

Fichiers avec aiofiles#

La bibliothèque aiofiles fournit des opérations de lecture/écriture de fichiers qui ne bloquent pas la boucle d’événements :

import aiofiles

async def lire_fichier(chemin: str) -> str:
    async with aiofiles.open(chemin, encoding="utf-8") as f:
        contenu = await f.read()
    return contenu

HTTP avec httpx#

import httpx
import asyncio

async def recuperer_plusieurs_urls(urls: list[str]) -> list[str]:
    async with httpx.AsyncClient() as client:
        taches = [client.get(url) for url in urls]
        reponses = await asyncio.gather(*taches)
        return [r.text for r in reponses]

Itérateurs et gestionnaires de contexte asynchrones#

Python étend les protocoles classiques à l’asynchronisme.

async for et __aiter__ / __anext__#

Un itérateur asynchrone implémente __aiter__ (qui retourne l’itérateur lui-même) et __anext__ (qui retourne un awaitable). La syntaxe async for est réservée aux contextes async def.

import asyncio

class CompteARebours:
    def __init__(self, debut: int):
        self._n = debut

    def __aiter__(self):
        return self

    async def __anext__(self) -> int:
        if self._n < 0:
            raise StopAsyncIteration
        await asyncio.sleep(0)   # point de suspension
        valeur = self._n
        self._n -= 1
        return valeur

async def demo_aiter():
    async for n in CompteARebours(4):
        print(n, end=" ")
    print()

asyncio.run(demo_aiter())

async with et __aenter__ / __aexit__#

Un gestionnaire de contexte asynchrone implémente __aenter__ et __aexit__ comme des coroutines. C’est indispensable pour les ressources dont l’acquisition ou la libération est elle-même une opération asynchrone (connexion à une base de données, session HTTP, transaction).

class ConnexionDB:
    async def __aenter__(self):
        self._conn = await ouvrir_connexion()
        return self._conn

    async def __aexit__(self, *exc_info):
        await self._conn.fermer()
        return False

async def interroger():
    async with ConnexionDB() as conn:
        résultat = await conn.executer("SELECT 1")

Bonnes pratiques#

Remarque 35

Quand utiliser async ? La programmation asynchrone est bénéfique lorsque le programme est I/O-bound : il passe plus de temps à attendre des entrées/sorties qu’à calculer. Pour les tâches CPU-bound (calcul intensif, compression, cryptographie), asyncio n’apporte rien — utilisez multiprocessing à la place. Une bonne heuristique : si vous faites plus de dix requêtes réseau ou accès disque concurrents, asyncio vous fera économiser du temps et de la mémoire par rapport aux threads.

Les pièges courants à éviter :

  • Appeler une coroutine sans await : saluer("Alice") crée un objet coroutine sans l’exécuter. Python 3.11+ émet un avertissement RuntimeWarning: coroutine 'saluer' was never awaited.

  • Appeler await hors d’une coroutine : await ne peut s’utiliser qu’à l’intérieur d’une fonction async def.

  • Bloquer la boucle avec du code synchrone : appeler time.sleep(5) au lieu de await asyncio.sleep(5) bloque l’intégralité de la boucle — aucune autre coroutine ne peut s’exécuter pendant ce temps.

  • Mélanger les bibliothèques synchrones et asynchrones : si une bibliothèque n’offre pas d’API async, utilisez loop.run_in_executor() pour exécuter les appels bloquants dans un thread pool sans bloquer la boucle.

import asyncio

async def exemple_run_in_executor():
    """Exécuter du code bloquant sans bloquer la boucle d'événements."""
    import time

    def operation_bloquante(n: int) -> int:
        time.sleep(0.05)   # opération synchrone bloquante
        return n * n

    boucle = asyncio.get_event_loop()
    resultat = await boucle.run_in_executor(None, operation_bloquante, 7)
    print(f"Résultat : {resultat}")   # 49

asyncio.run(exemple_run_in_executor())

Hide code cell source

fig, axes = plt.subplots(1, 2, figsize=(14, 7))

couleurs = sns.color_palette("muted", 6)

def dessiner_timeline(ax, titre, taches, total_temps, couleur_fond):
    ax.set_xlim(0, total_temps + 0.5)
    ax.set_ylim(-0.5, len(taches) + 0.5)
    ax.set_xlabel("Temps (unités)", fontsize=10)
    ax.set_title(titre, fontsize=13, fontweight='bold')
    ax.set_yticks(range(len(taches)))
    ax.set_yticklabels([t["nom"] for t in taches], fontsize=9)
    ax.set_facecolor(couleur_fond)
    ax.grid(axis='x', alpha=0.4)

    for i, tache in enumerate(taches):
        for segment in tache["segments"]:
            debut, fin, style = segment
            couleur = couleurs[i % len(couleurs)]
            if style == "actif":
                ax.barh(i, fin - debut, left=debut, height=0.5,
                        color=couleur, alpha=0.85, edgecolor='white', lw=0.8)
            else:  # attente
                ax.barh(i, fin - debut, left=debut, height=0.5,
                        color=couleur, alpha=0.2, edgecolor=couleur,
                        lw=1.5, linestyle='--')

# --- Modèle bloquant (séquentiel) ---
ax = axes[0]
taches_bloquant = [
    {"nom": "Tâche A", "segments": [(0, 1, "actif"), (1, 4, "attente"), (4, 5, "actif")]},
    {"nom": "Tâche B", "segments": [(5, 6, "actif"), (6, 9, "attente"), (9, 10, "actif")]},
    {"nom": "Tâche C", "segments": [(10, 11, "actif"), (11, 13, "attente"), (13, 14, "actif")]},
]
dessiner_timeline(ax, "Bloquant (séquentiel)", taches_bloquant, 14.5, "#fef9f9")
ax.axvline(14, color='#c0392b', lw=2, linestyle=':', label="Fin totale")
ax.text(14.2, len(taches_bloquant)/2, "t=14", color='#c0392b', fontsize=9,
        fontweight='bold', va='center')

# --- Modèle asyncio ---
ax = axes[1]
taches_async = [
    {"nom": "Tâche A", "segments": [(0, 1, "actif"), (1, 4, "attente"), (4, 5, "actif")]},
    {"nom": "Tâche B", "segments": [(1, 2, "actif"), (2, 4, "attente"), (4, 5, "actif")]},
    {"nom": "Tâche C", "segments": [(2, 3, "actif"), (3, 4, "attente"), (4, 5, "actif")]},
]
dessiner_timeline(ax, "asyncio (concurrent)", taches_async, 14.5, "#f9fef9")
ax.axvline(5, color='#27ae60', lw=2, linestyle=':', label="Fin totale")
ax.text(5.2, len(taches_async)/2, "t=5", color='#27ae60', fontsize=9,
        fontweight='bold', va='center')

# Légende commune
import matplotlib.patches as mpatches
legende = [
    mpatches.Patch(facecolor='grey', alpha=0.85, label="Exécution active"),
    mpatches.Patch(facecolor='grey', alpha=0.2, linestyle='--',
                   edgecolor='grey', linewidth=1.5, label="Attente I/O"),
]
fig.legend(handles=legende, loc='lower center', ncol=2,
           fontsize=10, frameon=True, bbox_to_anchor=(0.5, -0.02))

fig.suptitle(
    "Boucle d'événements asyncio : bloquant vs non-bloquant",
    fontsize=14, fontweight='bold'
)
plt.tight_layout(rect=[0, 0.05, 1, 1])
plt.show()

Résumé#

Dans ce chapitre, nous avons exploré la programmation asynchrone en Python :

  • Les I/O bloquants paralysent un thread entier pendant l’attente. La boucle d’événements d”asyncio permet à un seul thread de gérer des milliers d’opérations I/O concurrentes en suspendant et reprenant les coroutines.

  • Une coroutine se définit avec async def et se suspend avec await. Elle ne s’exécute qu’au sein d’une boucle d’événements, démarrée avec asyncio.run().

  • asyncio.create_task et asyncio.gather permettent de lancer plusieurs tâches concurrentes et d’attendre leurs résultats.

  • Les primitives de synchronisation (Lock, Event, Semaphore, Queue) protègent les ressources partagées entre coroutines sans bloquer la boucle.

  • Les protocoles asynchrones (async for, async with) étendent les protocoles classiques aux ressources dont l’acquisition ou la libération est asynchrone.

  • La règle d’or : asyncio pour les programmes I/O-bound, multiprocessing pour les programmes CPU-bound, et loop.run_in_executor() pour appeler du code synchrone bloquant depuis une coroutine.

Dans le chapitre suivant, nous abordons la concurrence et le parallélisme : le GIL, threading, multiprocessing et concurrent.futures.