1  Pourquoi distribuer ? Premiers pas en TCP

Objectifs de la séance

  • Comprendre par le calcul pourquoi une seule machine ne suffit plus, et ce que « distribuer » veut dire.
  • Connaître le vocabulaire minimal du réseau : adresse IP, port, client, serveur, TCP.
  • Écrire et exécuter votre premier serveur et votre premier client en Python.
  • Écrire un serveur qui expose une information réelle de la machine : sa charge CPU.

1.1 Pourquoi ce cours existe

Un problème de temps, pas de place

Commençons par un calcul très simple. Un disque SSD correct lit les données à environ 500 mégaoctets par seconde. Vous voulez compter les mots dans un fichier de 1 téraoctet.

1000000 Mo500 Mo/s=2000 s≈33 minutes \frac{1\,000\,000 \text{ Mo}}{500 \text{ Mo/s}} = 2\,000 \text{ s} \approx 33 \text{ minutes}

Et encore : ces 33 minutes correspondent au temps qu’il faut juste pour lire les octets, sans rien en faire. Si votre programme fait un traitement, comptez le double ou le triple.

Maintenant, imaginez que le fichier fasse 1 pétaoctet (mille téraoctets), ce qui est l’ordre de grandeur d’un jeu de données comme Common Crawl, une archive publique du Web utilisée aujourd’hui pour entraîner les IA. Le même calcul donne :

1000×33 minutes≈23 jours 1\,000 \times 33 \text{ minutes} \approx 23 \text{ jours}

Vingt-trois jours de lecture continue, sans panne, sans erreur. Ce n’est plus un problème de programmation : c’est un problème d’architecture.

ImportantÀ retenir

Le Big Data ne commence pas quand les données sont grosses. Il commence quand une seule machine ne peut plus faire le travail dans un temps acceptable. La réponse est alors de faire travailler plusieurs machines en même temps.

Grossir ou multiplier ?

Face à ce mur, il existe deux stratégies.

Grossir (scale up, mise à l’échelle verticale). On achète une machine plus puissante : plus de cœurs, plus de RAM, des disques plus rapides. C’est simple, ça ne change rien à votre code. Mais cette stratégie a deux limites : le prix, qui croît beaucoup plus vite que la performance, et un plafond physique — il n’existe pas de machine mille fois plus rapide que votre ordinateur portable.

Multiplier (scale out, mise à l’échelle horizontale). On achète cent machines ordinaires et on leur fait partager le travail. C’est moins cher, et surtout, il n’y a pas de plafond : si cent ne suffisent pas, on en met mille.

Mais rien n’est gratuit. En choisissant de multiplier, on hérite de trois problèmes qui n’existaient pas avant :

  1. Comment les machines se parlent-elles ? C’est l’objet des séances 1 à 3.
  2. Comment répartir les données et le travail ? C’est l’objet des séances 5 et 6.
  3. Que se passe-t-il quand une machine tombe en panne ? Avec cent machines, ce n’est plus une hypothèse d’école : c’est une certitude statistique. C’est l’objet des séances 5 et 7.
AstucePourquoi c’est important

Tout le cours tient dans cette phrase : distribuer, c’est échanger un problème de puissance contre un problème de coordination. Hadoop, Spark ou Kafka ne sont rien d’autre que des réponses industrielles à ce problème de coordination. Nous allons construire nos propres réponses, en plus simple, pour comprendre les leurs.

1.2 Le minimum vital sur le réseau

Adresse IP et port

Pour qu’un programme sur la machine A parle à un programme sur la machine B, il faut deux informations.

L’adresse IP identifie la machine sur le réseau. Par exemple 137.194.2.15. C’est l’équivalent de l’adresse postale d’un immeuble.

Le port est un nombre entre 1 et 65535 qui identifie le programme sur cette machine. C’est le numéro d’appartement dans l’immeuble. Une machine peut faire tourner cent programmes réseau en même temps : chacun occupe un port différent.

Certains ports sont réservés par convention :

