Partie 6 - Sockets

Support présentation

Introduction

Un socket est un point de communication bidirectionnel entre deux processus. Il peut servir à communiquer localement (AF_UNIX) ou via le réseau (AF_INET pour IPv4, AF_INET6 pour IPv6). Les sockets sont manipulés comme des descripteurs de fichiers (entiers), utilisables avec read/write ou send/recv.

Deux processus qui utilisent des sockets pour communiquer via le réseau IP.

Modes de transport TCP/UDP

  • TCP (Transmission Control Protocol) : SOCK_STREAM, mode connecté, fiable, ordonné (handshake, accusés de réception, retransmissions, contrôle de flux et de congestion). Idéal pour HTTP/HTTPS, SSH, SMTP/IMAP/POP3, bases de données.

  • UDP (User Datagram Protocol) : SOCK_DGRAM, mode non connecté, non fiable (best-effort), non ordonné, sans retransmission intégrée. Idéal lorsque la latence prime (streaming temps réel, VoIP, jeux).

TCP : ouverture de connexion en 3 messages, accusés de réception et renvoi d'un paquet perdu. UDP : datagrammes envoyés directement, une perte non détectée, un ordre d'arrivée non garanti.

TCP fournit une connexion fiable ; UDP, une boîte aux lettres rapide mais sans garantie. Pour reprendre l’image ci-dessous : avec TCP, on est sûr de boire l’eau ; avec UDP, on « espère » la boire.

Image montrant d'un côté « TCP » avec une personne buvant correctement à une bouteille d'eau, de l'autre « UDP » avec une personne versant le contenu d'une bouteille d'eau sur le visage.

Source de l’image.

Modèle OSI et pile TCP/IP

Le modèle OSI découpe une communication réseau en 7 couches. En pratique, on retient la pile TCP/IP, en 4 couches : une page web est échangée avec HTTP (application), transporté par TCP (transport), lui-même transporté par IP (réseau), sur Ethernet ou Wi-Fi (accès au réseau).

Les sockets sont l’interface entre le programme et la couche transport (TCP/UDP) : le programme fournit les données, le noyau se charge des couches inférieures.

Modèle OSI avec les 7 couches.

Source de l’image.

Principe d’une communication en mode connecté (TCP)

Une communication TCP met en jeu deux applications distinctes :

  • le serveur : attend et accepte les connexions entrantes ;

  • le client : initie la connexion vers le serveur.

  1. Le serveur doit être lancé avant le client. Il crée un socket (socket), l’associe à une adresse IP et un numéro de port (bind), puis le place en écoute (listen). Quand un client se présente, l’appel accept crée un nouveau socket connecté dédié à ce client.

  2. Le client crée aussi un socket (socket) puis tente de se connecter au serveur via connect en précisant l’adresse IP et le port du serveur.

  3. Une fois la connexion établie, les deux côtés peuvent échanger des données avec send/recv (ou write/read).

  4. Pour terminer, la connexion TCP est fermée avec close (précédé éventuellement de shutdown pour une fermeture en deux temps), ce qui libère les ressources.

À retenir

  • listen met simplement le socket serveur en attente, mais ne crée pas la connexion.

  • C’est accept qui génère un nouveau socket connecté pour dialoguer avec un client, tandis que le socket d’écoute continue à attendre d’autres connexions.

Déroulé d'un échange TCP : appels système du serveur et du client, et paquets échangés (SYN, SYN-ACK, ACK, données, FIN).

Déroulé d’un échange TCP : le serveur appelle socket, bind, listen puis accept (bloquant) ; le client appelle socket puis connect, qui déclenche l’ouverture de la connexion. Les deux échangent ensuite avec send/recv (les accusés de réception sont gérés par le noyau), puis ferment la connexion.

Le schéma suivant reprend ces étapes du point de vue du programme : ce que fait chaque appel, les numéros de descripteurs obtenus, et les moments où un appel est bloquant (le programme attend, sans rien faire, que l’événement attendu se produise). Les sections suivantes détaillent chaque appel.

