Séance 2

Servir plusieurs clients, interroger plusieurs machines

Rémi Sharrock

BGD701 — Télécom Paris

Objectifs

  • Comprendre pourquoi un serveur simple ne sert qu’un client à la fois
  • Rendre un serveur concurrent avec des threads
  • Interroger NN machines en parallèle
  • Ne jamais rester bloqué : maîtriser les timeouts

Un client à la fois

La boucle de la séance 1

while True:
    connexion, adresse = serveur.accept()   # (1) attendre un client
    donnees = connexion.recv(1024)          # (2) le lire
    connexion.sendall(donnees)              # (3) lui repondre
    connexion.close()                       # (4) raccrocher

Tant que le serveur est en (2), (3) ou (4), il n’est pas en (1).

Le client suivant attend dans la file du système — le 5 de listen(5).

Vous l’avez déjà observé

Exercice 1.5 : avec un time.sleep(10), le second client attend dix secondes de plus que le premier.

Pourquoi c’est important

Un site web bâti ainsi, avec 200 ms par page, sert 5 visiteurs par seconde. Le centième attend vingt secondes.

La concurrence n’est pas une optimisation. C’est une nécessité.

Vocabulaire

Itératif

Un client après l’autre.

C’est ce que nous avons écrit.

Concurrent

Plusieurs clients à la fois.

C’est ce que nous voulons.

Les threads

Un exemple hors réseau

import threading
import time

def travail(nom, duree):
    print(f"{nom} commence")
    time.sleep(duree)
    print(f"{nom} termine")

# Sans thread : 3 secondes au total
travail("A", 1)
travail("B", 1)
travail("C", 1)

# Avec threads : 1 seconde au total
threads = []
for nom in ["A", "B", "C"]:
    t = threading.Thread(target=travail, args=(nom, 1))
    t.start()          # lance le thread, ne bloque pas
    threads.append(t)

for t in threads:
    t.join()           # attend la fin de ce thread

Trois choses à retenir

  • Thread(target=fonction, args=(…,)) crée le thread
  • start() le démarre et rend la main immédiatement
  • join() attend qu’il soit fini

Le piège de la virgule

args=(nom) ne marche pas : ce sont de simples parenthèses, pas un tuple.

Écrivez args=(nom,) — ou args=[nom].

Le serveur concurrent

import socket
import threading
import os

PORT = 50007

def servir_client(connexion, adresse):
    """Execute dans un thread dedie a ce client."""
    try:
        charge1, charge5, charge15 = os.getloadavg()
        message = f"{charge1:.2f} {charge5:.2f} {charge15:.2f}\n"
        connexion.sendall(message.encode())
    except OSError as e:
        print(f"Erreur avec {adresse} : {e}")
    finally:
        connexion.close()

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(64)

while True:
    connexion, adresse = serveur.accept()
    threading.Thread(target=servir_client,
                     args=(connexion, adresse),
                     daemon=True).start()
    # La boucle repart immediatement vers accept()

Deux détails qui comptent

daemon=True — ce thread n’empêche pas le programme de se terminer. Sans lui, Ctrl+C laisse le processus vivant tant qu’un client est connecté.

try / finally — la connexion est fermée quoi qu’il arrive.

Oubliez de fermer, et le serveur atteint la limite de descripteurs : OSError: [Errno 24] Too many open files.

Sous Windows

os.getloadavg() n’existe pas

Fonction Unix. Elle marche à l’école et sur macOS, pas sous Windows.

Solution de repli dans le cours, en bibliothèque standard uniquement.

Aucune bibliothèque externe : en examen le réseau est coupé, rien ne s’installe. Et testez sur une machine de l’école au moins une fois.

Les données partagées

Compter les clients

compteur = 0

def servir_client(connexion, adresse):
    global compteur
    compteur = compteur + 1     # DANGEREUX
    ...

compteur = compteur + 1 n’est pas atomique : lire, ajouter, écrire.

Deux threads lisent 10 en même temps, tous deux écrivent 11. Un client a disparu du comptage.

C’est une condition de course (race condition).

Le verrou

import threading

compteur = 0
verrou = threading.Lock()

def servir_client(connexion, adresse):
    global compteur
    with verrou:            # un seul thread a la fois ici
        compteur += 1
        numero = compteur
    print(f"Client numero {numero}")

À retenir

Toute variable modifiée par plusieurs threads doit être protégée par un verrou. Mieux encore : évitez les variables partagées.