Port Usage habituel
22 SSH
80 HTTP (site web)
443 HTTPS (site web chiffré)
5432 PostgreSQL
9092 Kafka

Les ports en dessous de 1024 nécessitent des droits administrateur. Dans ce cours, nous utiliserons des ports « hauts », entre 10000 et 60000.

AvertissementAttention

Sur les machines de l’école, vous n’êtes pas seul. Si vous choisissez le port 50000 et qu’un camarade a fait le même choix, l’un des deux obtiendra l’erreur Address already in use. Prenez l’habitude de choisir un port « personnel », par exemple $40000 + $ les trois derniers chiffres de votre numéro d’étudiant.

Deux adresses IP particulières

  • 127.0.0.1, aussi appelée localhost : c’est votre propre machine. Un serveur qui écoute sur cette adresse n’est accessible que depuis la machine elle-même. Parfait pour tester.
  • 0.0.0.0 : signifie « toutes les interfaces réseau de cette machine ». Un serveur qui écoute sur cette adresse est accessible depuis le réseau. C’est ce qu’il faut utiliser quand vous voulez que d’autres machines vous contactent.

Client et serveur

La relation client/serveur est asymétrique :

  • Le serveur démarre en premier, s’installe sur un port, et attend. Il ne sait pas qui va l’appeler ni quand.
  • Le client démarre quand il veut, connaît l’adresse et le port du serveur, et initie la connexion.

TCP en une page

TCP (Transmission Control Protocol) est le protocole que nous utiliserons tout au long du cours. Il vous offre quatre garanties :

  1. Connexion : avant tout échange, un lien est établi entre les deux programmes.
  2. Fiabilité : les octets envoyés arrivent. Si un paquet se perd, TCP le renvoie tout seul.
  3. Ordre : les octets arrivent dans l’ordre où ils ont été envoyés.
  4. Contrôle de flux : si le destinataire est lent, TCP ralentit l’expéditeur au lieu de saturer.

Il existe une alternative, UDP, qui n’offre aucune de ces garanties mais qui est plus rapide et plus léger. On l’utilise pour la voix, la vidéo en direct, les jeux : là où recevoir une donnée en retard est pire que ne pas la recevoir. Dans ce cours, ce sera toujours TCP.

AvertissementAttention

Retenez bien la formulation exacte : TCP garantit que les octets arrivent, dans l’ordre. Il ne garantit pas que vos messages arrivent séparés les uns des autres. Cette distinction, qui semble anodine aujourd’hui, sera le sujet central de la séance 3 — et une source classique de bugs.

1.3 Le socket

L’analogie du téléphone

Un socket est le point d’accès qu’un programme utilise pour communiquer sur le réseau. Sa manipulation ressemble beaucoup à celle d’un téléphone fixe.

Téléphone Fonction Python Signification
Acheter un téléphone socket() Créer le point d’accès
Obtenir un numéro bind() S’attribuer une adresse et un port
Brancher le téléphone listen() Se déclarer prêt à recevoir des appels
Décrocher accept() Accepter une connexion entrante
Composer un numéro connect() Se connecter à un serveur
Parler sendall() Envoyer des octets
Écouter recv() Recevoir des octets
Raccrocher close() Fermer la connexion

Les quatre premières lignes sont réservées au serveur. connect() est réservé au client. Les trois dernières sont utilisées par les deux.

Un détail crucial : on envoie des octets

Un socket ne transporte pas du texte, ni des nombres, ni des objets Python. Il transporte des octets (le type bytes).

texte = "Bonjour"          # str  : du texte
octets = texte.encode()    # bytes : b'Bonjour'
retour = octets.decode()   # str  : "Bonjour"

On peut aussi écrire directement des octets littéraux avec le préfixe b : b"Bonjour". Par défaut, encode() et decode() utilisent l’encodage UTF-8.

AvertissementAttention

Oublier .encode() avant d’envoyer est l’erreur numéro un du débutant. Python répond alors : TypeError: a bytes-like object is required, not 'str'. Symétriquement, si vous affichez ce que vous recevez sans .decode(), vous verrez b'Bonjour' avec son préfixe b au lieu de Bonjour.

