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.

Objectifs du chapitre

  • 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)

Hide code cell source

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.

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}%")
=== En-tête UDP ===
Octets bruts (hex) : d4 31 00 35 00 14 00 00
  port_source               = 54321
  port_destination          = 53
  longueur                  = 20
  checksum                  = 0x0000
  données_octets            = 12

Taille totale du paquet : 20 octets
Overhead en-tête UDP   : 40.0%
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()
_images/b989d7173b3ffe2d953213099f7f1a245d19f7877954b8f2882b5b1df1b8cb6f.png

TCP vs UDP : tableau de comparaison#

Quand choisir UDP plutôt que TCP ?

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

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))
               Critère                                   TCP                                    UDP
             Connexion Orienté connexion (handshake 3 voies)                         Sans connexion
             Fiabilité            Garantie (retransmissions)                   Aucune (best-effort)
    Ordre de livraison         Garanti (numéros de séquence)                            Non garanti
      Contrôle de flux               Oui (fenêtre glissante)                                    Non
Contrôle de congestion              Oui (slow start, CUBIC…)                                    Non
               En-tête                          20–60 octets                               8 octets
               Latence          Plus élevée (+RTT handshake)            Minimale (pas de handshake)
        Débit possible             Limité par fenêtre et RTT Limité seulement par la bande passante
 Multicast / Broadcast                                   Non                                    Oui
    Use cases typiques HTTP, FTP, SSH, SMTP, base de données  DNS, DHCP, NTP, VoIP, streaming, jeux
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()
_images/2971a6fbc2059c476dbb6a5631c34e451784781d69f949235695381189312c77.png

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.

# 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))
Taille requête DNS (payload) : 29 octets
En-tête UDP                  : 8 octets
Paquet total                 : 37 octets
Hex du paquet :
d4 31 00 35 00 25 00 00 12 34 01 00 00 01 00 00 00 00 00 00 07 65 78 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01

NTP, DHCP, VoIP, jeux en ligne#

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

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.

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()
_images/c7a122c34091df369f3747960e937b5d8635e5c5a40137f7f7db61aebb6c518d.png

Exemple Python : rejoindre un groupe multicast#

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)")
[SENDER] Envoyé : 'Bonjour groupe multicast !' → 239.255.0.1:50007[RECEIVER] Reçu : 'Bonjour groupe multicast !' depuis 10.23.39.254:39594


Résultat : 1 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à.

Limitations du broadcast

  • 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)

# 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)")
[BROADCAST] Envoyé : 'DHCP Discover (simulation)' → 255.255.255.255:50008

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.

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")
Scénario                Envoyés    Reçus   Perdus    Livraison    Lat. moy.
------------------------------------------------------------------------------
LAN local                  1000      999        1        99.9%       19.8 ms
WiFi domestique            1000      980       20        98.0%       19.9 ms
4G mobile                  1000      960       40        96.0%       19.9 ms
3G dégradé                 1000      903       97        90.3%       19.9 ms
Réseau saturé              1000      806      194        80.6%       19.7 ms
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()
_images/add272d2f9568b2f209589f6ef7f2cfd7c0dfba4b80c940263cc4c05f67b2c71.png

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.

# 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()
/tmp/ipykernel_10906/3971976881.py:20: UserWarning: set_ticklabels() should only be used with a fixed number of ticks, i.e. after set_ticks() or using a FixedLocator.
  ax.set_xticklabels(protocols, rotation=25, ha="right", fontsize=9)
/tmp/ipykernel_10906/3971976881.py:20: UserWarning: set_ticklabels() should only be used with a fixed number of ticks, i.e. after set_ticks() or using a FixedLocator.
  ax.set_xticklabels(protocols, rotation=25, ha="right", fontsize=9)
/tmp/ipykernel_10906/3971976881.py:20: UserWarning: set_ticklabels() should only be used with a fixed number of ticks, i.e. after set_ticks() or using a FixedLocator.
  ax.set_xticklabels(protocols, rotation=25, ha="right", fontsize=9)
_images/205f1766c6a2c33c7290cd550ed397023a5ae0da5561e8fb1a3252faeb710455.png

Client/serveur UDP complet#

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)
[SERVER] En écoute sur 127.0.0.1:55555 (perte simulée : 0%)
[SERVER] #1 reçu de ('127.0.0.1', 38143): 'Bonjour' → renvoyé
[CLIENT] #1 → 'Bonjour' | ← 'ECHO:Bonjour'
[SERVER] #2 reçu de ('127.0.0.1', 38143): 'monde' → renvoyé
[CLIENT] #2 → 'monde' | ← 'ECHO:monde'
[SERVER] #3 reçu de ('127.0.0.1', 38143): 'UDP' → renvoyé
[CLIENT] #3 → 'UDP' | ← 'ECHO:UDP'
[SERVER] #4 reçu de ('127.0.0.1', 38143): 'est' → renvoyé
[CLIENT] #4 → 'est' | ← 'ECHO:est'
[SERVER] #5 reçu de ('127.0.0.1', 38143): 'simple' → renvoyé
[CLIENT] #5 → 'simple' | ← 'ECHO:simple'
[SERVER] Arrêté

[CLIENT] Stats : {'envoyés': 5, 'reçus': 5, 'timeouts': 0}
# 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)
=== Simulation avec 30% de perte ===

[SERVER] En écoute sur 127.0.0.1:55555 (perte simulée : 30%)
[SERVER] #1 reçu de ('127.0.0.1', 59565): 'Hello' → renvoyé
[CLIENT] #1 → 'Hello' | ← 'ECHO:Hello'
[SERVER] #2 reçu de ('127.0.0.1', 59565): 'UDP' → renvoyé
[CLIENT] #2 → 'UDP' | ← 'ECHO:UDP'
[SERVER] #3 reçu de ('127.0.0.1', 59565): 'avec' → renvoyé
[CLIENT] #3 → 'avec' | ← 'ECHO:avec'
[SERVER] #4 reçu de ('127.0.0.1', 59565): 'pertes' → renvoyé
[CLIENT] #4 → 'pertes' | ← 'ECHO:pertes'
[SERVER] Paquet #5 de ('127.0.0.1', 59565) PERDU (simulation)
[SERVER] Arrêté
[CLIENT] #5 → 'simulées' | ← TIMEOUT

[CLIENT] Stats : {'envoyés': 5, 'reçus': 4, 'timeouts': 1}

Résumé#

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()
_images/267c098614ff2845bc34ee61ea33d69bdf57168f3514fdf98ad5b32924dc8b87.png