Le GIL, en deux phrases

Python a un Global Interpreter Lock : deux threads ne peuvent pas exécuter du calcul Python en même temps.

Les threads n’accélèrent donc pas les calculs. Ils excellent pour l’attente : réseau, disque, sleep.

Un serveur passe son temps à attendre : les threads sont parfaits ici. Pour paralléliser du calcul, ce sera multiprocessing, en séance 6.

Interroger NN machines

Le coût du séquentiel

resultats = {}
for machine in machines:            # 100 machines
    resultats[machine] = interroger(machine)

À 200 ms par machine : 100×0,2=20100 \times 0{,}2 = 20 secondes.

Pendant ces 20 secondes, le programme ne calcule rien. Il attend. Il pourrait attendre les cent machines en même temps.

En parallèle

import socket
from concurrent.futures import ThreadPoolExecutor

PORT = 50007
DELAI = 3.0        # timeout en secondes

def interroger(machine):
    """Renvoie (machine, charge1min) ou (machine, None) si echec."""
    try:
        client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        client.settimeout(DELAI)
        client.connect((machine, PORT))
        reponse = client.recv(1024).decode().strip()
        client.close()
        charge1 = float(reponse.split()[0])
        return (machine, charge1)
    except (OSError, ValueError, IndexError):
        return (machine, None)

machines = [l.strip() for l in open("machines.txt") if l.strip()]

with ThreadPoolExecutor(max_workers=50) as pool:
    resultats = list(pool.map(interroger, machines))

Ce qu’il faut comprendre

ThreadPoolExecutor(max_workers=50) — un réservoir de 50 threads. Dès qu’un se libère, il prend la tâche suivante.

pool.map(fonction, liste) — renvoie les résultats dans l’ordre de la liste d’entrée, pas dans l’ordre d’arrivée.

with — attend la fin de toutes les tâches.

100 interrogations en 0,4 seconde au lieu de 20.

La ligne la plus importante

client.settimeout(3.0)

Sans elle, une machine éteinte peut bloquer connect() plusieurs minutes.

Avec elle, toute opération qui n’aboutit pas en 3 secondes lève une exception — qui hérite de OSError, donc attrapée par notre except.

La bonne question

Dans un système distribué

La question n’est jamais « est-ce que ça peut échouer ? »

mais « que fait mon programme quand ça échoue ? »

Un programme sans timeout n’a pas de réponse : il attend indéfiniment. Tout appel réseau doit avoir un timeout.

Les machines de l’école

D’abord : être sur le réseau de l’école

Les machines de TP ne sont pas joignables depuis Internet.