1.4 Votre premier serveur

Voici un serveur complet. Il attend une connexion, lit ce que le client envoie, le renvoie tel quel, puis raccroche. C’est ce qu’on appelle un serveur echo.

serveur_echo.py
import socket

HOTE = "127.0.0.1"
PORT = 50007

# 1. Creer le socket : IPv4 (AF_INET), TCP (SOCK_STREAM)
serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# 2. Autoriser la reutilisation immediate du port
serveur.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

# 3. S'attribuer l'adresse et le port
serveur.bind((HOTE, PORT))

# 4. Se declarer pret (5 = taille de la file d'attente)
serveur.listen(5)
print(f"Serveur en ecoute sur {HOTE}:{PORT}")

while True:
    # 5. Bloque ici jusqu'a ce qu'un client se connecte
    connexion, adresse = serveur.accept()
    print(f"Connexion de {adresse}")

    # 6. Lire au plus 1024 octets
    donnees = connexion.recv(1024)
    print(f"Recu : {donnees.decode()}")

    # 7. Renvoyer les memes octets
    connexion.sendall(donnees)

    # 8. Fermer cette connexion (pas le serveur)
    connexion.close()

Ce qu’il faut comprendre ligne par ligne

socket.socket(socket.AF_INET, socket.SOCK_STREAM) AF_INET veut dire IPv4. SOCK_STREAM veut dire TCP (pour UDP, ce serait SOCK_DGRAM). Ces deux constantes seront toujours les mêmes dans ce cours.

setsockopt(… SO_REUSEADDR …) Quand un serveur s’arrête, le système d’exploitation garde son port réservé pendant une à deux minutes (pour laisser passer d’éventuels paquets en retard). Sans cette ligne, relancer votre serveur juste après l’avoir arrêté produit Address already in use. Mettez-la toujours.

bind((HOTE, PORT)) Notez la double parenthèse : bind attend un seul argument, qui est un tuple (adresse, port).

listen(5) Le serveur devient passif : il attend. Le 5 est la taille de la file d’attente des connexions non encore acceptées.

accept() Cette ligne bloque : le programme s’arrête là et ne fait rien tant que personne ne se connecte. Elle renvoie deux choses : un nouveau socket, dédié à ce client, et l’adresse du client.

NoteDeux sockets, deux rôles

C’est un point qui déroute souvent. Après accept(), vous avez deux sockets distincts :

  • serveur : le socket d’écoute. Il ne sert qu’à accepter de nouvelles connexions. On ne lit et n’écrit jamais dessus.
  • connexion : le socket de dialogue avec ce client précis. C’est sur lui qu’on fait recv et sendall.

Fermer connexion met fin au dialogue avec un client ; fermer serveur arrête le service.

recv(1024) Lit au plus 1024 octets. Le mot « au plus » est important : on y reviendra longuement en séance 3.

sendall(donnees) Envoie tous les octets. Il existe aussi send(), mais send() peut n’envoyer qu’une partie des données et vous laisser gérer le reste. Utilisez toujours sendall().

1.5 Votre premier client

client_echo.py
import socket

HOTE = "127.0.0.1"
PORT = 50007

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect((HOTE, PORT))

client.sendall("Bonjour le reseau".encode())

reponse = client.recv(1024)
print("Reponse du serveur :", reponse.decode())

client.close()

Le client est plus simple : pas de bind, pas de listen, pas de accept. Il se connecte, parle, écoute, raccroche.

Lancer les deux programmes

Il vous faut deux terminaux. Dans le premier :

$ python3 serveur_echo.py
Serveur en ecoute sur 127.0.0.1:50007

Le terminal semble figé : c’est normal, le serveur est bloqué dans accept(). Dans le second terminal :

$ python3 client_echo.py
Reponse du serveur : Bonjour le reseau

Et dans le premier terminal, vous voyez apparaître :

