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

# UDP : rapidité et multicast

UDP (User Datagram Protocol) est le protocole de transport frugal d'Internet. Défini dans la RFC 768 (1980), il tient en moins d'une page : pas de connexion, pas d'accusé de réception, pas de reordering — juste l'envoi brut de datagrammes. Cette apparente pauvreté est en réalité sa plus grande force : UDP est le choix de prédilection partout où la latence prime sur la fiabilité — streaming vidéo, jeux en ligne, VoIP, DNS, NTP.

```{admonition} Objectifs du chapitre
:class: note
- Comprendre la structure minimale du datagramme UDP
- Savoir choisir entre UDP et TCP selon le cas d'usage
- Maîtriser le multicast et le broadcast UDP
- Implémenter un client/serveur UDP complet en Python
- Découvrir les protocoles qui ajoutent de la fiabilité au-dessus d'UDP (QUIC, KCP)
```

```{code-cell} python3
:tags: [hide-input]
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.patches as patches
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
import numpy as np
import pandas as pd
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted")
plt.rcParams.update({
    "figure.dpi": 110,
    "axes.titlesize": 13,
    "axes.labelsize": 11,
    "font.family": "sans-serif",
})
```

## Structure du datagramme UDP

Le datagramme UDP est d'une simplicité remarquable : **8 octets d'en-tête**, point final. Comparez avec les 20 à 60 octets de l'en-tête TCP.

```
 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          Port source          |         Port destination       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|            Longueur           |            Checksum            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          Données ...                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
```

Les quatre champs :

| Champ | Taille | Description |
|---|---|---|
| **Port source** | 16 bits | Port de l'émetteur (0 si non utilisé) |
| **Port destination** | 16 bits | Port du récepteur |
| **Longueur** | 16 bits | Taille totale en-tête + données (min 8, max 65535) |
| **Checksum** | 16 bits | Contrôle d'intégrité (optionnel en IPv4, obligatoire en IPv6) |

La taille maximale d'un datagramme UDP est donc **65 535 − 8 = 65 527 octets** de données. En pratique, on reste généralement sous la MTU Ethernet (1500 octets) pour éviter la fragmentation IP.

```{code-cell} python3
import struct

def parse_udp_header(raw: bytes) -> dict:
    """Parse les 8 premiers octets d'un datagramme UDP."""
    if len(raw) < 8:
        raise ValueError("Données trop courtes pour un en-tête UDP")
    src_port, dst_port, length, checksum = struct.unpack("!HHHH", raw[:8])
    return {
        "port_source": src_port,
        "port_destination": dst_port,
        "longueur": length,
        "checksum": f"0x{checksum:04X}",
        "données_octets": len(raw) - 8,
    }

def build_udp_header(src_port: int, dst_port: int, payload: bytes) -> bytes:
    """Construit un en-tête UDP (checksum = 0 pour simplification)."""
    length = 8 + len(payload)
    checksum = 0
    return struct.pack("!HHHH", src_port, dst_port, length, checksum)

# Exemple : simuler un paquet DNS (port 53)
payload = b"\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"  # DNS query simplifiée
header = build_udp_header(54321, 53, payload)
raw_packet = header + payload

print("=== En-tête UDP ===")
print(f"Octets bruts (hex) : {header.hex(' ')}")
parsed = parse_udp_header(raw_packet)
for k, v in parsed.items():
    print(f"  {k:25s} = {v}")
print(f"\nTaille totale du paquet : {len(raw_packet)} octets")
print(f"Overhead en-tête UDP   : {8/len(raw_packet)*100:.1f}%")
```

