TCP & Sockets :
la plomberie des serveurs
HTTP, Redis, PostgreSQL, SSH — ils ont tous une chose en commun : au fond, ils s'appuient sur des sockets TCP. Construire un serveur TCP de zéro, c'est comprendre ce que tous ces outils font en dessous.
Un serveur web, un serveur de base de données, un cache Redis : à première vue, des bêtes très différentes. Mais descends d'un niveau — au niveau du système d'exploitation — et tu trouveras exactement la même danse : créer un socket, le lier à un port, écouter les connexions, en accepter une, lire des octets, écrire des octets, fermer. La chorégraphie est la même pour tous.
Ce cours démonte cette mécanique pièce par pièce. Tu commences par comprendre ce qu'est vraiment TCP (un tuyau d'octets fiable, pas un protocole applicatif), tu découvres l'API Berkeley Sockets qui sert d'interface entre ton code et le noyau, et tu termines en construisant toi-même un serveur et un client en Python — une vingtaine de lignes qui font exactement ce que fait Nginx quand il accepte ta connexion.
Les 10 fiches du cours
Un serveur, c'est juste
un processus qui attend
Pas de hardware mystérieux. Juste un programme comme les autres — qui écoute sur un port et échange des octets avec qui se connecte.
Quand on parle de « serveur » ici, on ne parle pas de machines physiques. Un serveur, c'est simplement un processus lancé par le système d'exploitation : il attend des clients, reçoit des données via une connexion réseau, applique sa logique, et renvoie une réponse.
Ce qui le rend « TCP » c'est le transport : au lieu de lire depuis l'entrée standard ou un fichier, il reçoit des octets depuis une connexion TCP. Et à la place d'écrire dans un fichier ou sur la sortie standard, il renvoie des octets sur cette même connexion.
Tous les serveurs font la même danse
Un serveur HTTP, un serveur de base de données, un cache : malgré leurs différences énormes au niveau applicatif, ils partagent tous le même rituel au niveau des sockets :
- Créer un socket
- Le lier (bind) à une adresse et un port
- Passer en mode écoute (listen)
- Accepter une connexion cliente (accept)
- Lire des octets (recv) et en écrire (send)
- Fermer la connexion (close)
Les détails du protocole applicatif changent du tout au tout — mais la plomberie en dessous reste la même. C'est cette plomberie que tu vas apprendre ici.
Quelques protocoles bâtis sur TCP
Dans tous les cas, TCP transporte un flux d'octets — c'est le protocole au-dessus (HTTP, RESP, SSH) qui leur donne un sens.
AF_UNIX) ou TCP (AF_INET) — la même mécanique, avec un protocole applicatif différent : FastCGI. Voir le cours PHP, fiche 05.
Une socket :
une prise dans le noyau
Le système d'exploitation expose une abstraction unifiée pour que deux processus puissent s'échanger des données — localement ou via le réseau. Cette abstraction, c'est la socket.
socket() retourne un descripteur de fichier (un entier). En Python, c'est un objet socket qui encapsule ce descripteur.
L'analogie avec les prises électriques n'est pas accidentelle : le concept a été emprunté au monde réel. Une prise électrique définit une interface standardisée — même forme, même tension — qui permet à n'importe quel appareil de se connecter à n'importe quel mur. Les sockets Berkeley font pareil : une interface unifiée pour connecter des processus, quelle que soit leur nature.
Familles et types de sockets
Une socket se décrit par deux dimensions : sa famille (qui détermine l'espace d'adressage) et son type (qui détermine le style de communication).
| Famille | Signification | Usage typique |
|---|---|---|
AF_INET | IPv4 | Communication réseau via IPv4 |
AF_INET6 | IPv6 | Communication réseau via IPv6 |
AF_UNIX | Local (même machine) | IPC entre processus sur la même machine |
| Type | Signification | Protocole courant |
|---|---|---|
SOCK_STREAM | Flux d'octets fiable et ordonné | TCP (avec AF_INET/AF_INET6) |
SOCK_DGRAM | Datagrammes (sans connexion) | UDP (avec AF_INET/AF_INET6) |
AF_UNIX + SOCK_STREAM n'est pas TCP. C'est un flux local géré directement par le noyau, sans en-têtes IP, sans ports, sans routage. PHP-FPM et Nginx s'y connectent souvent via AF_UNIX pour éviter l'overhead réseau — mais la mécanique socket reste la même.
Dans ce cours, on se concentre sur la combinaison la plus courante : AF_INET + SOCK_STREAM — soit des sockets TCP sur IPv4.
Le descripteur de fichier
En C (et dans le noyau Linux), une socket est représentée par un descripteur de fichier : un entier renvoyé par l'appel socket(). C'est exactement comme l'entier que renvoie open() pour un fichier ordinaire. En Python, la classe socket.socket encapsule ce descripteur — tu peux l'obtenir avec sock.fileno() si tu en as besoin.
Les appels recv() et send() fonctionnent comme read() et write() sur un fichier, mais acceptent en plus un paramètre flags pour des comportements particuliers (comme MSG_PEEK pour lire sans consommer les données).
Des octets fragmentés,
puis réassemblés
Le réseau ne transporte pas des messages entiers. Il transporte de petits paquets numérotés — et c'est TCP qui se charge de les remettre dans l'ordre, même s'ils arrivent en désordre.
La fragmentation
Quand ton programme appelle send(), les données ne traversent pas le réseau en un seul bloc.
La carte réseau les découpe en paquets (la taille maximale dépend du MTU —
Maximum Transmission Unit — typiquement 1500 octets sur Ethernet). Chaque paquet embarque :
- Un en-tête : adresses source et destination, numéro de séquence, somme de contrôle (checksum)
- La charge utile (payload) : les octets de données proprement dits
La couche IP s'occupe du routage : chaque paquet peut emprunter un chemin différent et arriver dans le désordre. C'est TCP qui reprend la main ensuite.
Comment TCP garantit l'ordre et l'intégrité
TCP ajoute au-dessus d'IP deux mécanismes complémentaires :
- Numéros de séquence : chaque octet envoyé porte un numéro. Le récepteur peut ainsi remettre les paquets dans l'ordre exact d'émission, même s'ils sont arrivés dans le désordre.
- Sommes de contrôle : chaque paquet contient un checksum qui permet de détecter les corruptions en transit. Un paquet corrompu est redemandé.
Depuis le point de vue de ton programme, tout ça est invisible : recv() te retourne
les octets dans l'ordre correct, toujours. TCP a géré la plomberie à ta place.
SOCK_DGRAM) abandonne la fiabilité et l'ordre
en échange de la vitesse. Pas de numéros de séquence, pas d'accusé de réception. C'est le choix
de DNS, des jeux en temps réel, ou de QUIC (qui réimplémente sa propre fiabilité par-dessus).
TCP est le bon choix dès que tu as besoin d'exactitude : commandes SQL, pages HTTP, données de configuration.
Simulateur — fragmentation et réassemblage
Clique sur Envoyer pour voir un message découpé en paquets numérotés, voyager dans le désordre, puis être réassemblé correctement par TCP.
Le handshake :
établir la connexion
Avant d'envoyer le premier octet de données, client et serveur s'accordent sur un état partagé. C'est le handshake en 3 temps de TCP.
Une connexion = un accord sur les numéros de séquence
Pour que les deux extrémités puissent numéroter leurs octets et détecter les pertes, elles doivent d'abord se mettre d'accord sur leurs numéros de séquence initiaux. C'est l'objet du handshake TCP en 3 voies (three-way handshake) :
- SYN — le client envoie un paquet de synchronisation avec son numéro de séquence initial.
- SYN-ACK — le serveur accuse réception (ACK) du numéro du client et envoie le sien.
- ACK — le client accuse réception du numéro du serveur. La connexion est établie des deux côtés.
Ce n'est qu'après ce handshake que les données applicatives peuvent circuler.
Les ports : différencier les connexions
Sur une même machine, plusieurs processus peuvent écouter en même temps (un serveur web sur le port 80, une base de données sur le 5432, SSH sur le 22…). Et un même serveur peut avoir des milliers de clients connectés simultanément. Comment distinguer tout ça ?
TCP identifie chaque connexion par un tuple à 4 éléments — ce tuple doit être unique dans le monde entier au moment où la connexion existe :
(IP source, port source, IP destination, port destination)
Exemple :
(203.0.113.42, 51234, 93.184.216.34, 80)Un client depuis son IP, sur son port éphémère, vers un serveur web.
Le port destination est choisi par l'application serveur (80 pour HTTP, 443 pour HTTPS, 5432 pour PostgreSQL…). Le port source du client, lui, est choisi automatiquement par le système d'exploitation : c'est un port éphémère, un numéro temporaire dans la plage 49152–65535 sur Linux.
Simulateur — le handshake TCP en 3 voies
Clique sur Étape suivante pour avancer pas à pas dans l'établissement d'une connexion TCP.
Le contrat entre
ton code et le noyau
L'API Berkeley Sockets, née en 1983 avec BSD Unix, définit les appels système pour créer des connexions réseau. Elle est aujourd'hui présente dans tous les langages : C, Python, Go, Java, Node.js… avec des noms légèrement différents, mais la même logique.
Les appels du côté serveur
| Appel (Python) | Ce qu'il fait | Remarque |
|---|---|---|
socket(AF_INET, SOCK_STREAM) |
Crée un socket TCP/IPv4. Retourne un objet socket (qui encapsule un descripteur de fichier). | Le troisième paramètre proto=0 laisse le noyau choisir le protocole adapté au type (TCP pour SOCK_STREAM). |
bind(('0.0.0.0', 6543)) |
Réserve le port 6543 sur toutes les interfaces réseau (0.0.0.0 = toutes). |
'127.0.0.1' limite l'écoute à la boucle locale (localhost uniquement). |
listen(backlog) |
Bascule le socket en mode écoute. Le paramètre backlog fixe la taille de la file d'attente des connexions entrantes que le noyau maintient avant qu'accept() les traite. |
Une valeur de 10 signifie que le noyau peut maintenir jusqu'à 10 connexions en attente sans les perdre. |
accept() |
Bloque jusqu'à l'arrivée d'un client. Retourne un nouveau socket pour cette connexion, plus l'adresse du client. | Le socket d'écoute originel continue d'écouter. C'est le nouveau socket qui sert à dialoguer avec ce client. |
recv(taille) |
Lit jusqu'à taille octets depuis la connexion. Retourne b'' (bytes vide) quand le client a fermé sa connexion. |
Un appel unique à recv() peut retourner moins d'octets que demandé — il faut souvent boucler. |
sendall(data) |
Envoie tous les octets de data. Réessaie automatiquement si l'envoi est partiel. | send() peut envoyer moins d'octets que demandé sans signaler d'erreur — sendall() est plus sûr. |
close() |
Ferme la connexion et libère les ressources associées. | À appeler sur le socket client après chaque échange, et sur le socket d'écoute à l'arrêt du serveur. |
Les appels du côté client
| Appel (Python) | Ce qu'il fait |
|---|---|
socket(AF_INET, SOCK_STREAM) |
Identique au serveur — crée un socket TCP/IPv4. |
connect(('ip', port)) |
Initie la connexion vers le serveur. Déclenche le handshake TCP en 3 voies. Bloque jusqu'à ce que la connexion soit établie. |
sendall(data) |
Envoie les données vers le serveur. |
shutdown(SHUT_WR) |
Signale au serveur que le client a fini d'envoyer des données (envoie un FIN TCP). La connexion reste ouverte en lecture pour recevoir la réponse. |
recv(taille) |
Lit la réponse du serveur. |
close() |
Ferme la connexion. |
accept() crée un nouveau socket ? Le socket d'écoute est une "oreille" permanente — il reste ouvert pour accueillir d'autres clients. Chaque connexion cliente nécessite son propre socket dédié pour que les échanges de données ne se mélangent pas. Après accept(), tu te retrouves avec deux sockets côté serveur : l'écouteur, et le socket de la connexion en cours.
Le serveur TCP
en Python
Vingt lignes. C'est tout ce qu'il faut pour un serveur TCP fonctionnel qui écoute les connexions, reçoit des données et les renvoie à l'identique (un "echo server").
server.py — code complet
import socket # Crée un socket TCP sur IPv4. # proto=0 : le noyau choisit TCP automatiquement pour SOCK_STREAM. serv_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, proto=0) # Lie le socket à toutes les interfaces (0.0.0.0) sur le port 6543. serv_sock.bind(('0.0.0.0', 6543)) # Passe en mode écoute. Le backlog (10) = taille de la file d'attente noyau. serv_sock.listen(10) while True: # Bloque jusqu'à l'arrivée d'un client. # Retourne un NOUVEAU socket pour cette connexion + l'adresse du client. client_sock, client_addr = serv_sock.accept() print('Nouvelle connexion depuis', client_addr) chunks = [] while True: # Lit jusqu'à 2048 octets. Retourne b'' quand le client a fini d'envoyer. data = client_sock.recv(2048) if not data: break chunks.append(data) # Renvoie tout ce qui a été reçu (echo). client_sock.sendall(b''.join(chunks)) client_sock.close()
Ce que fait chaque partie
socket.socket(AF_INET, SOCK_STREAM, proto=0)
Crée le socket d'écoute. AF_INET = IPv4, SOCK_STREAM = flux TCP fiable. Le proto=0 est optionnel : quand il vaut 0, le noyau déduit TCP d'après SOCK_STREAM.
bind(('0.0.0.0', 6543))
'0.0.0.0' signifie "toutes les interfaces réseau de la machine" : loopback, Ethernet, Wi-Fi. Pour restreindre à localhost uniquement, on utiliserait '127.0.0.1'. Le port 6543 est arbitraire — les ports en dessous de 1024 nécessitent des privilèges root sur Linux.
listen(10)
Le paramètre backlog contrôle la file d'attente que le noyau maintient pour les connexions arrivées mais pas encore traitées par accept(). Si 10 clients se connectent pendant qu'on traite le premier, leurs connexions sont mises en attente. Au-delà, le noyau refuse les connexions suivantes.
serv_sock.accept()
Appel bloquant : le programme s'arrête ici jusqu'à ce qu'un client se connecte. Quand c'est le cas, accept() retourne deux valeurs : client_sock (un nouveau socket dédié à cette connexion) et client_addr (le tuple (ip, port) du client). Le serv_sock continue d'écouter d'autres connexions.
La boucle recv(2048)
Un seul appel à recv() peut retourner moins d'octets que la taille demandée : TCP garantit l'ordre, pas la cohérence des lots. Il faut donc boucler jusqu'à ce que recv() retourne b'' (bytes vide) — ce qui indique que le client a fermé sa connexion ou appelé shutdown(SHUT_WR).
sendall()
Contrairement à send() (qui peut envoyer moins d'octets que demandé et retourner le nombre réellement envoyé), sendall() réessaie en boucle jusqu'à ce que tous les octets soient transmis — ou lève une exception en cas d'erreur réseau.
Le client TCP :
plus simple que le serveur
Le client n'a pas besoin de bind(), de listen(), ni d'accept(). Il crée un socket, se connecte, envoie ses données, signale qu'il a fini, et lit la réponse.
client.py — code complet
import socket # Crée un socket TCP/IPv4. Même appel que pour le serveur. client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # Se connecte au serveur. Déclenche le handshake TCP en 3 voies. # Remplace l'IP par celle de ta machine serveur. client_sock.connect(('127.0.0.1', 6543)) # Envoie les données. client_sock.sendall(b'Hello, serveur !') # Signale qu'on a fini d'envoyer (FIN TCP côté écriture). # La connexion reste ouverte en lecture pour recevoir la réponse. client_sock.shutdown(socket.SHUT_WR) # Lit la réponse. Même prudence que côté serveur : boucler jusqu'à b''. chunks = [] while True: data = client_sock.recv(2048) if not data: break chunks.append(data) print('Reçu :', repr(b''.join(chunks))) # Ferme la connexion. client_sock.close()
Pourquoi le client est plus simple
Le serveur doit maintenir un socket d'écoute permanent et créer un nouveau socket à chaque connexion. Le client n'a qu'une seule connexion à gérer : la sienne. Pas de bind() (le noyau assigne automatiquement un port éphémère), pas de listen(), pas d'accept().
Le rôle de shutdown(SHUT_WR)
Quand le serveur boucle sur recv(), comment sait-il que le client a fini d'envoyer ? Il attend que recv() retourne b''. Cela se produit quand le client ferme sa connexion en écriture — ce que shutdown(SHUT_WR) fait : il envoie un paquet FIN TCP, signalant "j'ai fini d'écrire", tout en gardant la connexion ouverte en lecture.
Si le client appelait directement close() sans passer par shutdown(), la connexion se fermerait complètement et il ne pourrait plus lire la réponse du serveur.
connect()— établit la connexionsendall()— envoie les donnéesshutdown(SHUT_WR)— signale la fin d'envoirecv()en boucle — lit la réponseclose()— ferme définitivement
'127.0.0.1' (loopback) pour un test local où client et serveur tournent sur la même machine. Dans un environnement distribué, tu remplacerais par l'IP réelle du serveur.
Le cycle de vie
d'une connexion TCP
Client et serveur, côte à côte. Clique sur "Étape suivante" pour avancer dans le cycle de vie complet, appel système par appel système.
SERVEUR
CLIENT
Le noyau lui assigne un port éphémère automatiquement.
Les limites du serveur séquentiel
Le serveur qu'on a écrit traite les clients un par un. Pendant qu'il lit les données d'un client, les autres attendent dans la file du backlog. Si un client est lent (connexion mauvaise, gros upload), tous les autres attendent.
Dans la vraie vie, on résout ça de trois façons :
- Multiprocessus : fork() après chaque accept() — chaque client est géré par un processus enfant. C'est ce que fait Apache en mode prefork, et PHP-FPM avec son pool.
- Multithreading : un thread par connexion. Plus léger que le fork, partagent la mémoire.
- I/O asynchrone : un seul thread gère des milliers de connexions via
select(),poll()ouepoll(). C'est ce que fait Nginx, Node.js, ou l'event loop de Redis.
Ce qu'il faut
retenir
Un concept, une phrase. La référence rapide à relire dans un an.
| Concept | L'essentiel |
|---|---|
| Serveur TCP | Un processus qui attend des connexions sur un port et échange des octets. La logique applicative (HTTP, SQL…) est au-dessus de TCP. |
| Socket | Abstraction OS pour la communication inter-processus. En C : un descripteur de fichier. En Python : un objet socket. |
AF_INET + SOCK_STREAM | IPv4 + TCP — le combo utilisé par la quasi-totalité des serveurs réseau. |
AF_UNIX + SOCK_STREAM | Flux local géré par le noyau — pas TCP, pas de ports, pas de réseau. Utilisé par PHP-FPM ↔ Nginx. |
| Paquets TCP | Les données sont fragmentées en paquets numérotés. TCP les réassemble dans l'ordre correct, invisible depuis le code. |
| Handshake 3 voies | SYN → SYN-ACK → ACK. S'accordent sur les numéros de séquence avant tout échange de données. |
| Tuple à 4 éléments | (IP src, port src, IP dst, port dst) — identifie chaque connexion de façon unique dans le monde entier. |
bind() | Réserve un port sur une interface. Serveur uniquement. |
listen(backlog) | Active l'écoute. Le backlog = taille de la file d'attente noyau. |
accept() | Bloque jusqu'à un client, retourne un nouveau socket pour cette connexion. Le socket d'écoute reste ouvert. |
recv() | Retourne b'' quand le client a fermé sa connexion en écriture. Boucler jusqu'à ce cas. |
sendall() vs send() | sendall() garantit que tout est envoyé. send() peut être partiel sans lever d'exception. |
shutdown(SHUT_WR) | Envoie un FIN TCP (fin d'écriture) tout en gardant la lecture ouverte. Permet de recevoir la réponse après avoir fini d'envoyer. |
Guide de choix rapide
→ Utilise
AF_UNIX + SOCK_STREAM. Moins d'overhead, pas besoin de ports.
→ Utilise
AF_INET + SOCK_STREAM (TCP). Fiable, ordonné, universel.
→ Considère
AF_INET + SOCK_DGRAM (UDP). Mais tu gères toi-même l'ordre et les pertes.
Pour aller plus loin
- Beej's Guide to Network Programming — la référence absolue pour l'API sockets en C. Gratuit, exhaustif.
- Documentation Python
socket—docs.python.org/3/library/socket.html— tous les appels, tous les flags. - Cours du wiki : PHP — Concurrence & Runtimes pour voir comment PHP-FPM orchestre ces sockets avec des processus.
- Cours du wiki : HTTP, de 1.1 à 3 pour comprendre ce qui voyage dans le tuyau TCP côté web.