Il faut être sur campus-telecom ou sur le VPN

  • Wi-Fi campus-telecom : d’accord
  • VPN de l’école (https://doc.telecom-paris.fr/) : d’accord
  • Wi-Fi eduroam : non, considéré comme un réseau extérieur

C’est la cause numéro un des « ça ne marche pas chez moi ».

La liste des machines, à la main

machines.txt
tp-1a201-01.enst.fr
tp-1a201-02.enst.fr
tp-1a201-03.enst.fr
with open("machines.txt", "w") as f:
    for salle in ["1a201", "1a207", "1a222"]:
        for numero in range(1, 21):
            f.write(f"tp-{salle}-{numero:02d}.enst.fr\n")

Suffisant pour commencer, mais rien ne garantit que ces machines existent ni qu’elles soient allumées.

L’inventaire de l’école

https://tp.telecom-paris.fr/ — alimenté par une petite API JSON :

{"data": [["tp-1a201-00", false, "", "", ""],
          ["tp-1a201-01", true,  0,  0,  1],
          ["tp-1a201-02", true,  0,  1,  0]]}
Position Signification
0 nom court
1 allumée ?
2 sessions locales
3 bureau à distance
4 sessions SSH

Votre premier client d’API

import json
import urllib.request

URL = "https://tp.telecom-paris.fr/ajax.php"

def machines_libres():
    """Machines allumees et sans session locale, en nom complet."""
    with urllib.request.urlopen(URL, timeout=10) as reponse:
        donnees = json.load(reponse)

    libres = []
    for nom, allumee, locales, bureau, ssh in donnees["data"]:
        if allumee and not locales:
            libres.append(nom + ".enst.fr")
    return sorted(libres)

if __name__ == "__main__":
    noms = machines_libres()
    with open("machines.txt", "w") as f:
        f.write("\n".join(noms) + "\n")
    print(f"{len(noms)} machines ecrites dans machines.txt")

Ce que ce script contient déjà

Une requête, une réponse JSON, un format convenu à l’avance, un timeout. Nous formaliserons tout cela en séance 3.

urllib.request et json sont dans la bibliothèque standard : rien à installer.

Une question de courtoisie

and not locales écarte les machines devant lesquelles quelqu’un est assis. Lancer trente processus sur le poste d’un camarade en train de travailler est un excellent moyen de se faire remarquer.

SSH sans mot de passe

Ne lancez pas ssh-keygen

Une paire de clés a été configurée lors du cours précédent. La régénérer l’écrase, et vous perdez l’accès de l’autre cours.

Vérifiez, puis testez :

$ ls ~/.ssh/
$ ssh login@ssh.enst.fr hostname

Si on vous demande un mot de passe : ne créez rien, venez me voir.

Depuis votre ordinateur personnel

  • Il faut, là encore, être sur campus-telecom ou sur le VPN
  • Sous Windows, le client OpenSSH est installé par défaut et s’utilise dans PowerShell comme ici

La gestion des clés y diffère un peu, et nous ne nous y attarderons pas. Si vous butez dessus : passez sur une machine de l’école, tout y est déjà configuré.

Éviter la question sur l’empreinte

~/.ssh/config
Host tp-*.enst.fr
    StrictHostKeyChecking no
    UserKnownHostsFile /dev/null
    LogLevel ERROR

Attention

Ceci désactive une protection contre l’attaque de l’intercepteur. Acceptable sur le réseau fermé d’une salle de TP. Jamais en production.

À vous

Trois commandes suffisent

ssh — exécuter une commande ailleurs

$ ssh login@tp-1a201-05.enst.fr hostname
tp-1a201-05

scp — copier un fichier ; la destination s’écrit machine:chemin

$ scp serveur_charge.py login@tp-1a201-05.enst.fr:~/

Le répertoire personnel étant partagé, une seule copie suffit : le fichier apparaît sur toutes les machines.

Survivre à la déconnexion

Un programme lancé par ssh est tué quand la connexion se ferme.

$ ssh login@tp-1a201-05.enst.fr \
      "nohup python3 ~/serveur_charge.py > /tmp/charge-\$USER.log 2>&1 &"
Élément Rôle
nohup no hangup : ignorer le signal de fin de session
& arrière-plan, rend la main tout de suite
> /tmp/charge-\$USER.log 2>&1 sortie et erreurs dans un fichier

/tmp est partagé par tous

Sans \$USER dans le nom, le fichier créé par un camarade vous bloque : Permission denied.

Le \ compte : sans lui, $USER serait évalué sur votre machine.

La redirection n’est pas optionnelle

Sans elle, ssh semble figé : il attend la fermeture du flux de sortie, qui n’arrivera jamais.

Vérifier et arrêter

$ ssh login@tp-1a201-05.enst.fr "pgrep -af serveur_charge.py"
$ ssh login@tp-1a201-05.enst.fr "pkill -f serveur_charge.py"

Arrêtez toujours vos serveurs. Un processus oublié occupe un port, et le camarade qui le choisira demain obtiendra Address already in use sans comprendre pourquoi.

Les exercices

  • 2.1 — Serveur concurrent. Trois clients, sleep(5) : 5 s pour tous, pas 5, 10 et 15.
  • 2.2 — Compter sans se tromper. 500 connexions, avec et sans verrou.
  • 2.3 — Séquentiel contre parallèle. Démarrez trois serveurs par ssh, ajoutez trois machines éteintes, chronométrez.
  • 2.4 — Machines libres. Depuis l’inventaire, produire vivantes.txt — il servira en séance 7.
  • 2.5 — Le serveur qui n’oublie pas. Un journal écrit par plusieurs threads. Est-ce dangereux ?

Ce qu’il faut retenir

  • Un serveur itératif ne sert qu’un client à la fois
  • Un thread par client, daemon=True, et finally: close()
  • Toute variable partagée se protège par un verrou
  • Le GIL : les threads servent à attendre, pas à calculer
  • ThreadPoolExecutor + pool.map pour interroger NN machines
  • Tout appel réseau doit avoir un timeout
  • Les machines de l’école exigent campus-telecom ou le VPN