```{code-cell} python3
fig, axes = plt.subplots(1, 2, figsize=(13, 4))

# ── Diagramme de l'en-tête UDP ──────────────────────────────────────────────
ax = axes[0]
ax.set_xlim(0, 32)
ax.set_ylim(-0.5, 2.5)
ax.set_aspect("equal")
ax.axis("off")
ax.set_title("Structure de l'en-tête UDP (8 octets)", fontweight="bold")

champs = [
    (0, 16, "Port source\n(16 bits)", "#4C9BE8"),
    (16, 16, "Port destination\n(16 bits)", "#E87A4C"),
    (0, 16, "Longueur\n(16 bits)", "#54B87A"),
    (16, 16, "Checksum\n(16 bits)", "#C96DD8"),
]

positions = [(0, 1), (16, 1), (0, 0), (16, 0)]
for (x, y), (_, w, label, color) in zip(positions, champs):
    rect = FancyBboxPatch((x + 0.2, y + 0.1), w - 0.4, 0.8,
                          boxstyle="round,pad=0.05", linewidth=1.5,
                          edgecolor="white", facecolor=color, alpha=0.85)
    ax.add_patch(rect)
    ax.text(x + w/2, y + 0.5, label, ha="center", va="center",
            fontsize=9, fontweight="bold", color="white")

# Étiquettes bits
for b in [0, 8, 16, 24, 31]:
    ax.text(b if b < 31 else 31.5, 2.2, str(b), ha="center", va="center",
            fontsize=7, color="gray")

ax.text(16, 2.45, "Bits 0–31", ha="center", fontsize=10, color="#333333")

# ── Comparaison des tailles d'en-têtes ──────────────────────────────────────
ax2 = axes[1]
protocoles = ["UDP", "TCP (min)", "TCP (max)", "IPv4 (min)", "Ethernet"]
tailles = [8, 20, 60, 20, 14]
colors = ["#54B87A", "#4C9BE8", "#4C9BE8", "#E87A4C", "#C96DD8"]
bars = ax2.barh(protocoles, tailles, color=colors, edgecolor="white", height=0.55)
for bar, val in zip(bars, tailles):
    ax2.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height()/2,
             f"{val} octets", va="center", fontsize=10, fontweight="bold")
ax2.set_xlabel("Taille de l'en-tête (octets)")
ax2.set_title("Comparaison des tailles d'en-têtes", fontweight="bold")
ax2.set_xlim(0, 75)
ax2.grid(axis="x", alpha=0.4)
ax2.tick_params(axis="y", labelsize=10)

plt.tight_layout()
plt.show()
```

## TCP vs UDP : tableau de comparaison

```{admonition} Quand choisir UDP plutôt que TCP ?
:class: tip
Choisissez UDP quand : (1) la latence est critique et une retransmission serait pire que la perte, (2) vous envoyez de petits messages indépendants, (3) vous faites du multicast/broadcast, (4) vous implémentez votre propre mécanisme de fiabilité (QUIC, KCP, RTP).
```

```{code-cell} python3
comparaison = {
    "Critère": [
        "Connexion", "Fiabilité", "Ordre de livraison",
        "Contrôle de flux", "Contrôle de congestion",
        "En-tête", "Latence", "Débit possible",
        "Multicast / Broadcast", "Use cases typiques"
    ],
    "TCP": [
        "Orienté connexion (handshake 3 voies)",
        "Garantie (retransmissions)",
        "Garanti (numéros de séquence)",
        "Oui (fenêtre glissante)",
        "Oui (slow start, CUBIC…)",
        "20–60 octets",
        "Plus élevée (+RTT handshake)",
        "Limité par fenêtre et RTT",
        "Non",
        "HTTP, FTP, SSH, SMTP, base de données"
    ],
    "UDP": [
        "Sans connexion",
        "Aucune (best-effort)",
        "Non garanti",
        "Non",
        "Non",
        "8 octets",
        "Minimale (pas de handshake)",
        "Limité seulement par la bande passante",
        "Oui",
        "DNS, DHCP, NTP, VoIP, streaming, jeux"
    ]
}

df = pd.DataFrame(comparaison)
print(df.to_string(index=False))
```