Connexion TCP pas à pas. Serveur : socket crée le fd 3 ; bind réserve le port 8080 ; listen en fait le socket d'écoute ; accept bloque jusqu'à l'arrivée d'un client. Client : socket, puis connect vers 127.0.0.1:8080, qui ouvre la connexion. accept renvoie un nouveau socket, fd 4, dédié au client ; fd 3 continue d'écouter. Le serveur se bloque dans recv, le client envoie « Bonjour\n » avec send_all, le serveur répond pendant que le client attend dans recv. Fermeture : le client fait shutdown(SHUT_WR), le recv du serveur renvoie 0 ; le serveur fait shutdown puis close de fd 4, le recv du client renvoie 0 et il ferme son socket ; le serveur ferme fd 3.

Créer un socket

Pour créer un socket, nous utilisons la fonction socket :

int socket(int domain, int type, int protocol);
  • domain : AF_INET (IPv4), AF_INET6 (IPv6), AF_UNIX (local)

  • type : SOCK_STREAM (TCP), SOCK_DGRAM (UDP)

  • protocol : souvent 0 (choix par défaut selon type)

Retourne un descripteur de fichier (entier >= 0), ou -1 en cas d’erreur (errno indique la cause).

Le client comme le serveur commencent par créer un socket.

Côté serveur, on ajoute en général l’option SO_REUSEADDR avec setsockopt. Après l’arrêt d’un serveur, les connexions qu’il a fermées en premier restent un moment (60 secondes sous Linux) dans l’état TIME_WAIT. Sans cette option, relancer le serveur pendant ce délai fait échouer bind avec l’erreur Address already in use. L’option doit être posée avant bind.

int setsockopt(int sockfd, int level, int optname,
               const void *optval, socklen_t optlen);
  • sockfd : descripteur du socket

  • level : niveau de l’option, SOL_SOCKET pour les options générales des sockets

  • optname : nom de l’option, ici SO_REUSEADDR

  • optval : pointeur vers la valeur de l’option (un int à 1 pour l’activer)

  • optlen : taille de cette valeur

Retourne 0, ou -1 en cas d’erreur (errno indique la cause).

Exemple :

// 1. Création d'un socket TCP
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1) {
    perror("socket");
    exit(EXIT_FAILURE);
}

// Option SO_REUSEADDR (côté serveur, avant bind) : permet de relancer le
// serveur sans attendre la fin de l'état TIME_WAIT
int opt = 1;
if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {
    perror("setsockopt");
    close(fd);
    exit(EXIT_FAILURE);
}

Associer une adresse avec bind

bind permet d’associer un socket à une adresse locale (IP + port ou chemin UNIX). Le serveur doit l’appeler pour réserver l’adresse sur laquelle il écoute. Le client n’a pas besoin de bind, car le noyau lui attribue automatiquement une adresse locale lors de connect ou sendto. Toutefois, un client peut appeler bind lorsqu’il veut imposer une adresse ou un port source spécifique.

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd : descripteur de socket

  • addr : pointeur vers la structure d’adresse (convertie en struct sockaddr *)

  • addrlen : taille de la structure

Retourne 0, ou -1 en cas d’erreur (errno indique la cause).

Structure d’adresse IPv4 : sockaddr_in

On ne remplit pas directement une struct sockaddr : chaque famille d’adresses a sa propre structure. Pour IPv4, c’est struct sockaddr_in (<netinet/in.h>) :

struct sockaddr_in {
   sa_family_t    sin_family;   // AF_INET
   in_port_t      sin_port;     // port en ordre réseau (htons)
   struct in_addr sin_addr;     // adresse IPv4 (htonl ou inet_pton)
   unsigned char  sin_zero[8];  // remplissage, à 0
};

struct in_addr {
   uint32_t s_addr; // adresse IPv4 en ordre réseau
};

Exemple :

struct sockaddr_in addr = {0};            // met tous les champs de addr à 0
addr.sin_family      = AF_INET;
addr.sin_port        = htons(8080);       // port 8080 → ordre réseau
addr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0 → écouter sur toutes les interfaces IPv4

Pour IPv6, le principe est le même avec AF_INET6 et struct sockaddr_in6 ; pour un socket local, avec AF_UNIX et struct sockaddr_un (voir sockets locaux).

Ordre des octets : htons, htonl

Nos machines rangent les entiers en little-endian, octet de poids faible en premier (voir les variables en mémoire). Le réseau utilise l’ordre inverse, big-endian : le port et l’adresse IP doivent être convertis avant d’être placés dans la structure.