Connexion de ('127.0.0.1', 54321)
Recu : Bonjour le reseau

Pour arrêter le serveur : Ctrl+C.

AvertissementAttention

Ordre obligatoire : le serveur d’abord, le client ensuite. Si vous lancez le client seul, vous obtenez ConnectionRefusedError: [Errno 61] Connection refused. Cette erreur signifie littéralement : personne n’écoute sur ce port.

1.6 Un serveur utile : la charge de la machine

Un serveur echo ne sert à rien. Faisons un serveur qui expose une information réelle : la charge CPU de la machine.

Sous Linux et macOS, la commande uptime affiche trois nombres appelés load average : la charge moyenne du système sur les 1, 5 et 15 dernières minutes.

$ uptime
 14:32:07 up 12 days,  3:41,  2 users,  load average: 0.42, 0.55, 0.61

Grossièrement : une charge de 1,0 signifie qu’un cœur est occupé en permanence. Sur une machine à 8 cœurs, une charge de 8 signifie que la machine est pleinement utilisée ; une charge de 0,4 qu’elle est presque libre.

Python donne accès à ces trois nombres avec os.getloadavg(), qui renvoie un tuple de trois flottants.

AvertissementSur Windows, cette fonction n’existe pas

os.getloadavg() est propre aux systèmes Unix : elle fonctionne sur les machines de l’école et sur macOS, mais lève AttributeError sous Windows. Windows ne tient tout simplement pas cette statistique.

Si vous travaillez sous Windows, remplacez l’appel par cette fonction, qui n’utilise que la bibliothèque standard :

import os
import subprocess

def charge_moyenne():
    """Charge sur 1, 5 et 15 min. Valeur approchee sous Windows."""
    try:
        return os.getloadavg()              # Linux et macOS
    except AttributeError:
        pourcentage = subprocess.run(
            ["powershell", "-NoProfile", "-Command",
             "(Get-CimInstance Win32_Processor |"
             " Measure-Object -Property LoadPercentage -Average).Average"],
            capture_output=True, text=True, timeout=10).stdout.strip()
        occupe = float(pourcentage or 0) / 100 * (os.cpu_count() or 1)
        return (occupe, occupe, occupe)

Windows ne fournissant qu’un pourcentage d’occupation instantané, on le convertit en un équivalent de charge en le multipliant par le nombre de cœurs, et on renvoie trois fois la même valeur. C’est une approximation : il n’y a pas d’historique sur 5 et 15 minutes.

NoteLa règle générale

Le cours s’appuie très peu sur le système d’exploitation, et le peu qu’il en utilise est signalé comme ici. Deux principes à garder en tête :

  1. Aucune bibliothèque externe. Pas de psutil, pas de requests : le jour de l’évaluation, le réseau est coupé et rien ne s’installe.
  2. L’évaluation a lieu sur les machines Linux de l’école. Préparez vos exercices où vous voulez, mais vérifiez-les au moins une fois là-bas.
serveur_charge.py
import socket
import os
import sys

# Le port peut etre passe en argument, pour lancer plusieurs serveurs
# sur la meme machine.
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 50007

serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serveur.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serveur.bind(("0.0.0.0", PORT))   # accessible depuis le reseau
serveur.listen(5)
print(f"Serveur de charge en ecoute sur le port {PORT}")

while True:
    connexion, adresse = serveur.accept()
    charge1, charge5, charge15 = os.getloadavg()
    message = f"{charge1:.2f} {charge5:.2f} {charge15:.2f}\n"
    connexion.sendall(message.encode())
    connexion.close()
client_charge.py
import socket
import sys

HOTE = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
PORT = 50007

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect((HOTE, PORT))

reponse = client.recv(1024).decode().strip()
client.close()

c1, c5, c15 = [float(x) for x in reponse.split()]
print(f"{HOTE} -> 1min={c1}  5min={c5}  15min={c15}")

Notez le sys.argv[1] : le client accepte l’adresse du serveur en argument de ligne de commande, ce qui permet d’interroger n’importe quelle machine :