```{code-cell} python3
fig, ax = plt.subplots(figsize=(13, 6))
ax.axis("off")

table = ax.table(
    cellText=df.values,
    colLabels=df.columns,
    cellLoc="left",
    loc="center",
)
table.auto_set_font_size(False)
table.set_fontsize(8.5)
table.scale(1, 1.8)

# Style header
for j in range(len(df.columns)):
    table[0, j].set_facecolor("#2C3E50")
    table[0, j].set_text_props(color="white", fontweight="bold")

# Style lignes alternées
for i in range(1, len(df) + 1):
    for j in range(len(df.columns)):
        if i % 2 == 0:
            table[i, j].set_facecolor("#F0F4F8")
        if j == 1:
            table[i, j].set_facecolor("#D6E8FF")
        elif j == 2:
            table[i, j].set_facecolor("#D6FFE8")

ax.set_title("Comparaison TCP vs UDP", fontsize=14, fontweight="bold", pad=20)
plt.tight_layout()
plt.show()
```

## Cas d'usage d'UDP

### DNS — Domain Name System

DNS utilise UDP sur le port 53 pour les requêtes standards. Une requête DNS est typiquement inférieure à 512 octets — parfaitement adaptée à un datagramme UDP. La latence de résolution est directement impactée par le protocole de transport.

```{code-cell} python3
# Simulation : construction d'une requête DNS minimale en UDP
def build_dns_query(domain: str) -> bytes:
    """Construit une requête DNS A minimale (format simplifié)."""
    # En-tête DNS
    transaction_id = 0x1234
    flags = 0x0100        # Requête standard, récursion désirée
    qdcount = 1           # 1 question
    ancount = nscount = arcount = 0
    header = struct.pack("!HHHHHH", transaction_id, flags,
                         qdcount, ancount, nscount, arcount)
    # Question : encodage QNAME
    qname = b""
    for label in domain.split("."):
        encoded = label.encode()
        qname += bytes([len(encoded)]) + encoded
    qname += b"\x00"  # terminateur
    qtype = 1   # A record
    qclass = 1  # IN (Internet)
    question = qname + struct.pack("!HH", qtype, qclass)

    return header + question

query = build_dns_query("example.com")
udp_header = build_udp_header(54321, 53, query)
full_packet = udp_header + query

print(f"Taille requête DNS (payload) : {len(query)} octets")
print(f"En-tête UDP                  : 8 octets")
print(f"Paquet total                 : {len(full_packet)} octets")
print(f"Hex du paquet :")
print(" ".join(f"{b:02x}" for b in full_packet))
```

### NTP, DHCP, VoIP, jeux en ligne

```{code-cell} python3
cas_usage = {
    "Protocole": ["DNS", "DHCP", "NTP", "VoIP (RTP)", "Jeux en ligne",
                  "Streaming (RTSP/RTP)", "TFTP", "SNMP"],
    "Port": ["53", "67/68", "123", "5004–5005", "Variable",
             "Variable", "69", "161/162"],
    "Taille typique": ["<512 o", "<576 o", "48 o", "160–320 o",
                       "50–1400 o", "188–1316 o", "512 o max", "<512 o"],
    "Raison UDP": [
        "Requête/réponse rapide, retry applicatif",
        "Pas encore d'adresse IP (broadcast)",
        "Précision temporelle, faible latence",
        "Perte acceptable, ordre optionnel",
        "Latence critique, jitter toléré",
        "Perte d'images acceptable",
        "Simplicité, embarqué",
        "Polling léger"
    ]
}

df_usage = pd.DataFrame(cas_usage)
print(df_usage.to_string(index=False))
```

## Multicast UDP

Le **multicast** permet d'envoyer un paquet à un groupe de destinataires simultanément, sans dupliquer les données sur chaque lien. C'est l'antithèse de l'unicast (un émetteur, un récepteur) et du broadcast (tous les hôtes du sous-réseau).

### Adresses multicast IPv4

Les adresses multicast IPv4 sont dans la plage **224.0.0.0 – 239.255.255.255** (classe D).