uint16_t htons(uint16_t hostshort);
uint32_t htonl(uint32_t hostlong);
uint16_t ntohs(uint16_t netshort);
uint32_t ntohl(uint32_t netlong);
  • hostshort, hostlong : entier de 16 ou 32 bits dans l’ordre d’octets de la machine

  • netshort, netlong : entier de 16 ou 32 bits dans l’ordre d’octets du réseau

Retourne la valeur convertie (ces fonctions ne peuvent pas échouer).

htons (Host TO Network Short) convertit un entier 16 bits de l’ordre d’octets de la machine (host order) vers l’ordre d’octets du réseau (network order, big-endian). htonl (Host TO Network Long) fait de même pour un entier 32 bits. Les fonctions inverses ntohs et ntohl (Network TO Host) convertissent de l’ordre réseau vers l’ordre de la machine, par exemple pour afficher le port d’un client. Elles sont définies dans <arpa/inet.h>.

Sans htons, le port serait faux : 8080 (0x1F90) serait rangé 90 1F et lu par le réseau comme 0x901F, soit 36895 (voir le schéma de la section Le cast en struct sockaddr *). Sur une machine big-endian, ces fonctions ne font rien : on les écrit quand même, pour que le programme fonctionne partout.

Adresse IP en texte : inet_pton, inet_ntop

Pour passer d’une adresse IP écrite en texte ("127.0.0.1") à sa forme binaire, et inversement, on utilise inet_pton et inet_ntop :

int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
  • af : famille d’adresse (AF_INET ou AF_INET6)

  • src : pour inet_pton, le texte de l’adresse ; pour inet_ntop, l’adresse binaire

  • dst : zone qui reçoit le résultat, soit l’adresse binaire (inet_pton), soit le texte (inet_ntop)

  • size (inet_ntop) : taille de dst ; INET_ADDRSTRLEN (16) est la taille suffisante pour une adresse IPv4 ("255.255.255.255" + '\0')

inet_pton retourne 1 en cas de succès, 0 si le texte n’est pas une adresse valide (errno n’est pas modifié), ou -1 si af est invalide (errno indique la cause). inet_ntop retourne dst, ou NULL en cas d’erreur (errno indique la cause).

inet_pton (presentation to network) convertit le texte src en adresse binaire dans dst. inet_ntop (network to presentation) écrit l’adresse binaire src sous forme de texte dans dst. Elles sont définies dans <arpa/inet.h>.

Comme inet_pton ne modifie pas errno pour un texte invalide, perror afficherait un message sans rapport (« Success » si errno vaut 0) : on affiche le message soi-même avec fprintf (voir l’exemple de connect).

Le cast en struct sockaddr *

bind (comme accept et connect) accepte toutes les familles d’adresses : son paramètre est donc du type générique struct sockaddr *. On lui donne l’adresse de notre sockaddr_in, convertie avec un cast : (struct sockaddr *)&server_addr.

Ce cast ne copie ni ne modifie rien : c’est la même adresse, présentée avec un autre type (voir Conversions explicites (cast) et Les pointeurs). Le noyau lit d’abord le champ family, placé au début de toutes les structures d’adresse, pour savoir quelle structure se trouve réellement à cette adresse, et addrlen (sizeof(server_addr)) lui indique combien d’octets lire.

Les 16 octets de server_addr pour 0.0.0.0:8080 : 02 00 (sin_family, AF_INET), 1F 90 (sin_port, htons(8080) ; sans htons : 90 1F, lu comme 36895), 00 00 00 00 (sin_addr), puis 8 octets à 0 (sin_zero). Lue comme struct sockaddr : sa_family sur les 2 premiers octets, sa_data[14] pour la suite. &server_addr (type struct sockaddr_in *) et (struct sockaddr *)&server_addr (type struct sockaddr *) désignent la même adresse.

Exemple avec bind et sockaddr_in :

#define PORT 8080

// 2. Configuration de l'adresse (écoute sur toutes les interfaces, port 8080)
struct sockaddr_in server_addr = {0};
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0
server_addr.sin_port = htons(PORT);