$ python3 client_charge.py tp-1a201-05.enst.fr
tp-1a201-05.enst.fr -> 1min=0.42  5min=0.55  15min=0.61
NotePour l’instant, testez en local

Cet exemple suppose que serveur_charge.py tourne déjà sur la machine distante. Installer un programme sur une autre machine demande SSH, et c’est l’objet de la séance 2. En attendant, lancez votre serveur sur votre propre machine et interrogez 127.0.0.1.

AstucePourquoi c’est important

Ce petit programme est le germe de tout ce que nous ferons ensuite. En séance 2, nous l’utiliserons pour interroger cent machines en parallèle et savoir lesquelles sont disponibles. En séance 7, ces mêmes machines exécuteront notre MapReduce. Un système distribué commence toujours par là : savoir qui est vivant et dans quel état.

NoteUn premier protocole, sans le savoir

Nous venons de définir un protocole, c’est-à-dire un accord sur la forme des messages : le serveur envoie trois nombres décimaux séparés par des espaces et terminés par un retour à la ligne. Le client peut décoder la réponse uniquement parce qu’il connaît cette convention. Si le serveur décidait d’envoyer les nombres séparés par des virgules, le client planterait. Nous formaliserons cette idée en séance 3.

1.7 Exercices

Exercice 1.1 — Serveur horloge

Écrivez serveur_heure.py : un serveur qui, à chaque connexion, envoie la date et l’heure courante puis ferme la connexion.

Indication : from datetime import datetime, puis datetime.now().strftime("%Y-%m-%d %H:%M:%S").

Écrivez également le client correspondant. Testez avec deux terminaux.

Exercice 1.2 — Le serveur qui compte

Modifiez le serveur echo pour qu’il renvoie, au lieu du message reçu, le nombre de caractères de ce message.

Puis faites en sorte que le serveur affiche dans sa console le nombre total de clients qu’il a servis depuis son démarrage.

Exercice 1.3 — Interroger plusieurs machines

Vous disposez du serveur_charge.py vu en cours. Lancez-le trois fois sur votre machine, dans trois terminaux, sur trois ports différents :

$ python3 serveur_charge.py 50007     # terminal 1
$ python3 serveur_charge.py 50008     # terminal 2
$ python3 serveur_charge.py 50009     # terminal 3

Vos trois « machines » sont alors ("127.0.0.1", 50007), 50008 et 50009. Déployer sur de vraies machines distantes demande SSH : ce sera l’objet de la séance 2.

Écrivez moyenne_charge.py qui :

  1. se connecte successivement aux trois serveurs ;
  2. récupère les trois charges à 1 minute ;
  3. affiche la charge de chaque machine, puis la moyenne des trois.

Votre programme doit ne pas planter si l’une des machines ne répond pas : affichez alors INJOIGNABLE pour cette machine et calculez la moyenne sur les machines restantes.

Indication : entourez la connexion d’un try: ... except OSError: ....

Exercice 1.4 — Serveur calculatrice

Écrivez un serveur qui reçoit une opération sous forme de texte, par exemple "12 + 30", et renvoie le résultat, ici "42".

Il doit gérer les quatre opérations +, -, *, /. En cas de division par zéro ou de message mal formé, il renvoie "ERREUR" au lieu de planter.

Attention : n’utilisez pas eval(). C’est une faille de sécurité majeure — un client malveillant pourrait envoyer "__import__('os').system('rm -rf /')". Découpez la chaîne avec split() et traitez l’opérateur avec des if.

Exercice 1.5 — Observer le blocage

Lancez serveur_echo.py, puis lancez deux clients en même temps (dans deux terminaux séparés). Pour bien voir ce qui se passe, ajoutez import time et time.sleep(10) juste avant le sendall du serveur.

Décrivez ce que vous observez : que fait le deuxième client pendant que le premier est servi ? Combien de temps attend-il ?

Cette expérience est le point de départ de la séance 2.

1.8 Corrigés