| Plage | Portée | Exemples |
|---|---|---|
| 224.0.0.0/24 | Lien local (TTL=1) | 224.0.0.1 (tous hôtes), 224.0.0.2 (tous routeurs) |
| 224.0.1.0/24 | Internet global | 224.0.1.1 (NTP) |
| 232.0.0.0/8 | Source-specific multicast (SSM) | IPTV professionnel |
| 239.0.0.0/8 | Administratif (scope limité) | IPTV local, mises à jour |

### IGMP — Internet Group Management Protocol

Les hôtes utilisent **IGMP** pour rejoindre ou quitter un groupe multicast. Les routeurs écoutent ces messages pour savoir sur quels liens diffuser le trafic.

```{code-cell} python3
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# ── Diagramme multicast vs unicast vs broadcast ──────────────────────────────
ax = axes[0]
ax.set_xlim(0, 10)
ax.set_ylim(0, 8)
ax.axis("off")
ax.set_title("Unicast vs Multicast vs Broadcast", fontweight="bold")

def draw_host(ax, x, y, label, color="#4C9BE8", size=0.35):
    circle = plt.Circle((x, y), size, color=color, zorder=4)
    ax.add_patch(circle)
    ax.text(x, y - 0.6, label, ha="center", va="top", fontsize=7.5)

def draw_arrow(ax, x1, y1, x2, y2, color, lw=1.5):
    ax.annotate("", xy=(x2, y2), xytext=(x1, y1),
                arrowprops=dict(arrowstyle="->", color=color, lw=lw))

# Émetteur commun
draw_host(ax, 1.5, 4, "Émetteur", "#E87A4C")

# Unicast (haut)
for i, (x, y, lbl) in enumerate([(3.5, 6.5, "H1"), (3.5, 5.5, "H2"), (3.5, 4.5, "H3")]):
    c = "#4C9BE8" if i == 0 else "#CCCCCC"
    draw_host(ax, x, y, lbl, c)
    draw_arrow(ax, 1.85, 4, x - 0.35, y, "#4C9BE8" if i == 0 else "#DDDDDD",
               lw=2 if i == 0 else 0.8)
ax.text(3.5, 7.3, "Unicast\n(1 flux / destinataire)", ha="center", fontsize=8,
        color="#4C9BE8", fontweight="bold")

# Multicast (milieu droit)
for i, (x, y, lbl) in enumerate([(6.5, 6.8, "H1"), (6.5, 5.2, "H2"), (8.5, 6.8, "H3")]):
    draw_host(ax, x, y, lbl, "#54B87A")
    draw_arrow(ax, 1.85, 4.1, x - 0.35, y, "#54B87A", lw=2)
draw_host(ax, 8.5, 5.2, "H4", "#CCCCCC")
draw_arrow(ax, 1.85, 4.0, 8.15, 5.2, "#DDDDDD", lw=0.8)
ax.text(7.5, 7.6, "Multicast\n(1 flux → groupe)", ha="center", fontsize=8,
        color="#54B87A", fontweight="bold")

# Broadcast (bas)
for x, y, lbl in [(3.5, 2.5, "H1"), (3.5, 1.5, "H2"), (5.5, 2.5, "H3"), (5.5, 1.5, "H4")]:
    draw_host(ax, x, y, lbl, "#C96DD8")
    draw_arrow(ax, 1.85, 3.9, x - 0.35, y, "#C96DD8", lw=1.5)
ax.text(4.5, 0.7, "Broadcast\n(tous les hôtes)", ha="center", fontsize=8,
        color="#C96DD8", fontweight="bold")

# ── Plages d'adresses multicast ──────────────────────────────────────────────
ax2 = axes[1]
plages = ["224.0.0.0/24\n(lien local)", "224.0.1.0/24\n(global)",
          "232.0.0.0/8\n(SSM)", "233.0.0.0/8\n(GLOP)",
          "239.0.0.0/8\n(administratif)"]
tailles = [256, 256, 16_777_216, 16_777_216, 16_777_216]
colors_m = ["#E87A4C", "#4C9BE8", "#54B87A", "#C96DD8", "#F0C040"]
bars = ax2.bar(plages, [np.log2(t) for t in tailles], color=colors_m,
               edgecolor="white", width=0.6)
ax2.set_ylabel("log₂(nombre d'adresses)")
ax2.set_title("Plages d'adresses multicast IPv4", fontweight="bold")
ax2.set_ylim(0, 27)
for bar, t in zip(bars, tailles):
    ax2.text(bar.get_x() + bar.get_width()/2,
             bar.get_height() + 0.3,
             f"{t:,}", ha="center", fontsize=8)
ax2.tick_params(axis="x", labelsize=8)
ax2.grid(axis="y", alpha=0.4)

plt.tight_layout()
plt.show()
```