// 3. Bind
if (bind(fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
    perror("bind");
    close(fd);
    exit(EXIT_FAILURE);
}

Attendre des connexions avec listen

Pour les sockets TCP, le serveur utilise listen pour indiquer qu’il accepte des connexions entrantes :

int listen(int sockfd, int backlog);
  • sockfd : socket créé et lié (bind)

  • backlog : taille maximale de la file d’attente des connexions

Retourne 0, ou -1 en cas d’erreur (errno indique la cause).

Les connexions établies par des clients mais pas encore acceptées par le serveur (accept) sont placées dans une file d’attente. backlog borne la taille de cette file : au-delà, les nouvelles demandes de connexion sont ignorées ou refusées, selon le système.

Exemple :

#define BACKLOG 5

// 4. Listen
if (listen(fd, BACKLOG) == -1) {
    perror("listen");
    close(fd);
    exit(EXIT_FAILURE);
}

Le serveur est maintenant prêt à accepter des clients.

Accepter des connexions avec accept

Un serveur TCP appelle accept pour accepter une connexion, ce qui crée un nouveau socket connecté pour le client.

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd : descripteur du socket d’écoute

  • addr : pointeur vers une structure qui contiendra l’adresse du client connecté

  • addrlen : pointeur vers la taille de cette structure

Retourne un nouveau descripteur (distinct du socket d’écoute), ou -1 en cas d’erreur (errno indique la cause).

Exemple (inet_ntop et ntohs servent à afficher l’adresse et le port du client) :

// 5. Acceptation d'un client (à faire dans une boucle pour en accepter plusieurs)
struct sockaddr_in client_addr = {0};
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd == -1) {
    perror("accept");
    close(fd);
    exit(EXIT_FAILURE);
}

char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN);
printf("[Serveur] Client connecté depuis %s:%d\n", client_ip, ntohs(client_addr.sin_port));

client_len sert dans les deux sens, d’où le passage par adresse (&client_len, voir Passage par valeur, passage par adresse) : avant l’appel, il indique la place disponible dans client_addr ; après, accept y a écrit la taille réellement remplie. Si l’adresse du client ne vous intéresse pas, passez NULL pour les deux : accept(fd, NULL, NULL).

accept est bloquant : s’il n’y a aucun client dans la file d’attente, le programme attend à cette ligne jusqu’à l’arrivée d’un client (voir Servir plusieurs clients).

Se connecter avec connect

Le client appelle connect pour établir la connexion :

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd : descripteur du socket du client

  • addr : adresse du serveur

  • addrlen : taille de la structure

Retourne 0, ou -1 en cas d’erreur (errno indique la cause).

Exemple (IPv4 localhost:8080) :

#define ADDRESS "127.0.0.1"
#define PORT 8080

// 2. Configuration de l'adresse du serveur
struct sockaddr_in server_addr = {0};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);

// Conversion de l'adresse IP texte en binaire
// (pas de perror : inet_pton ne modifie pas errno si le texte est invalide)
if (inet_pton(AF_INET, ADDRESS, &server_addr.sin_addr) != 1) {
    fprintf(stderr, "inet_pton: adresse invalide : %s\n", ADDRESS);
    close(fd);
    exit(EXIT_FAILURE);
}

