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ées 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, 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).

Présentation de TCP avec les handshake et de UDP avec des envois de données directs.

Source de l’image.

TCP fournit une connexion et une fiabilité, UDP fournit une boîte aux lettres rapide sans garantie. Analogie avec boire de l’eau, avec TCP on est sûr de boire l’eau, avec UDP, on « espère » boire l’eau.

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

Les sockets se situent au-dessus des protocoles de transport (TCP/UDP) et exposent une API de programmation.

Modèle OSI avec les 7 couches.

Source de l’image.

Principe d’un échange 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 une socket (socket), l’associe à une adresse IP et un numéro de port (bind), puis la place en écoute (listen). Quand un client se présente, l’appel accept crée une nouvelle socket connectée dédiée à ce client.

  2. Le client crée aussi une 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 proprement avec shutdown et close, ce qui libère les ressources.

À retenir

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

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

Présentation de TCP.

Source de l’image.

Principe d’un échange d’une communication en mode non connecté (UDP)

Une communication UDP implique également deux applications :

  • le serveur : attend les datagrammes sur un port donné

  • le client : envoie des datagrammes vers l’adresse et le port du serveur

Contrairement à TCP, aucune connexion persistante n’est établie : chaque envoi est indépendant et peut être perdu ou reçu dans un ordre différent.

  1. Le serveur crée une socket (socket) de type SOCK_DGRAM puis l’associe à une adresse IP et un port avec bind. Il utilise ensuite recvfrom pour attendre la réception d’un datagramme, et peut répondre avec sendto.

  2. Le client crée aussi une socket (socket), puis envoie un datagramme au serveur avec sendto en indiquant explicitement l’adresse IP et le port de destination. Pour recevoir la réponse, il utilise recvfrom.

  3. Chaque envoi ou réception est indépendant : il n’y a pas de notion de session ou de « connexion » comme en TCP.

  4. Les sockets UDP se ferment simplement avec close.

À retenir

  • UDP ne garantit ni la livraison, ni l’ordre, ni l’absence de doublons.

  • C’est le programme qui doit gérer ces aspects si nécessaire (accusés de réception, numéros de séquence, retransmission, etc.).

  • Ce manque de fiabilité en contrepartie d’une faible latence en fait un choix adapté au streaming, à la voix sur IP (VoIP) ou aux jeux en ligne.

Présentation de UDP.

Source de l’image.

Création d’un socket

Prototype C :

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 -1 si erreur (errno contient le code d’erreur).

Exemple :

// connexion TCP en mode connecté
int fd_tcp = socket(AF_INET, SOCK_STREAM, 0);
if (fd_tcp == -1) {
   perror("socket TCP");
   exit(1);
}

// connexion UDP en mode non connecté
int fd_udp = socket(AF_INET, SOCK_DGRAM, 0);
if (fd_udp == -1) {
   perror("socket UDP");
   exit(1);
}

Définition des interfaces avec bind

bind associe un socket à une adresse locale (IP + port pour AF_INET/AF_INET6 ou chemin pour AF_UNIX).

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

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

  • addrlen : taille de la structure

Retourne 0 si succès, -1 si erreur (errno contient le code d’erreur).

Note

En pratique, on n’utilise pas directement struct sockaddr. On remplit une structure spécialisée (sockaddr_in, sockaddr_in6, sockaddr_un), puis on la cast en struct sockaddr* pour l’appel système.

Note

La fonction htons (Host TO Network Short, htonl pour Long) 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). Elle est définie dans <arpa/inet.h>.

Utilisation typique : convertir un numéro de port avant de l’inscrire dans une structure d’adresse sockaddr_in.

Sans cette conversion, le programme pourrait fonctionner par hasard sur une machine little-endian (x86) mais échouer sur une architecture big-endian (ordre mémoire différent).

  • Adresse IPv4 (AF_INET - <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];  // padding pour compatibilité
    };
    
    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
    
  • Adresse IPv6 (AF_INET6 - <netinet/in.h>)

    struct sockaddr_in6 {
       sa_family_t     sin6_family;   // AF_INET6
       in_port_t       sin6_port;     // port en ordre réseau (htons)
       uint32_t        sin6_flowinfo; // infos QoS/flux
       struct in6_addr sin6_addr;     // adresse IPv6 (inet_pton)
       uint32_t        sin6_scope_id; // identifiant de zone (interfaces locales)
    };
    
    struct in6_addr {
       unsigned char s6_addr[16]; // adresse IPv6 en binaire
    };
    

    Exemple :

    struct sockaddr_in6 addr6 = {0};
    addr6.sin6_family = AF_INET6;
    addr6.sin6_port   = htons(8080); // port 8080 → ordre réseau
    addr6.sin6_addr   = in6addr_any; // :: → écouter sur toutes les interfaces IPv6
    
  • Adresse locale (AF_UNIX - <sys/un.h>)

    struct sockaddr_un {
       sa_family_t sun_family;   // AF_UNIX
       char        sun_path[108]; // chemin du fichier-socket
    };
    

    Exemple :

    struct sockaddr_un addr = {0};
    addr.sun_family = AF_UNIX;
    strcpy(addr.sun_path, "/tmp/mysocket"); // chemin du fichier-socket
    

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éée et bindée

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

Retourne 0 si succès, -1 si erreur (errno contient le code d’erreur).