### Exemple Python : rejoindre un groupe multicast

```{code-cell} python3
import socket
import struct
import threading
import time

MULTICAST_GROUP = "239.255.0.1"
MULTICAST_PORT = 50007

def multicast_sender(message: str, ttl: int = 1):
    """Envoie un message vers un groupe multicast."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
    sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
    try:
        sock.sendto(message.encode(), (MULTICAST_GROUP, MULTICAST_PORT))
        print(f"[SENDER] Envoyé : '{message}' → {MULTICAST_GROUP}:{MULTICAST_PORT}")
    finally:
        sock.close()

def multicast_receiver(timeout: float = 2.0):
    """Reçoit depuis un groupe multicast (version non-bloquante pour démo)."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(("", MULTICAST_PORT))
    # Rejoindre le groupe multicast
    mreq = struct.pack("4sL", socket.inet_aton(MULTICAST_GROUP),
                       socket.INADDR_ANY)
    sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
    sock.settimeout(timeout)
    try:
        data, addr = sock.recvfrom(1024)
        print(f"[RECEIVER] Reçu : '{data.decode()}' depuis {addr[0]}:{addr[1]}")
        return data.decode()
    except socket.timeout:
        print("[RECEIVER] Timeout — aucun message reçu dans les délais")
        return None
    finally:
        # Quitter le groupe
        sock.setsockopt(socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, mreq)
        sock.close()

# Démonstration en loopback
received = []

def receiver_thread():
    result = multicast_receiver(timeout=3.0)
    if result:
        received.append(result)

t = threading.Thread(target=receiver_thread, daemon=True)
t.start()
time.sleep(0.1)  # Laisser le receiver s'initialiser

multicast_sender("Bonjour groupe multicast !")
t.join(timeout=4)

print(f"\nRésultat : {len(received)} message(s) reçu(s)")
```

## Broadcast UDP

Le **broadcast** envoie un paquet à tous les hôtes d'un sous-réseau. L'adresse `255.255.255.255` est un broadcast limité (non routé). L'adresse de **subnet-directed broadcast** (ex: `192.168.1.255` pour `192.168.1.0/24`) est routée dans le sous-réseau mais pas au-delà.

```{admonition} Limitations du broadcast
:class: warning
- Les routeurs ne transmettent **pas** les broadcasts entre sous-réseaux
- Génère du trafic sur **tous** les hôtes du sous-réseau, même non intéressés
- Utilisé principalement pour DHCP (discover) et ARP
- Limité à IPv4 — IPv6 remplace le broadcast par des adresses multicast spécifiques (ex: `ff02::1`)
```

```{code-cell} python3
# Exemple : envoi en broadcast UDP (nécessite SO_BROADCAST)
def broadcast_sender(message: str, port: int = 50008):
    """Envoie un message en broadcast UDP (local)."""
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    sock.settimeout(1)
    try:
        sock.sendto(message.encode(), ("255.255.255.255", port))
        print(f"[BROADCAST] Envoyé : '{message}' → 255.255.255.255:{port}")
    except Exception as e:
        print(f"[BROADCAST] Erreur : {e}")
    finally:
        sock.close()

broadcast_sender("DHCP Discover (simulation)")
```