// 3. Connexion au serveur
printf("[Client] Connexion à %s:%d...\n", ADDRESS, PORT);
if (connect(fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
    perror("connect");
    close(fd);
    exit(EXIT_FAILURE);
}
printf("[Client] Connecté !\n");

Si aucun serveur n’écoute sur ce port, connect échoue : connect: Connection refused. Il faut donc lancer le serveur avant le client.

Envoyer et recevoir des données avec send/recv

Ces fonctions permettent d’échanger des données. En TCP, le socket est connecté et l’adresse du pair est implicite. En UDP, on utilise généralement sendto/recvfrom pour préciser l’adresse à chaque envoi/réception (voir UDP).

En TCP :

ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
  • sockfd : descripteur du socket connecté

  • buf : buffer de données

  • len : pour send, nombre d’octets à envoyer ; pour recv, nombre maximal d’octets à recevoir (taille du buffer)

  • flags : options (souvent 0)

Retourne le nombre d’octets envoyés/reçus, ou -1 en cas d’erreur (errno indique la cause).

recv retourne 0 si le pair a fermé la connexion.

Le type ssize_t (entier signé, qui peut valoir -1) s’affiche avec le format %zd dans printf (%zu pour size_t).

recv est bloquant : tant que le pair n’a rien envoyé, le programme attend.

recv copie des octets bruts, comme read : il n’ajoute pas de '\0'. Pour afficher le message comme une chaîne, on garde une case libre dans le buffer (BUF_SIZE - 1) et on écrit soi-même le '\0' après les octets reçus.

Exemple (écho simple) :

#define BUF_SIZE 1024

// envoyer
const char *msg = "Hello";
if (send(fd, msg, strlen(msg), 0) == -1) { // voir send_all ci-dessous
    perror("send");
}

// recevoir
char buffer[BUF_SIZE];
ssize_t bytes_received = recv(fd, buffer, BUF_SIZE - 1, 0); // garde la place du '\0'
if (bytes_received == -1) {
    perror("recv"); // erreur
} else if (bytes_received == 0) {
    printf("Client/serveur déconnecté\n"); // le pair a fermé la connexion (ex. : l'autre programme s'est terminé)
} else {
    buffer[bytes_received] = '\0'; // termine la chaîne après les octets reçus
    printf("Message reçu (%zd octets) : %s\n", bytes_received, buffer);
}

Envois partiels et signal SIGPIPE

send peut envoyer moins d’octets que demandé : il faut alors rappeler send avec le reste. De plus, un send vers un pair qui a fermé la connexion déclenche par défaut le signal SIGPIPE, qui tue le processus (un serveur s’arrêterait parce qu’un client s’est déconnecté brutalement). Avec l’option MSG_NOSIGNAL (POSIX ; absente de macOS), send retourne -1 avec errno à EPIPE au lieu d’envoyer le signal.

La fonction suivante règle les deux problèmes :

// Envoie les len octets de buf, en plusieurs appels à send si nécessaire.
// Retourne 0 si tout a été envoyé, -1 en cas d'erreur.
int send_all(int fd, const char *buf, size_t len) {
    while (len > 0) {
        ssize_t n = send(fd, buf, len, MSG_NOSIGNAL);
        if (n == -1) {
            return -1;
        }
        buf += n; // avance dans le buffer
        len -= (size_t)n;
    }
    return 0;
}

buf += n utilise l’arithmétique des pointeurs (voir Les pointeurs) : buf désigne maintenant le premier octet pas encore envoyé.

send_all sur le buffer de 10 octets « Bonjour !\n ». Tour 1 : len = 10, send envoie 6 octets. Tour 2 : buf += 6 désigne le 7e octet, len -= 6 donne 4, send envoie les 4 octets restants. Fin : len = 0, la boucle s'arrête et la fonction retourne 0.

TCP est un flux : délimiter les messages

TCP transporte un flux d’octets et ne conserve pas les frontières des messages :

  • deux messages envoyés rapidement peuvent être reçus ensemble par un seul recv ;

  • un long message peut être découpé et nécessiter plusieurs recv.

Un recv ne correspond donc pas forcément à un send :

L'émetteur fait send("Bonjour\n") puis send("Salut\n") : 14 octets à la suite dans le buffer du récepteur. Un seul recv peut recevoir les 14 octets d'un coup, ou les recv peuvent couper le flux ailleurs : « Bonjour\nSal » puis « ut\n ». Avec un protocole à lignes, fgets redécoupe le flux à chaque \n : « Bonjour\n » puis « Salut\n ».

Pour savoir où s’arrête un message, l’émetteur et le récepteur doivent se mettre d’accord sur un protocole, par exemple :

  • chaque message se termine par un délimiteur, comme '\n' (protocole texte, une ligne par message) ;

  • ou chaque message commence par sa longueur.

Avec des lignes terminées par '\n', le plus simple pour la réception est de créer un FILE * sur le socket avec fdopen, puis de lire ligne par ligne avec fgets, comme pour un pipe (voir la simplification des échanges avec la libc). fgets relit sur le socket (avec read) autant de fois que nécessaire et s’arrête au '\n'. fdopen est une fonction POSIX : avec -std=c2x, mettre #define _POSIX_C_SOURCE 200809L (ou #define _GNU_SOURCE) avant les #include.

FILE *in = fdopen(fd, "r"); // flux en lecture sur le socket
if (in == NULL) {
    perror("fdopen");
    close(fd);
    exit(EXIT_FAILURE);
}

char line[BUF_SIZE];
while (fgets(line, BUF_SIZE, in) != NULL) { // NULL : fin de connexion ou erreur
    line[strcspn(line, "\n")] = '\0'; // retire le \n
    printf("Message reçu : %s\n", line);
}

fclose(in); // ferme aussi fd

Pour l’envoi, on garde send_all sur le descripteur, en ajoutant le '\n' à la fin de chaque message : on lit avec le FILE *, on écrit avec send_all. Créez un seul FILE * par socket : les lignes déjà lues en avance dans le buffer d’un FILE * ne sont pas visibles depuis un autre.

Fermer un socket

En fin de communication, il faut libérer proprement le socket pour éviter les fuites de ressources et signaler correctement la fin d’échange au pair.

TCP : shutdown puis close

En TCP, la fermeture peut se faire en deux temps, avec une demi-fermeture (half-close : on arrête l’envoi mais on continue à recevoir), ou d’un coup. La fermeture d’un ou des deux sens se fait avec shutdown :

int shutdown(int sockfd, int how);
  • sockfd : descripteur du socket connecté

  • how : sens à fermer :

    • SHUT_WR : on n’envoie plus, on peut encore recevoir (demi-fermeture) ;

    • SHUT_RD : on ne reçoit plus ;

    • SHUT_RDWR : on n’envoie ni ne reçoit (fermeture logique des deux sens).

Retourne 0, ou -1 en cas d’erreur (errno indique la cause).

Contrairement à close, shutdown ne libère pas le descripteur.

close libère le descripteur au niveau du processus. Sur un socket, si c’est la dernière référence, close termine aussi la connexion : le pair reçoit une fin de connexion normale, ou une réinitialisation (RST) s’il restait des données reçues non lues.

Patron de fermeture conseillé (côté client ou serveur) :

  1. Terminer les envois : shutdown(fd, SHUT_WR), comme Ctrl+D dans un terminal : « je n’envoie plus, mais j’écoute encore ». Le pair voit alors son recv retourner 0.

  2. Lire jusqu’à EOF (recv qui retourne 0) pour consommer les données restantes. Cette boucle suppose que le pair ferme à son tour sa connexion : sinon, elle bloque indéfiniment.

  3. Appeler close(fd) pour libérer la ressource.

Les étapes 9 et 10 du schéma de la connexion montrent cette fermeture : le client ferme son sens d’envoi, le recv du serveur retourne 0, le serveur ferme à son tour, puis le recv du client retourne 0. Si les deux côtés attendent l’EOF de l’autre sans avoir appelé shutdown(fd, SHUT_WR), aucun des deux ne le reçoit : les deux programmes restent bloqués.

Exemple : fermeture TCP propre

printf("\n[Client] Fermeture de la connexion...\n");
shutdown(fd, SHUT_WR); // On n'envoie plus

// Consommer les données restantes jusqu'à EOF
while (recv(fd, buffer, sizeof(buffer), 0) > 0) {
    // On ignore les données restantes
}

close(fd);
printf("[Client] Déconnecté.\n");

Récapitulatif : serveur et client

Étape

Serveur

Client

1

socket (+ setsockopt pour SO_REUSEADDR)

socket

2

bind : adresse et port d’écoute

3

listen : le socket devient le socket d’écoute

4

accept (bloquant) : retourne un nouveau descripteur pour ce client

connect : adresse et port du serveur

5

recv / send_all sur le nouveau descripteur

send_all / recv

6

shutdown, lecture jusqu’à EOF, close du descripteur du client ; close du socket d’écoute à l’arrêt

shutdown, lecture jusqu’à EOF, close

Tester avec nc et ss

nc (netcat) permet de tester un programme sans avoir écrit l’autre côté :

$ ./serveur                 # terminal 1 : votre serveur
$ nc 127.0.0.1 8080         # terminal 2 : nc joue le client

Chaque ligne tapée dans nc est envoyée au serveur, et ce que le serveur envoie s’affiche ; Ctrl+D termine les envois (comme shutdown(fd, SHUT_WR)).

Pour tester un client sans serveur, nc -l 8080 joue le serveur (selon la version de nc : nc -l -p 8080).

Pour voir quels programmes écoutent un port TCP (-t : TCP, -l : en écoute, -n : numéros, -p : programme) :

$ ss -tlnp | grep 8080
LISTEN 0  5  0.0.0.0:8080  0.0.0.0:*  users:(("serveur",pid=12345,fd=3))

Erreurs fréquentes

  • bind: Address already in use : un ancien serveur tourne encore (dans un autre terminal ou en arrière-plan, ss -tlnp le montre), ou SO_REUSEADDR a été oublié.

  • connect: Connection refused : le serveur n’est pas lancé, ou le port ou l’adresse sont faux.

  • bind: Permission denied : les ports inférieurs à 1024 sont réservés à l’administrateur ; utilisez par exemple 8080.

  • Caractères en trop à l’affichage d’un message reçu : le '\0' n’a pas été ajouté après les octets reçus par recv.

  • Les deux programmes sont figés : chacun attend dans un appel bloquant (recv, fgets, accept) que l’autre envoie quelque chose. Vérifiez que chaque message envoyé se termine bien par '\n' si l’autre côté lit avec fgets, et que les deux côtés suivent le même ordre d’envois et de réceptions.

Servir plusieurs clients

accept, recv et fgets sur un socket sont bloquants. Un serveur à un seul thread qui dialogue avec un client passe son temps bloqué dans recv : il n’appelle plus accept, et les autres clients attendent dans la file d’attente.

La solution est de servir chaque client dans son propre thread (voir le chapitre Threads) : le thread principal ne fait qu’accepter les clients, dans une boucle, et crée pour chacun un thread qui dialogue avec lui.

void *gerer_client(void *arg) {
    int *ptr_fd = arg;
    int client_fd = *ptr_fd;
    free(ptr_fd); // alloué par le thread principal

    // ... dialogue avec le client : recv / send_all sur client_fd ...

    close(client_fd);
    return NULL;
}

// thread principal, après socket, bind et listen
while (1) {
    int client_fd = accept(fd, NULL, NULL); // bloqué jusqu'au client suivant
    if (client_fd == -1) {
        perror("accept");
        continue;
    }

    // un int dans la heap par thread (voir la durée de vie des arguments)
    int *ptr_fd = malloc(sizeof *ptr_fd);
    if (ptr_fd == NULL) {
        close(client_fd);
        continue;
    }
    *ptr_fd = client_fd;

    pthread_t tid;
    int err = pthread_create(&tid, NULL, gerer_client, ptr_fd);
    if (err != 0) {
        fprintf(stderr, "pthread_create: %s\n", strerror(err));
        free(ptr_fd);
        close(client_fd);
        continue;
    }
    pthread_detach(tid); // personne n'attendra ce thread avec pthread_join
}

Le socket d’écoute reste au thread principal ; chaque thread travaille sur le descripteur de son client (voir Durée de vie de l’argument et pthread_detach) :

Processus serveur avec sa table des descripteurs : 0, 1, 2 pour le terminal, 3 pour le socket d'écoute, utilisé par le thread principal (accept), 4 pour le client A, utilisé par le thread A, 5 pour le client B, utilisé par le thread B. Dans le noyau : le socket d'écoute (port 8080, file d'attente), à partir duquel accept crée un socket connecté par client (127.0.0.1:8080 ↔ 127.0.0.1:51234 et ↔ 127.0.0.1:51240). Chaque client est un autre processus, avec son propre fd 3. send et recv se font sur fd 4 ou 5, jamais sur fd 3 ; fermer fd 4 ne ferme ni le serveur ni la connexion du client B.

Si les threads partagent des données, par exemple la liste des clients connectés, chaque accès doit être protégé par un mutex.

On peut aussi servir chaque client dans un processus enfant créé avec fork : l’enfant dialogue avec le client, et le parent ferme sa copie de client_fd puis retourne à accept.

Le même problème se pose côté client lorsqu’il doit à la fois attendre la saisie au clavier (fgets sur stdin) et les messages du serveur : chacune de ces attentes bloque, il faut donc deux threads.