2 Servir plusieurs clients, interroger plusieurs machines
Objectifs de la séance
- Comprendre pourquoi un serveur simple ne peut servir qu’un client à la fois.
- Rendre un serveur concurrent avec des threads.
- Interroger machines en parallèle au lieu de les interroger l’une après l’autre.
- Ne jamais rester bloqué : maîtriser les timeouts.
2.1 Le problème : un client à la fois
Reprenons le serveur echo de la séance 1. Sa boucle principale est :
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) raccrocherTant que le serveur est aux étapes (2), (3) et (4) avec un client, il n’est pas à l’étape (1). Il ne peut donc accepter personne d’autre. Un deuxième client qui se connecte est mis en file d’attente par le système d’exploitation (c’est le rôle du 5 de listen(5)) et attend son tour.
C’est ce que vous avez observé à l’exercice 1.5 : avec un time.sleep(10), le second client attend dix secondes de plus que le premier.
Imaginez un site web construit ainsi : si une page met 200 ms à s’afficher, le serveur ne peut servir que 5 visiteurs par seconde. Le sixième attend. Le centième attend vingt secondes. Aucun service réel ne peut fonctionner ainsi. La concurrence n’est pas une optimisation : c’est une nécessité.
Un serveur qui traite les clients l’un après l’autre est dit itératif (ou séquentiel). Un serveur capable d’en traiter plusieurs simultanément est dit concurrent.
2.2 Les threads : faire plusieurs choses à la fois
Un thread (fil d’exécution) est une tâche qui s’exécute en parallèle du reste du programme, dans le même processus. En Python, on les crée avec le module threading.
Un exemple minimal, 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 threadTrois choses à retenir :
Thread(target=fonction, args=(…,))crée le thread. Attention à la virgule dansargs: c’est un tuple.start()le démarre et rend la main immédiatement.join()attend qu’il soit fini.
args=(nom) ne fonctionne pas : ce ne sont que de simples parenthèses, pas un tuple. Il faut écrire args=(nom,) avec une virgule, ou args=[nom].
Le serveur concurrent
L’idée est simple : dès qu’un client est accepté, on confie son traitement à un thread, et la boucle retourne immédiatement attendre le client suivant.
serveur_concurrent.py
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)
print(f"Serveur concurrent en ecoute sur le port {PORT}")
while True:
connexion, adresse = serveur.accept()
t = threading.Thread(
target=servir_client,
args=(connexion, adresse),
daemon=True,
)
t.start()
# La boucle repart immediatement vers accept()daemon=True indique que ce thread ne doit pas empêcher le programme de se terminer. Sans cette option, un Ctrl+C laisserait le processus vivant tant que des clients sont connectés.
Le bloc try / finally garantit que la connexion est fermée quoi qu’il arrive. Si vous oubliez de fermer les connexions, votre serveur finira par atteindre la limite de descripteurs de fichiers du système et refusera toute nouvelle connexion avec OSError: [Errno 24] Too many open files.
Le piège des données partagées
Dès que plusieurs threads modifient la même variable, il y a danger. Supposons que l’on veuille compter les clients servis :
compteur = 0
def servir_client(connexion, adresse):
global compteur
compteur = compteur + 1 # DANGEREUX
...L’instruction compteur = compteur + 1 n’est pas atomique : elle se décompose en lire, ajouter 1, écrire. Si deux threads lisent la valeur 10 en même temps, tous deux écrivent 11 : un client a disparu du comptage. C’est ce qu’on appelle une condition de course (race condition).
La solution est un 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}")
...Règle simple et suffisante pour ce cours : toute variable modifiée par plusieurs threads doit être protégée par un verrou. Le mieux reste d’éviter complètement les variables partagées.
Python possède un Global Interpreter Lock qui empêche deux threads d’exécuter du calcul Python en même temps. Les threads n’accélèrent donc pas les calculs. En revanche ils fonctionnent très bien pour l’attente : réseau, disque, sleep. Comme un serveur passe l’essentiel de son temps à attendre, les threads sont parfaits ici. Pour paralléliser du calcul, il faudra le module multiprocessing : nous le verrons en séance 6.
2.3 L’autre côté : interroger machines
Jusqu’ici nous avons rendu le serveur concurrent. Le problème symétrique se pose côté client : comment interroger cent machines ?
La version séquentielle, et son coût
resultats = {}
for machine in machines: # 100 machines
resultats[machine] = interroger(machine)Si chaque interrogation prend 200 ms (connexion réseau comprise), le total est de secondes. Or, pendant ces 20 secondes, votre programme ne calcule rien : il attend. Il pourrait attendre les cent machines en même temps.
La version parallèle
Le module concurrent.futures fournit un pool de threads qui rend cela très simple.
interroger_parallele.py
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)
def main():
machines = [ligne.strip()
for ligne in open("machines.txt")
if ligne.strip()]
with ThreadPoolExecutor(max_workers=50) as pool:
resultats = list(pool.map(interroger, machines))
vivantes = [(m, c) for (m, c) in resultats if c is not None]
mortes = [m for (m, c) in resultats if c is None]
for machine, charge in sorted(vivantes, key=lambda x: x[1]):
print(f"{machine:35s} {charge:6.2f}")
print(f"\n{len(vivantes)} machines vivantes, "
f"{len(mortes)} injoignables")
if vivantes:
moyenne = sum(c for _, c in vivantes) / len(vivantes)
print(f"Charge moyenne : {moyenne:.2f}")
main()ThreadPoolExecutor(max_workers=50) crée un réservoir de 50 threads. Les 100 tâches y sont distribuées : dès qu’un thread se libère, il prend la tâche suivante.
pool.map(fonction, liste) applique la fonction à chaque élément, en parallèle, et renvoie les résultats dans l’ordre de la liste d’entrée — et non dans l’ordre d’arrivée. C’est très pratique.
Le with garantit que l’on attend la fin de toutes les tâches avant de continuer.
Avec 50 threads, les 100 interrogations prennent environ ms, soit 0,4 seconde au lieu de 20.
Le timeout : la ligne la plus importante
client.settimeout(3.0)Sans cette ligne, si une machine est éteinte ou si un câble est débranché, connect() peut bloquer plusieurs minutes avant d’abandonner. Avec cent machines dont trois sont en panne, votre programme reste figé sans raison apparente.
settimeout(3.0) impose la règle suivante : si une opération (connect, recv, sendall) n’aboutit pas en 3 secondes, elle lève une exception socket.timeout — qui hérite de OSError et est donc attrapée par notre except.
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 à cette question : il attend indéfiniment. Tout appel réseau doit avoir un timeout. Nous reviendrons sur la difficulté de choisir sa valeur en séance 5.
2.4 Obtenir la liste des machines
Pour travailler sur les machines de l’école, il faut d’abord savoir lesquelles existent et lesquelles sont allumées.
Les machines de TP ne sont pas joignables depuis Internet. Pour les contacter — que ce soit par ssh, par vos programmes, ou pour consulter l’inventaire ci-dessous — vous devez être :
- sur le Wi-Fi
campus-telecom; ou - connecté au VPN de l’école, documenté sur https://doc.telecom-paris.fr/.
Le Wi-Fi eduroam ne convient pas : il est traité comme un réseau extérieur. C’est la cause numéro un des « ça ne marche pas chez moi ».
Un fichier écrit à la main
Le plus simple est un fichier texte, une machine par ligne :
machines.txt
tp-1a201-01.enst.fr
tp-1a201-02.enst.fr
tp-1a201-03.enst.frQue l’on peut générer par un motif :
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")C’est suffisant pour commencer, mais approximatif : rien ne garantit que ces machines existent, ni qu’elles soient allumées.
L’inventaire de l’école
L’école publie l’état des salles de TP sur https://tp.telecom-paris.fr/. La page est alimentée par une petite API qui renvoie du JSON, à l’adresse https://tp.telecom-paris.fr/ajax.php :
{"data": [["tp-1a201-00", false, "", "", ""],
["tp-1a201-01", true, 0, 0, 1],
["tp-1a201-02", true, 0, 1, 0]]}Chaque ligne décrit une machine :
| Position | Signification |
|---|---|
| 0 | nom court de la machine |
| 1 | true si elle est allumée |
| 2 | nombre de sessions locales (quelqu’un est devant) |
| 3 | nombre de sessions de bureau à distance |
| 4 | nombre de sessions SSH |
Une machine est donc libre si elle est allumée et qu’aucune session locale n’est ouverte. Le script suivant construit machines.txt à partir de cet inventaire, avec la seule bibliothèque standard :
lister_machines.py
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 script est votre premier client d’API, et il contient déjà tout ce que nous formaliserons en séance 3 : une requête, une réponse en JSON, un format de données convenu à l’avance, et un timeout. Notez surtout que urllib.request et json sont dans la bibliothèque standard : aucune installation n’est nécessaire.
Le filtre and not locales écarte les machines devant lesquelles quelqu’un est physiquement assis. Lancer trente processus sur le poste d’un camarade en train de travailler est un excellent moyen de se faire remarquer. En séance 7, nous ferons tourner de vrais calculs : prenez l’habitude dès maintenant.
Utilisez toujours le nom canonique complet, avec le suffixe .enst.fr. Selon la configuration du réseau, tp-1a201-05 tout court peut ne pas être résolu et provoquer une erreur socket.gaierror: [Errno 8] nodename nor servname provided.
Se connecter sans mot de passe
En séance 7, nous lancerons des programmes à distance par SSH. Pour que cela fonctionne sans taper cent fois votre mot de passe, il faut une paire de clés SSH — et vous en avez déjà une.
ssh-keygen
Une paire de clés a été configurée pour vous lors du cours qui précède celui-ci. Si vous relancez ssh-keygen en acceptant les valeurs par défaut, vous écrasez cette clé, et vous perdez l’accès configuré pour l’autre cours.
Vérifiez simplement qu’une clé est en place :
$ ls ~/.ssh/
config id_ed25519 id_ed25519.pub known_hostsLe nom du fichier dépend de l’algorithme choisi au moment de la création : vous pouvez tout aussi bien voir id_rsa, id_ecdsa, id_dsa, ou un nom personnalisé. Peu importe : ce qui compte est qu’il existe une paire, un fichier sans extension (la clé privée) et son jumeau en .pub (la clé publique).
Puis testez une connexion, qui ne doit pas demander de mot de passe :
$ ssh login@ssh.enst.fr hostnameComme votre répertoire personnel est partagé entre toutes les machines de l’école par un système de fichiers réseau, une clé en place vaut pour toutes les machines.
Si la commande ci-dessus demande un mot de passe, ou renvoie Permission denied (publickey), c’est que la clé est mal configurée en amont — ou qu’il n’y en a pas. Ne cherchez pas à la créer ni à la régénérer : signalez-le à votre enseignant, qui verra cela avec le responsable du cours précédent.
Avant tout, vérifiez l’évidence : êtes-vous bien sur le réseau de l’école, campus-telecom ou VPN ? Depuis l’extérieur, la connexion échoue quelle que soit la clé.
Deux points à connaître :
- il faut, là encore, être sur
campus-telecomou sur le VPN ; - sous Windows, le client SSH d’OpenSSH est installé par défaut depuis Windows 10 et s’utilise dans PowerShell exactement 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, où tout est déjà configuré.
Ajoutez également ceci dans ~/.ssh/config pour éviter la question « Are you sure you want to continue connecting? » à chaque nouvelle machine :
~/.ssh/config
Host tp-*.enst.fr
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
LogLevel ERRORCette configuration désactive une vérification de sécurité (l’empreinte du serveur, qui protège contre une attaque de l’intercepteur). C’est acceptable sur le réseau fermé d’une salle de TP ; ce ne l’est jamais sur un serveur de production.
Copier un fichier, lancer un programme à distance
Trois commandes suffisent pour tout ce que nous ferons.
ssh — exécuter une commande ailleurs. Sans argument final, il ouvre une session interactive. Suivi d’une commande entre guillemets, il l’exécute sur la machine distante, affiche le résultat, et rend la main :
$ ssh login@tp-1a201-05.enst.fr hostname
tp-1a201-05scp — copier un fichier. Même idée que cp, mais la destination s’écrit machine:chemin. Ici, ~/ désigne votre répertoire personnel sur la machine distante :
$ scp serveur_charge.py login@tp-1a201-05.enst.fr:~/Comme ce répertoire est partagé par toutes les machines, une seule copie suffit : le fichier apparaît partout.
nohup … & — survivre à la déconnexion. Un programme lancé par ssh est tué dès que la connexion se ferme : il reçoit un signal de fin de session. Notre serveur mourrait donc aussitôt. Deux ajouts règlent cela :
$ 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 |
& |
lancer en arrière-plan et rendre la main tout de suite |
> /tmp/charge-\$USER.log 2>&1 |
envoyer sortie et erreurs dans un fichier |
/tmp est partagé par tout le monde
Sur une machine de TP, /tmp est commun à tous les utilisateurs. Si vous écrivez dans /tmp/charge.log et qu’un camarade l’a créé avant vous, le fichier ne vous appartient pas et vous obtenez :
-bash: /tmp/charge.log: Permission denied
D’où le \$USER dans le nom : chacun écrit dans son propre fichier.
C’est exactement le problème de cette séance, transposé aux fichiers : une ressource partagée sans coordination. Le verrou règle le cas des variables ; ici, il suffit de ne pas partager du tout.
ssh machine "... \$USER ..." — sans le \, votre shell local remplacerait $USER avant l’envoi, et vous obtiendriez votre login d’ordinateur personnel au lieu de celui de l’école.
Avec le \, le $USER part tel quel et c’est le shell distant qui l’évalue. Un test où le login local vaut dupont et le distant martin donne /tmp/charge-dupont.log sans échappement, /tmp/charge-martin.log avec.
Sans > /tmp/charge-\$USER.log 2>&1, la commande ssh semble figée : elle attend que le flux de sortie du programme se ferme, ce qui n’arrivera jamais puisque le serveur tourne indéfiniment. Rediriger vers un fichier libère ssh immédiatement — et vous donne en prime un journal à consulter en cas de problème.
Pour vérifier qu’un serveur tourne, ou l’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"Un processus oublié continue de tourner et d’occuper un port. Le camarade qui choisira le même port demain obtiendra Address already in use sans comprendre pourquoi.
2.5 Exercices
Exercice 2.1 — Rendre le serveur concurrent
Reprenez votre serveur_heure.py de l’exercice 1.1 et rendez-le concurrent avec des threads.
Ajoutez un time.sleep(5) avant la réponse, puis lancez trois clients en même temps. Vérifiez que les trois obtiennent leur réponse au bout de 5 secondes, et non de 5, 10 et 15 secondes.
Exercice 2.2 — Compter les clients sans se tromper
Ajoutez à votre serveur concurrent un compteur du nombre total de clients servis. Le serveur affiche "Client no 42 servi" à chaque connexion.
1. Écrivez d’abord la version fausse, exprès, sans verrou — et décomposez l’incrément pour élargir la fenêtre de course :
lu = compteur
time.sleep(0.001)
compteur = lu + 12. Le client. Inutile d’écrire un client à threads, ce serait bien plus compliqué. Il suffit qu’il n’attende pas la réponse :
import socket
for _ in range(500):
c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
c.connect(("127.0.0.1", 50007))
c.close() # aucun recv : on enchaine tout de suite3. Comparez le compteur final à 500, sans verrou puis avec.
Questions.
Ce client est séquentiel : il n’ouvre qu’une connexion à la fois. Comment peut-il provoquer une condition de course côté serveur ?
Ajoutez
c.recv(1024)avantc.close(), puis relancez. Le compteur redevient juste. Pourquoi ?Repartez du client sans
recv, mais retirez letime.sleep(0.001)du serveur. Le compteur redevient juste lui aussi. Le bug a-t-il disparu ? En quoi cette situation est-elle la plus dangereuse des trois ?Dans la version corrigée, où s’arrêtent exactement les bornes du
with verrou:?
Exercice 2.3 — Séquentiel contre parallèle
Il faut d’abord des serveurs à interroger. Nous allons les démarrer à la main, sur trois machines de l’école. Le déploiement automatique sera l’objet de la séance 7.
1. Copier le serveur — une seule fois. Votre répertoire personnel est partagé par toutes les machines : une copie suffit pour qu’il soit visible partout.
$ scp serveur_charge.py login@tp-1a201-05.enst.fr:~/2. Le démarrer sur trois machines. Choisissez-en trois dans l’inventaire, allumées et sans session locale, puis une commande ssh par machine :
$ ssh login@tp-1a201-05.enst.fr \
"nohup python3 ~/serveur_charge.py > /tmp/charge-\$USER.log 2>&1 &"nohup ... & détache le processus : il survit à la fermeture de la connexion SSH. Le \$USER évite de vous battre avec un camarade pour le même fichier de journal — /tmp est partagé.
3. Vérifier avec le client de la séance 1 :
$ python3 client_charge.py tp-1a201-05.enst.fr4. Mesurer. Construisez une liste de six cibles : vos trois machines et trois machines éteintes repérées dans l’inventaire. Interrogez-les séquentiellement, puis avec un ThreadPoolExecutor. Chronométrez avec time.perf_counter() et affichez l’accélération. Faites varier max_workers : 1, 2, 3, 6.
Questions. D’où vient l’essentiel de l’écart entre les deux versions ? À partir de combien de threads le temps cesse-t-il de diminuer ? Ce n’est pas le nombre de cibles : expliquez pourquoi.
5. Ranger. Arrêtez vos serveurs quand vous avez fini :
$ ssh login@tp-1a201-05.enst.fr "pkill -f serveur_charge.py"On ouvre ici trois connexions SSH, une par machine, à la main. N’écrivez pas de boucle qui en lance des dizaines en parallèle : l’école limite le nombre de sessions SSH simultanées. Nous verrons en séance 7 comment déployer proprement, et dans quelles limites.
Exercice 2.4 — Trouver les machines libres
Écrivez machines_libres.py qui, à partir du seul inventaire de l’école :
- récupère la liste complète des postes ;
- écarte les machines éteintes ;
- écarte celles où une session locale est ouverte ;
- écrit les autres dans
vivantes.txt, en noms complets ; - affiche un résumé : total, éteintes, occupées localement, retenues.
Une seule requête HTTP suffit : inutile de contacter les machines une à une.
Question : ce fichier dit quelles machines sont disponibles. Il ne dit pas que votre serveur de charge y répondra. Pourquoi ? Que faut-il faire de plus — et vous venez de le faire à la main pour trois machines.
Ce fichier vivantes.txt nous servira directement en séance 7 pour déployer notre MapReduce.
Exercice 2.5 — Le serveur qui n’oublie pas
Modifiez le serveur concurrent pour qu’il tienne un journal : à chaque connexion, il ajoute une ligne dans le fichier journal.txt avec l’horodatage et l’adresse du client.
Question : plusieurs threads écrivent dans le même fichier. Est-ce dangereux ? Protégez l’écriture par un verrou et expliquez en deux phrases ce que le verrou empêche.
2.6 Corrigés
import socket, threading, time
from datetime import datetime
PORT = 50007
def servir_client(connexion, adresse):
try:
time.sleep(5)
maintenant = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
connexion.sendall(maintenant.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()Les trois clients reçoivent leur réponse au bout de 5 secondes environ, et non 5, 10 et 15.
La version fausse, celle qu’il faut écrire en premier :
serveur_compteur.py — SANS verrou
import socket
import threading
import time
PORT = 50007
compteur = 0
def servir_client(connexion, adresse):
global compteur
lu = compteur
time.sleep(0.001) # elargit la fenetre de course
compteur = lu + 1
numero = compteur
try:
print(f"Client no {numero} servi")
connexion.sendall(str(numero).encode())
except OSError:
pass # le client est peut-etre deja parti
finally:
connexion.close()
serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serveur.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serveur.bind(("127.0.0.1", PORT))
serveur.listen(128)
print(f"Serveur compteur en ecoute sur le port {PORT}")
while True:
connexion, adresse = serveur.accept()
threading.Thread(target=servir_client,
args=(connexion, adresse),
daemon=True).start()Le client, qui n’attend jamais la réponse :
client_rafale.py
import socket
for _ in range(500):
c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
c.connect(("127.0.0.1", 50007))
c.close() # aucun recv : on enchaine tout de suite
print("500 connexions envoyees")La version corrigée ne change que servir_client, et ajoute le verrou :
serveur_compteur.py — AVEC verrou
verrou = threading.Lock()
def servir_client(connexion, adresse):
global compteur
with verrou:
lu = compteur
time.sleep(0.001) # le sleep peut rester : plus de course possible
compteur = lu + 1
numero = compteur
try:
print(f"Client no {numero} servi")
connexion.sendall(str(numero).encode())
except OSError:
pass
finally:
connexion.close()Résultats mesurés sur 500 connexions :
| Version | Compteur final |
|---|---|
| sans verrou | 27 / 500 |
| avec verrou | 500 / 500 |
Le chiffre de gauche varie d’une exécution à l’autre — 26, 27, 31 — puisque c’est précisément le propre d’une course : le résultat dépend de l’ordonnancement des threads. Celui de droite, lui, vaut toujours 500.
Près de 95 % des incréments perdus. Le désastre est total, et pourtant le programme n’affiche aucune erreur.
a) Comment un client séquentiel provoque-t-il une course ?
La concurrence n’est pas chez le client : elle est chez le serveur.
connect() rend la main dès que la poignée de main TCP est terminée. Le système d’exploitation accepte et met en file d’attente les connexions — le 128 de listen(128) — sans attendre que le serveur appelle accept(). Le client peut donc enchaîner ses 500 connexions en quelques millisecondes.
Pendant ce temps, le serveur crée un thread par connexion, et chaque thread reste une milliseconde dans la zone dangereuse. Des dizaines de threads s’y trouvent donc en même temps, lisent la même valeur de compteur, et écrivent tous le même résultat.
Le client n’a pas besoin d’être parallèle. Il lui suffit d’être rapide.
b) Pourquoi recv() fait disparaître le problème ?
Parce qu’il sérialise tout. Le client bloque jusqu’à ce que le serveur ait répondu, donc jusqu’à ce que le thread soit sorti de la zone dangereuse. Il n’y a plus jamais qu’un seul thread à l’intérieur, et la course ne peut plus se produire.
Attention au piège : le code du serveur est tout aussi faux. Seul le test a cessé de le révéler.
c) Sans le sleep, le bug a-t-il disparu ?
Non. Il est devenu invisible. Mesuré sur trois exécutions : 500/500 à chaque fois.
Sans la pause, l’intervalle entre la lecture et l’écriture dure quelques microsecondes. La probabilité qu’un thread soit interrompu précisément là devient minuscule — mais elle n’est pas nulle.
Un bug qui se voit se corrige. Un bug qui ne se voit jamais en test part en production, et se manifeste six mois plus tard sur une machine plus rapide, sous une charge plus forte, ou le jour où l’opération entre la lecture et l’écriture devient un peu plus longue.
La justesse d’un code concurrent ne se démontre pas en l’exécutant. Elle se démontre en raisonnant : cette variable est-elle modifiée par plusieurs threads ? Si oui, verrou — que le test passe ou non.
d) Où s’arrêtent les bornes du verrou ?
Le with doit couvrir la lecture, le calcul et l’écriture, ainsi que la copie du résultat dans une variable locale :
with verrou:
compteur += 1
numero = compteur # la copie est DANS le bloc
print(f"Client no {numero} servi")Si l’on sort du bloc avant de copier, un autre thread a pu modifier compteur entre-temps, et l’on affiche un numéro qui n’est pas le sien.
En revanche, print et sendall doivent rester en dehors : ce sont des opérations lentes, et garder le verrou pendant une écriture réseau sérialiserait tout le serveur — exactement ce que l’on cherchait à éviter.
import socket
import time
from concurrent.futures import ThreadPoolExecutor
PORT, DELAI = 50007, 2.0
VIVANTES = ["tp-1a201-05.enst.fr", # vos trois serveurs
"tp-1a201-06.enst.fr",
"tp-1a201-07.enst.fr"]
ETEINTES = ["tp-1a201-00.enst.fr", # reperees dans l'inventaire
"tp-1a207-00.enst.fr",
"tp-1a222-00.enst.fr"]
CIBLES = VIVANTES + ETEINTES
def interroger(machine):
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.settimeout(DELAI)
try:
client.connect((machine, PORT))
return float(client.recv(1024).decode().split()[0])
except (OSError, ValueError, IndexError):
return None
finally:
client.close()
t0 = time.perf_counter()
[interroger(m) for m in CIBLES]
sequentiel = time.perf_counter() - t0
print(f"sequentiel : {sequentiel:5.2f} s")
for n in [1, 2, 3, 6]:
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=n) as pool:
list(pool.map(interroger, CIBLES))
duree = time.perf_counter() - t0
print(f"{n:3d} threads : {duree:5.2f} s "
f"(acceleration {sequentiel/duree:4.1f}x)")D’où vient l’écart. Les trois machines vivantes répondent en moins d’une milliseconde : sur le réseau de l’école, elles ne coûtent presque rien. Tout le temps vient des trois machines éteintes, qui n’envoient aucune réponse : il faut attendre le timeout complet, 2 secondes chacune.
En séquentiel, ces attentes s’additionnent : environ 6 secondes. En parallèle, elles se recouvrent.
Où se situe le plateau. Les mesures donnent à peu près ceci :
max_workers |
Temps | Accélération |
|---|---|---|
| séquentiel | 6,0 s | 1,0 |
| 1 | 6,0 s | 1,0 |
| 2 | 4,0 s | 1,5 |
| 3 | 2,0 s | 3,0 |
| 6 | 2,0 s | 3,0 |
Le temps cesse de diminuer dès 3 threads, alors qu’il y a 6 cibles.
La raison : seules trois tâches sont lentes. Les trois machines vivantes répondent instantanément et n’ont pas besoin d’un thread pour elles seules — un même thread les traite toutes les trois sans perdre de temps. Ce qui limite, c’est le nombre de tâches qui attendent, et il n’y en a que trois.
Le parallèle ne gagne pas grand-chose sur les machines qui répondent vite. Il gagne énormément sur celles qui ne répondent pas.
Et le bon nombre de threads ne se déduit pas du nombre de tâches : il se déduit du nombre de tâches lentes. Dans un parc de cent machines dont cinq sont en panne, cinq threads suffisent à absorber l’essentiel de l’attente.
machines_libres.py
import json
import urllib.request
URL = "https://tp.telecom-paris.fr/ajax.php"
with urllib.request.urlopen(URL, timeout=10) as reponse:
postes = json.load(reponse)["data"]
eteintes, occupees, retenues = 0, 0, []
for nom, allumee, locales, bureau, ssh in postes:
if not allumee:
eteintes += 1
elif locales:
occupees += 1
else:
retenues.append(nom + ".enst.fr")
retenues.sort()
with open("vivantes.txt", "w") as f:
f.write("\n".join(retenues) + "\n")
print(f"total : {len(postes)}")
print(f"eteintes : {eteintes}")
print(f"occupees localement : {occupees}")
print(f"retenues : {len(retenues)}")Pourquoi vivantes.txt ne garantit rien sur votre serveur. L’inventaire décrit l’état des machines, pas celui de vos processus. Il dit qu’un poste est allumé et libre ; il ne dit pas qu’un programme à vous y écoute sur le port 50007.
Pour qu’une machine réponde, il faut y déployer le serveur : copier le fichier, puis le lancer. C’est exactement ce que vous venez de faire à la main, par scp et ssh, pour trois machines. La séance 7 automatisera ce geste pour machines.
Retenez la distinction, elle est au cœur des systèmes distribués : la machine est-elle disponible ? et mon service y répond-il ? sont deux questions différentes, et la seconde ne se déduit pas de la première.
import os
import threading
from datetime import datetime
verrou_journal = threading.Lock()
def journaliser(message):
with verrou_journal:
with open("journal.txt", "a", encoding="utf-8") as f:
f.write(message + "\n")
def servir_client(connexion, adresse):
horodatage = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
journaliser(f"{horodatage} {adresse[0]}:{adresse[1]}")
try:
charge1, charge5, charge15 = os.getloadavg()
connexion.sendall(
f"{charge1:.2f} {charge5:.2f} {charge15:.2f}\n".encode())
finally:
connexion.close()adresse est le couple (ip, port) renvoyé par accept() : on journalise les deux, car le port distingue deux connexions venues de la même machine. Le fichier obtenu ressemble à ceci :
journal.txt
2026-03-14 10:22:01 127.0.0.1:54312
2026-03-14 10:22:01 137.194.2.18:41908Oui, c’est dangereux. Deux threads qui écrivent dans le même fichier sans se coordonner peuvent produire des lignes entrelacées, du type 2026-03-14 10:2026-03-14 10:22:01 ... : rien ne garantit qu’une écriture soit menée à son terme avant qu’une autre ne commence.
En pratique, avec CPython et des lignes courtes, l’entrelacement est rare — la protection dépend de la taille des écritures et du tampon du fichier, et non d’une garantie du langage. C’est précisément ce qui rend le bug dangereux : il ne se manifeste ni sur votre machine, ni dans vos tests, mais en production, avec des lignes plus longues ou davantage de clients.
Le verrou garantit qu’une ligne est écrite entièrement avant qu’une autre ne commence, quelles que soient sa taille et la charge.
2.7 Ce qu’il faut retenir
- Un serveur sans threads ne sert qu’un client à la fois.
- Un thread par client rend le serveur concurrent : on crée le thread juste après
accept(). - Toute variable partagée entre threads doit être protégée par un
threading.Lock. - Côté client,
ThreadPoolExecutorpermet d’interroger machines en parallèle ;pool.mapconserve l’ordre. settimeout()est obligatoire : sans lui, une seule machine en panne fige tout le programme.- En Python, les threads accélèrent l’attente, pas le calcul (à cause du GIL).