## Simulation de perte de paquets

UDP ne retransmet pas les paquets perdus. Pour comprendre l'impact, simulons un canal avec perte et mesurons le taux de livraison.

```{code-cell} python3
import random

def simulate_udp_channel(num_packets: int, loss_rate: float, seed: int = 42):
    """
    Simule l'envoi de num_packets datagrammes UDP sur un canal
    avec un taux de perte loss_rate (0.0 à 1.0).
    """
    rng = random.Random(seed)
    sent = 0
    received = 0
    lost = 0
    latencies = []

    for seq in range(num_packets):
        sent += 1
        # Perte aléatoire
        if rng.random() < loss_rate:
            lost += 1
        else:
            received += 1
            # Latence simulée : exponentielle centrée autour de 20 ms
            latency = max(1, rng.gauss(20, 5))
            latencies.append(latency)

    return {
        "envoyés": sent,
        "reçus": received,
        "perdus": lost,
        "taux_livraison_%": received / sent * 100,
        "latence_moy_ms": np.mean(latencies) if latencies else 0,
        "latence_p99_ms": np.percentile(latencies, 99) if latencies else 0,
    }

scenarios = [
    ("LAN local", 0.001),
    ("WiFi domestique", 0.02),
    ("4G mobile", 0.05),
    ("3G dégradé", 0.10),
    ("Réseau saturé", 0.20),
]

print(f"{'Scénario':<20} {'Envoyés':>10} {'Reçus':>8} {'Perdus':>8} "
      f"{'Livraison':>12} {'Lat. moy.':>12}")
print("-" * 78)
results = {}
for nom, loss in scenarios:
    r = simulate_udp_channel(1000, loss)
    results[nom] = r
    print(f"{nom:<20} {r['envoyés']:>10} {r['reçus']:>8} {r['perdus']:>8} "
          f"{r['taux_livraison_%']:>11.1f}% {r['latence_moy_ms']:>10.1f} ms")
```

```{code-cell} python3
fig, axes = plt.subplots(1, 2, figsize=(13, 4.5))

noms = list(results.keys())
livraisons = [results[n]["taux_livraison_%"] for n in noms]
latences = [results[n]["latence_moy_ms"] for n in noms]
perdus_pct = [100 - l for l in livraisons]

# ── Taux de livraison ────────────────────────────────────────────────────────
ax1 = axes[0]
x = np.arange(len(noms))
width = 0.38
b1 = ax1.bar(x - width/2, livraisons, width, label="Reçus", color="#54B87A", edgecolor="white")
b2 = ax1.bar(x + width/2, perdus_pct, width, label="Perdus", color="#E87A4C", edgecolor="white")
ax1.set_ylabel("Pourcentage de paquets (%)")
ax1.set_title("Taux de livraison UDP selon le réseau", fontweight="bold")
ax1.set_xticks(x)
ax1.set_xticklabels(noms, rotation=25, ha="right", fontsize=9)
ax1.set_ylim(0, 110)
ax1.legend(fontsize=9)
ax1.grid(axis="y", alpha=0.4)
for bar in b1:
    h = bar.get_height()
    ax1.text(bar.get_x() + bar.get_width()/2, h + 1, f"{h:.1f}%",
             ha="center", va="bottom", fontsize=8)

# ── Comparaison latence TCP vs UDP ───────────────────────────────────────────
ax2 = axes[1]
rng = random.Random(1)
n = 200
udp_lat = [max(0.5, rng.gauss(20, 4)) for _ in range(n)]
# TCP ajoute ~RTT pour le handshake et peut avoir des retransmissions
tcp_lat = [max(1, rng.gauss(22, 6)) + (rng.expovariate(0.2) if rng.random() < 0.05 else 0)
           for _ in range(n)]

ax2.hist(udp_lat, bins=30, alpha=0.7, color="#54B87A", label=f"UDP (moy={np.mean(udp_lat):.1f} ms)")
ax2.hist(tcp_lat, bins=30, alpha=0.7, color="#4C9BE8", label=f"TCP (moy={np.mean(tcp_lat):.1f} ms)")
ax2.axvline(np.mean(udp_lat), color="#2E8A50", linestyle="--", lw=2)
ax2.axvline(np.mean(tcp_lat), color="#2E5FA3", linestyle="--", lw=2)
ax2.set_xlabel("Latence (ms)")
ax2.set_ylabel("Nombre de paquets")
ax2.set_title("Distribution des latences TCP vs UDP\n(simulation)", fontweight="bold")
ax2.legend(fontsize=9)
ax2.grid(alpha=0.4)

plt.tight_layout()
plt.show()
```