Si plusieurs demandes de connexion ont lieu en même temps, elles sont stockées dans une file d’attente.

Exemple :

int s = socket(AF_INET, SOCK_STREAM, 0);
// ... bind(s, ...)
if (listen(s, 16) == -1) {
    perror("listen");
    exit(1);
}
// la socket s est maintenant en écoute

Accepter des connexions avec accept

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

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd : descripteur de la 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 de la socket d’écoute si succès, -1 si erreur (errno contient le code d’erreur).

Exemple :

struct sockaddr_in cli;
socklen_t len = sizeof cli;
int c = accept(s, (struct sockaddr*)&cli, &len);
if (c == -1) {
    perror("accept");
    // gérer l'erreur
} else {
    // c est la socket connectée au client
}

Connexion à un socket avec connect

Le client appelle connect pour établir la connexion :

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

  • addr : adresse du serveur

  • addrlen : taille de la structure

Retourne 0 si succès, -1 si erreur (errno contient le code d’erreur).

Exemple (IPv4 localhost:8080) :

int sock = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv = {0};
serv.sin_family = AF_INET;
serv.sin_port   = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr);
if (connect(sock, (struct sockaddr*)&serv, sizeof serv) == -1) {
    perror("connect");
    exit(1);
}

Envoyer et recevoir des données avec send/recv ou sendto/recvfrom

Ces fonctions permettent d’échanger des données. En TCP, la socket est connectée 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 (sauf si la socket UDP a été connect-ée pour figer le pair).

En TCP :

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

  • buf : tampon de données

  • len : taille du tampon

  • flags : options (souvent 0)

Retourne le nombre d’octets envoyés/reçus ou -1 si erreur.

Exemple (écho simple) :

const char *msg = "Hello\n";
if (send(sock, msg, strlen(msg), 0) == -1) perror("send");
char buf[1024];
ssize_t n = recv(sock, buf, sizeof buf - 1, 0);
if (n > 0) {
   buf[n] = 0;
   printf("%s", buf);
}

En UDP (avec adresse) :

int sendto(int sockfd, const void *buf, size_t len, int flags,
           const struct sockaddr *dest_addr, socklen_t addrlen);

int recvfrom(int sockfd, void *buf, size_t len, int flags,
             struct sockaddr *src_addr, socklen_t *addrlen);
  • sockfd : descripteur de socket UDP

  • buf : tampon de données

  • len : taille du tampon

  • flags : options (souvent 0)

  • dest_addr / src_addr : adresse de destination/source

  • addrlen : taille de la structure d’adresse

Retourne le nombre d’octets envoyés/reçus ou -1 si erreur.

Exemple (ping UDP localhost:8080) :

int u = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in dst = {0};
dst.sin_family = AF_INET;
dst.sin_port   = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &dst.sin_addr);
const char *p = "Ping";
if (sendto(u, p, strlen(p), 0, (struct sockaddr*)&dst, sizeof dst) == -1) perror("sendto");
// côté serveur UDP : recvfrom(...), puis sendto(...) pour répondre

Fermeture d’une socket

En fin de communication, il faut libérer proprement la 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 être demi-duplex (on arrête l’envoi mais on continue à recevoir) ou complète. L’API propose :

  • shutdown(fd, SHUT_WR) // on n’envoie plus, on peut encore recevoir (half-close)

  • shutdown(fd, SHUT_RD) // on ne reçoit plus

  • shutdown(fd, SHUT_RDWR) // on n’envoie ni ne reçoit (fermeture logique des deux sens)

  • close(fd) // libère le descripteur au niveau du processus

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

  1. Terminer les envois : shutdown(fd, SHUT_WR)

  2. Lire jusqu’à EOF (recv qui retourne 0) pour consommer les données restantes.

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

Exemple : fermeture TCP propre

// fin des envois vers le pair
shutdown(fd, SHUT_WR);        // signal FIN envoyé

// lire jusqu'à EOF pour s'assurer de recevoir la fin distante
char buf[4096];
for (;;) {
    ssize_t n = recv(fd, buf, sizeof buf, 0);
    if (n > 0) {
        // traiter éventuellement les derniers octets
        continue;
    }
    if (n == 0) {
        // EOF : le pair a fermé sa moitié d'écriture
        break;
    }
    if (errno == EINTR) continue; // interruption signal → reprendre
    perror("recv");
    break;
}

// libère le descripteur local
close(fd);

UDP : close suffit

UDP n’a pas de connexion persistante ni de handshake de fermeture. Il suffit de fermer le descripteur :

close(fd_udp);  // libère la socket UDP

Sockets locales (AF_UNIX)

Pour la communication locale entre processus, AF_UNIX est très performant (pas de pile IP). Au lieu d’une IP/port, on utilise un chemin de fichier.

1#include <sys/socket.h>
2#include <sys/un.h>
3#include <string.h>
4
5int fd = socket(AF_UNIX, SOCK_STREAM, 0);
6struct sockaddr_un addr = {0};
7addr.sun_family = AF_UNIX;
8strncpy(addr.sun_path, "/tmp/mysock", sizeof addr.sun_path - 1);
9bind(fd, (struct sockaddr*)&addr, sizeof addr);

Avec AF_UNIX, il faut fermer puis supprimer le fichier-socket (côté serveur) :

close(fd_unix);            // ferme le descripteur
unlink("/tmp/mysocket");   // supprime le chemin de la socket (sinon fichier persiste)