# serveur_heure.py
import socket
from datetime import datetime

PORT = 50007
serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serveur.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serveur.bind(("0.0.0.0", PORT))
serveur.listen(5)
print(f"Serveur heure sur le port {PORT}")

while True:
    connexion, adresse = serveur.accept()
    maintenant = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    connexion.sendall(maintenant.encode())
    connexion.close()

# client_heure.py
import socket
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("127.0.0.1", 50007))
print("Heure du serveur :", client.recv(1024).decode())
client.close()
import socket

PORT = 50007
clients_servis = 0

serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serveur.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serveur.bind(("0.0.0.0", PORT))
serveur.listen(5)

while True:
    connexion, adresse = serveur.accept()
    clients_servis += 1
    donnees = connexion.recv(1024)
    nb = len(donnees.decode())
    connexion.sendall(str(nb).encode())
    connexion.close()
    print(f"Client no {clients_servis} : message de {nb} caracteres")

Ici le compteur est sûr, car le serveur est itératif : un seul client à la fois, donc pas de concurrence. Ce ne sera plus vrai en séance 2.

import socket

SERVEURS = [("127.0.0.1", 50007),
            ("127.0.0.1", 50008),
            ("127.0.0.1", 50009)]

charges = []
for hote, port in SERVEURS:
    try:
        client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        client.settimeout(3.0)
        client.connect((hote, port))
        reponse = client.recv(1024).decode().strip()
        client.close()
        charge1 = float(reponse.split()[0])
        charges.append(charge1)
        print(f"{hote}:{port} -> {charge1:.2f}")
    except (OSError, ValueError, IndexError):
        print(f"{hote}:{port} -> INJOIGNABLE")

if charges:
    print(f"\nMoyenne sur {len(charges)} machines : "
          f"{sum(charges) / len(charges):.2f}")
else:
    print("\nAucune machine n'a repondu")

Points clés : le try/except entoure toute la séquence réseau, et le calcul de la moyenne est protégé contre la division par zéro.

import socket

def calculer(texte):
    morceaux = texte.strip().split()
    if len(morceaux) != 3:
        return "ERREUR"
    a_txt, operateur, b_txt = morceaux
    try:
        a, b = float(a_txt), float(b_txt)
    except ValueError:
        return "ERREUR"

    if operateur == "+":  return str(a + b)
    if operateur == "-":  return str(a - b)
    if operateur == "*":  return str(a * b)
    if operateur == "/":
        return "ERREUR" if b == 0 else str(a / b)
    return "ERREUR"

PORT = 50007
serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serveur.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serveur.bind(("0.0.0.0", PORT))
serveur.listen(5)

while True:
    connexion, _ = serveur.accept()
    demande = connexion.recv(1024).decode()
    connexion.sendall(calculer(demande).encode())
    connexion.close()

eval() exécuterait n’importe quel code Python envoyé par le client. L’analyse manuelle n’accepte que ce qui est prévu : c’est le principe de la liste blanche.

Le premier client reçoit sa réponse au bout de 10 secondes. Le second, lui, doit attendre 20 secondes : sa connexion a bien été acceptée par le système d’exploitation (elle est dans la file de listen), mais le programme serveur ne revient à accept() qu’après avoir entièrement terminé avec le premier client.

Avec nn clients simultanés, le dernier attend 10n10n secondes. C’est le comportement d’un serveur itératif, et la raison d’être de la séance 2.

1.9 Ce qu’il faut retenir

ImportantL’essentiel de la séance
  • On distribue parce qu’une machine seule est trop lente, pas parce que les données sont grosses.
  • Une communication réseau est identifiée par une adresse IP (la machine) et un port (le programme).
  • Le serveur fait socket, bind, listen, accept ; le client fait socket, connect. Ensuite les deux font sendall et recv.
  • accept() et recv() sont bloquants.
  • On envoie et on reçoit des bytes : .encode() pour partir, .decode() pour arriver.
  • TCP garantit que les octets arrivent dans l’ordre. Pas que vos messages restent séparés.