## UDP avec fiabilité applicative

Plusieurs protocoles modernes réimplémentent la fiabilité **au-dessus d'UDP**, évitant les blocages head-of-line de TCP tout en gardant la souplesse d'UDP.

### QUIC

**QUIC** (RFC 9000) est le protocole de transport développé par Google, maintenant standardisé. Il tourne sur UDP et fournit :
- Multiplexage de streams sans blocage head-of-line
- Chiffrement TLS 1.3 intégré (0-RTT ou 1-RTT)
- Migration de connexion (changement d'IP sans interruption)
- Contrôle de congestion pluggable

QUIC est la base de **HTTP/3** (anciennement HTTP-over-QUIC).

### KCP

**KCP** est un protocole de fiabilité applicative optimisé pour la latence. Il offre un contrôle fin sur les paramètres de retransmission, particulièrement apprécié dans les jeux en ligne compétitifs.

```{code-cell} python3
# Visualisation : overhead et latence des protocoles
protocols = ["TCP", "UDP brut", "UDP + QUIC", "UDP + KCP", "UDP + ARQ custom"]
overhead_bytes = [40, 8, 36, 24, 16]      # en-tête approximatif
latency_setup_ms = [100, 0, 50, 0, 0]     # latence d'établissement (1 RTT = 100 ms)
reliability = [100, 0, 100, 99, 95]       # % fiabilité

fig, axes = plt.subplots(1, 3, figsize=(14, 4.5))

colors_p = ["#4C9BE8", "#54B87A", "#E87A4C", "#C96DD8", "#F0C040"]

for ax, values, ylabel, title in zip(
    axes,
    [overhead_bytes, latency_setup_ms, reliability],
    ["Octets d'en-tête", "Latence d'établissement (ms)", "Fiabilité (%)"],
    ["Overhead des en-têtes", "Latence d'établissement\n(basé sur 1 RTT = 100 ms)", "Fiabilité de livraison"]
):
    bars = ax.bar(protocols, values, color=colors_p, edgecolor="white", width=0.6)
    ax.set_ylabel(ylabel)
    ax.set_title(title, fontweight="bold")
    ax.set_xticklabels(protocols, rotation=25, ha="right", fontsize=9)
    ax.grid(axis="y", alpha=0.4)
    for bar, v in zip(bars, values):
        ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + max(values)*0.01,
                str(v), ha="center", va="bottom", fontsize=9, fontweight="bold")

plt.tight_layout()
plt.show()
```

## Client/serveur UDP complet

```{code-cell} python3
import socket
import threading
import time
import random

def udp_echo_server(host: str = "127.0.0.1", port: int = 55555,
                    loss_rate: float = 0.0, max_messages: int = 5):
    """
    Serveur UDP echo avec simulation optionnelle de perte de paquets.
    S'arrête après avoir traité max_messages messages.
    """
    server_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_sock.settimeout(3.0)
    server_sock.bind((host, port))
    print(f"[SERVER] En écoute sur {host}:{port} (perte simulée : {loss_rate*100:.0f}%)")

    count = 0
    while count < max_messages:
        try:
            data, addr = server_sock.recvfrom(4096)
            count += 1
            if random.random() < loss_rate:
                print(f"[SERVER] Paquet #{count} de {addr} PERDU (simulation)")
                continue
            msg = data.decode(errors="replace")
            response = f"ECHO:{msg}"
            server_sock.sendto(response.encode(), addr)
            print(f"[SERVER] #{count} reçu de {addr}: '{msg}' → renvoyé")
        except socket.timeout:
            break

    server_sock.close()
    print("[SERVER] Arrêté")


def udp_client(host: str = "127.0.0.1", port: int = 55555,
               messages: list = None, timeout: float = 1.0):
    """Client UDP avec timeout par message."""
    if messages is None:
        messages = ["Bonjour", "UDP", "est", "rapide", "!"]

    client_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    client_sock.settimeout(timeout)

    stats = {"envoyés": 0, "reçus": 0, "timeouts": 0}

    for i, msg in enumerate(messages, 1):
        client_sock.sendto(msg.encode(), (host, port))
        stats["envoyés"] += 1
        try:
            data, _ = client_sock.recvfrom(4096)
            stats["reçus"] += 1
            print(f"[CLIENT] #{i} → '{msg}' | ← '{data.decode()}'")
        except socket.timeout:
            stats["timeouts"] += 1
            print(f"[CLIENT] #{i} → '{msg}' | ← TIMEOUT")

    client_sock.close()
    print(f"\n[CLIENT] Stats : {stats}")
    return stats


# Lancer le serveur en arrière-plan
messages = ["Bonjour", "monde", "UDP", "est", "simple"]
server_thread = threading.Thread(
    target=udp_echo_server,
    kwargs={"loss_rate": 0.0, "max_messages": len(messages)},
    daemon=True
)
server_thread.start()
time.sleep(0.05)

# Lancer le client
stats = udp_client(messages=messages)
server_thread.join(timeout=5)
```

```{code-cell} python3
# Même chose avec simulation de perte
print("=== Simulation avec 30% de perte ===\n")

messages2 = ["Hello", "UDP", "avec", "pertes", "simulées"]
server_thread2 = threading.Thread(
    target=udp_echo_server,
    kwargs={"loss_rate": 0.3, "max_messages": len(messages2)},
    daemon=True
)
server_thread2.start()
time.sleep(0.05)

stats2 = udp_client(messages=messages2, timeout=0.5)
server_thread2.join(timeout=5)
```

## Résumé

```{code-cell} python3
fig, ax = plt.subplots(figsize=(12, 5))
ax.axis("off")
ax.set_title("Récapitulatif — UDP : rapidité et multicast", fontsize=14, fontweight="bold", pad=15)

resume = [
    ["En-tête UDP", "8 octets seulement (src port, dst port, longueur, checksum)"],
    ["Sans connexion", "Pas de handshake — envoi immédiat, latence minimale"],
    ["Best-effort", "Pas de retransmission, pas de garantie d'ordre"],
    ["Multicast", "Groupes 224.0.0.0–239.255.255.255, gestion par IGMP"],
    ["Broadcast", "255.255.255.255 ou subnet-directed, non routé entre sous-réseaux"],
    ["QUIC", "Fiabilité + multiplexage + TLS 1.3, base de HTTP/3"],
    ["Use cases", "DNS, DHCP, NTP, VoIP, streaming, jeux en ligne"],
]

table = ax.table(
    cellText=resume,
    colLabels=["Concept", "Détail"],
    cellLoc="left",
    loc="center",
    colWidths=[0.25, 0.65]
)
table.auto_set_font_size(False)
table.set_fontsize(10)
table.scale(1, 1.9)

for j in range(2):
    table[0, j].set_facecolor("#2C3E50")
    table[0, j].set_text_props(color="white", fontweight="bold")
for i in range(1, len(resume) + 1):
    for j in range(2):
        if i % 2 == 0:
            table[i, j].set_facecolor("#F5F7FA")
        if j == 0:
            table[i, j].set_text_props(fontweight="bold", color="#2C3E50")

plt.tight_layout()
plt.show()
```
