---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# Programmation asynchrone

```{code-cell} python
:tags: [hide-input]

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*).

```{prf:definition} Boucle d'événements
:label: definition-19-01
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.

```{prf:definition} Coroutine
:label: definition-19-02
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.
```

```{code-cell} python
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())
```

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.

```{code-cell} python
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.

```{code-cell} python
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.

```{code-cell} python
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())
```

```{prf:definition} Primitives asyncio de synchronisation
:label: definition-19-03
- **`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.
```

```{code-cell} python
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

```python
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 :

```python
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`

```python
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`.

```{code-cell} python
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).

```python
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

```{prf:remark}
:label: remark-19-01
**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.

```{code-cell} python
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())
```

```{code-cell} python
:tags: [hide-input]

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`.
