Programmation réseau via socket en C

Table des matières

Dans ce monde informatique ultra-connecté, il est crucial de savoir comment envoyer et recevoir des données à distance, grâce aux sockets. Dans cet article, nous verrons qu’une socket est essentiellement une “prise” numérique qu’on attache à une adresse locale ou distante afin d’établir une connexion. Nous explorerons aussi l’architecture et les appels systèmes qui nous permettront de construire non seulement un client, mais aussi un serveur en langage C.

Qu’est-ce qu’une socket ?

On aura probablement déjà entendu l’adage qui dit que dans les systèmes Unix, “tout est un fichier”. Les sockets n’y échappent pas. Une socket, donc, c’est un simple descripteur de fichier qui permet de communiquer à distance. Il y a plusieurs types de sockets différents, mais nous allons nous concentrer ici sur les sockets Internet.

Il existe aussi plusieurs types de sockets Internet qui transmettent leurs données de manières différentes. Parmi ceux-ci, les deux types principaux sont :

  • Les " stream sockets" ( SOCK_STREAM), qui se servent du protocole TCP pour communiquer. Ce protocole permet un transport des données fiable et connecté, au coût d’une performance réduite.
  • Les " datagram sockets" ( SOCK_DGRAM), qui utilisent le protocole UDP. Contrairement à TCP, UDP permet une transmission sans connexion, rapide mais sans garanties.

Nous avons eu l’occasion d’explorer les protocoles TCP et UDP dans l’article sur les couches réseau d’Internet. Dans cet article, nous allons nous concentrer principalement sur les sockets Internet de type “stream”, et voir comment les utiliser pour communiquer à distance.

L’ordre des octets

Lorsqu’on veut envoyer et recevoir des données d’un ordinateur à un autre, il faut être conscient du fait que les systèmes peuvent représenter leurs données de deux manières distinctes et opposées. Prenons l’exemple d’un entier hexadécimal : 2F0A (qui représente le nombre décimal 12042). De par sa taille, cet entier doit impérativement être stocké sur deux octets : 2F et 0A.

Logiquement, on pourrait penser que l’entier sera toujours stocké dans l’ordre : 2F, suivi de 0A. Cet ordre est le plus répandu et s’appelle l’ordre “gros-boutiste” ("Big-Endian" en anglais), puisque l’octet de poids fort de l’entier est stocké en premier. Mais ce n’est pas toujours le cas…

Certains systèmes, particulièrement ceux qui possèdent un processeur Intel ou compatible avec Intel, préfèrent stocker les octets de l’entier en sens inverse, avec l’octet de poids faible en premier : 0A, suivi de 2F. C’est l’ordre qu’on appelle “petit-boutiste” ("Little Endian" en anglais).

                    Premier octet       Deuxième octet
Gros-boutisme
  - hexadécimal     2F                  0A
  - binaire         00101111            00001010

Petit-boutisme
  - hexadécimal     0A                  2F
  - binaire         00001010            00101111

Cette différence potentiellement incompatible entre les systèmes hôte peut évidemment poser problème au moment de l’échange de données.

L’ordre des octets du réseau ("Network Byte Order" en anglais) est toujours gros-boutiste. Cependant, l’ordre des octets du système hôte ("Host Byte Order" en anglais) peut être soit gros-boutiste, soit petit-boutiste, selon son architecture.

Convertir entre l’ordre de l’hôte et l’ordre du réseau

Heureusement, on peut simplement présumer que le système hôte ne stocke pas ses octets correctement par rapport au réseau. Il nous suffit donc de systématiquement réordonner les octets qu’on veut transférer entre l’ordre des octets du système hôte et l’ordre des octets du réseau. Pour cela, nous pouvons utiliser quatre fonctions utilitaires de la bibliothèque <arpa/inet.h> :

uint32_t htonl(uint32_t hostlong);  //"Host to network long"
uint16_t htons(uint16_t hostshort); //"Host to network short"
uint32_t ntohl(uint32_t netlong);   //"Network to host long"
uint16_t ntohs(uint16_t netshort);  //"Network to host short"

Comme on peut le voir, ces fonctions ont deux variantes : celles qui peuvent convertir un short (deux octets, c’est à dire 16 bits), et celles qui convertissent un long (quatre octets, c’est à dire 32 bits). Elles fonctionnent aussi pour les nombre non-signés.

Pour convertir un entier de quatre octets (32 bits) de l’ordre du système hôte vers l’ordre du réseau, on fera appel à la fonction htonl() ("host to network long", soit “hôte vers réseau long” en français). Pour l’opération inverse, on utilisera ntohl() ("network to host long" soit “réseau vers hôte long” en français).

Avec cette mise en garde en tête, nous pouvons nous tourner vers le problème de l’établissement d’une connexion dans nos programmes.

Préparer la connexion

Que notre programme soit un serveur ou un client, nous devons en tout premier lieu préparer une petite structure de données. Cette structure contiendra les informations indispensables à toute socket, notamment l’adresse IP, le port de connexion.

Les structures pour l’adresse IP et le port de connexion

Les structures fondamentales pour indiquer l’adresse IP et le port de connexion se trouvent dans la bibliothèque <netinet/in.h>. Elles ont deux variantes, une pour l’IPv4 et l’autre pour l’IPv6.

Pour une adresse IPv4

Pour une adresse IPv4, nous voudrons une structure sockaddr_in, qui est définie comme suit :

// Pour une adresse IPv4 uniquement
// (voir sockaddr_in6 pour IPv6)
struct sockaddr_in {
    sa_family_t    sin_family;
    in_port_t      sin_port;
    struct in_addr sin_addr;
};
struct in_addr {
    uint32_t       s_addr;
};

Décrivons ce à quoi s’attend cette structure :

  • sin_family : représente la famille de protocoles d’adresse IP, c’est à dire la version du protocole Internet : soit IPv4, soit IPv6. Comme cette structure n’est que pour les adresses IPv4, nous indiquerons toujours ici la constante AF_INET.
  • sin_port : le port auquel nous souhaitons nous connecter. Attention, il faut indiquer ce port avec ses octets dans l’ordre du réseau et non de l’hôte. Par exemple, pour se connecter au port 3302, on voudra utiliser htons() pour l’indiquer : htons(3302).
  • sin_addr : une petite structure de type in_addr, qui contient la représentation en entier d’une adresse IPv4.

Dans la structure in_addr se trouve un seul champ à remplir : s_addr. C’est un entier, dans l’ordre des octets du réseau, qui représente une adresse IPv4. Nous verrons plus tard comment transformer une adresse IP en entier. Toutefois, il existe certaines constantes que nous pouvons utiliser ici (sans oublier de convertir l’ordre de leurs octets en utilisant htonl() !) :

  • INADDR_LOOPBACK : l’adresse IP locale : localhost, ou 127.0.0.1
  • INADDR_ANY : l’adresse IP 0.0.0.0
  • INADDR_BROADCAST : l’adresse IP 255.255.255.255

Pour une adresse IPv6

Une structure similaire existe pour indiquer une adresse IPv6 :

// Pour une adresse IPv6 uniquement
// (voir sockaddr_in pour IPv4)
struct sockaddr_in6 {
    sa_family_t     sin6_family;
    in_port_t       sin6_port;
    uint32_t        sin6_flowinfo;
    struct in6_addr sin6_addr;
    uint32_t        sin6_scope_id;
};
struct in6_addr {
    unsigned char   s6_addr[16];
};

Cette structure sockaddr_in6 s’attend donc aux mêmes données que la structure IPv4 que nous avons vu précédemment. Nous ne nous attarderons pas sur les deux nouveaux champs, sin6_flowinfo et sin6_scope_id, ceci étant un article d’introduction aux sockets.

De même que pour l’IPv4, il existe des variables globales que nous pouvons renseigner dans la structure in6_addr pour l’adresse IPv6 : in6addr_loopback et in6addr_any.

Convertir une adresse IP en entier

Une adresse IPv4 telle que 216.58.192.3 (ou une adresse IPv6 comme 2001:db8:0:85a3::ac1f:8001) n’est pas un entier, c’est une chaîne de caractères. Pour convertir cette chaîne de charactères en entier afin de l’utiliser dans l’une de nos structures précédentes, il nous faut faire appel à une fonction de la bibliothèque <arpa/inet.h> : inet_pton() (“pton” veut dire " presentation to network" ou, en français “présentation au réseau”).

int inet_pton(int af, const char * src, void *dst);

Regardons de plus près ses paramètres :

  • af : un entier qui indique la famille d’adresses de protocole Internet. Pour une adresse IPv4, nous voulons ici renseigner AF_INET ; pour une adresse IPv6, AF_INET6.
  • src : une chaîne de caractères contenant l’adresse IPv4 ou IPv6 à convertir.
  • dst : un pointeur vers une structure in_addr (IPv4) ou in6_addr (IPv6) dans laquelle stocker le résultat de la conversion.

La fonction inet_pton() renvoie :

  • 1 en cas de succès,
  • 0 si src ne contient pas une adresse valide pour la famille indiquée,
  • -1 si af n’est pas une famille d’adresses valide, et met errno à EAFNOSUPPORT.

Pour une adresse IPv4, nous pouvons donc faire :

// IPv4 uniquement
struct sockaddr_in sa;
inet_pton(AF_INET, "216.58.192.3", &(sa.sin_addr));

Pour une adresse IPv6 :

// IPv6 uniquement
struct sockaddr_in6 sa;
inet_pton(AF_INET6, "2001:db8:0:85a3::ac1f:8001", &(sa.sin6_addr));

Bien sûr, la fonction inverse existe aussi, inet_ntop() (“ntop” veut dire " network to presentation", en français “réseau vers présentation”). Elle permet de reconvertir un entier en adresse IP lisible.

Mais comment faire si l’on ne connaît pas l’adresse IP à laquelle nous connecter ? Peut être n’avons nous qu’un nom de domaine, comme http://www.exemple.com

Récupérer l’adresse IP à l’aide de getaddrinfo()

Si l’on ne connaît pas l’adresse IP précise à laquelle nous souhaitons nous connecter, la fonction getaddrinfo() de la bibliothèque <netdb.h> pourra nous aider. Elle nous permet, entres autres, d’indiquer un nom de domaine (http://www.exemple.com) au lieu de son adresse IP. L’appel à getaddrinfo() aura un petit coût en termes de performance puisqu’elle va consulter le DNS pour remplir l’adresse IP à notre place. Son prototype est le suivant :

int getaddrinfo(const char *node, const char *service,
                const struct addrinfo *hints,
                struct addrinfo **res);

Cette fonction n’est pas si compliquée qu’elle en a l’air. Ses paramètres sont les suivants :

  • node : une chaîne de caractères qui représente soit une adresse IP (IPv4 ou IPv6), ou un nom de domaine tel que “www.exemple.com”. Ici, on peut aussi mettre NULL si l’on fournit les bons drapeaux à la structure hints décrite ci-dessous, pour, par exemple, automatiquement remplir l’adresse IP du système.
  • service : une chaîne de caractères qui représente soit le port auquel on souhaite se connecter, par exemple “80”, soit le nom du service, tel que “http”. Dans un système de type Unix, on peut trouver une liste des services et leurs ports dans /etc/services. Cette liste est aussi disponible sur iana.org.
  • hints : un pointeur vers une structure de type addrinfo où l’on peut renseigner plus d’informations sur le type de connexion que l’on souhaite. Ces indications agissent comme un filtre de recherche pour getaddrinfo(). On regardera cette structure de plus près ci-dessous.
  • res : un pointeur vers une liste chaînée de structures de type addrinfogetaddrinfo() peut stocker son ou ses résultats.

La fonction getaddrinfo() nous renvoie 0 lorsqu’elle réussit, ou un code erreur sinon. La fonction gai_strerror() nous permet de traduire l’erreur de getaddrinfo() en chaîne de caractères.

De plus, la fonction freeaddrinfo() nous permet de libérer la mémoire de la structure addrinfo que nous allons examiner ci-dessous.

La structure addrinfo

Deux des paramètres de getaddrinfo(), hints et res, sont des pointeurs vers le même type de structure. Regardons donc de quoi il s’agit :

struct addrinfo {
    int              ai_flags;
    int              ai_family;
    int              ai_socktype;
    int              ai_protocol;
    size_t           ai_addrlen;
    struct sockaddr *ai_addr;
    char            *ai_canonname;
    struct addrinfo *ai_next;
};

Les éléments de cette structure sont les suivants :

  • ai_flags : les drapeaux contenant des options qui peuvent être cumulées avec un OU binaire. Ici, l’option qui pourra nous intéresser le plus est AI_PASSIVE qui indique que la socket que nous allons créer sera utilisée pour écouter et accepter des connexions dans le cadre d’un serveur. Dans ce cas, on n’aura pas à indiquer d’adresse IP lors de notre appel à getaddrinfo() puisque ce sera celle de la machine. Une liste de tous les drapeaux disponibles se trouve sur la page manuel de getaddrinfo().
  • ai_family : la famille d’adresses de protocole Internet. Pour forcer une adresse IPv4, on peut ici indiquer AF_INET ; pour forcer une adresse IPv6, on indiquera plutôt AF_INET6. Un grand avantage, c’est qu’on peut rendre notre code indifférent à la version IP utilisée en indiquant ici AF_UNSPEC.
  • ai_socktype : le type désiré de socket. Deux des constantes disponibles sont SOCK_STREAM, qui utilise TCP, et SOCK_DGRAM qui utilise UDP. On peut aussi mettre 0 ici, ce qui indique à getaddrinfo() qu’elle peut nous renvoyer n’importe quel type d’adresses de socket.
  • ai_protocol : le protocole de l’adresse de la socket. En général, il n’y a qu’un protocole valide par type de socket, TCP pour une socket stream ou UDP pour une socket datagramme, donc on peut mettre la valeur à 0 pour indiquer à getaddrinfo() qu’elle peut renvoyer les adresses de n’importe quel type.
  • ai_addrlen : getaddrinfo() indiquera la longueur de l’adresse ici.
  • ai_addr : un pointeur vers une structure de type sockaddr_in ou sockaddr_in6 que nous avons examiné dans la partie précédente et que getaddrinfo() remplira pour nous.
  • ai_canonname : utilisé uniquement si le drapeau AI_CANONNAME est indiqué dans ai_flags.
  • ai_next : un pointeur vers la prochaine structure dans la liste.

Exemple de getaddrinfo()

Écrivons un petit programme qui nous imprime les adresses IP d’un nom de domaine qu’on lui indique :

// showip.c -- un simple programme qui montre la ou les adresses IP d'un domaine
#include <arpa/inet.h>
#include <netdb.h>
#include <stdio.h>
#include <string.h>

int main(int ac, char **av) {
    struct addrinfo hints; // Indications pour getaddrinfo()
    struct addrinfo *res;  // Résultat de getaddrinfo()
    struct addrinfo *r;    // Pointeur pour itérer sur les résultats
    int status; // Valeur de retour de getaddrinfo()
    char buffer[INET6_ADDRSTRLEN]; // Buffer pour reconvertir l'adresse IP

    if (ac != 2) {
        fprintf(stderr, "usage: /a.out hostname\n");
        return (1);
    }

    memset(&hints, 0, sizeof hints); // Initialise la structure
    hints.ai_family = AF_UNSPEC; // IPv4 ou IPv6
    hints.ai_socktype = SOCK_STREAM; // TCP

    // Récupère la ou les adresses IP associées
    status = getaddrinfo(av[1], 0, &hints, &res);
    if (status != 0) { // en cas d'erreur
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(status));
        return (2);
    }

    printf("IP adresses for %s:\n", av[1]);

    r = res;
    while (r != NULL) {
        void *addr; // Pointeur vers l'adresse IP
        if (r->ai_family == AF_INET) { // Adresse IPv4
            // il faut caster l'adresse en structure sockaddr_in pour récupérer
            // l'adresse IP, comme le champ ai_addr pourrait être soit
            // un sockaddr_in soit un sockaddr_in6
            struct sockaddr_in *ipv4 = (struct sockaddr_in *)r->ai_addr;
            // Transforme l'entier en adresse IP lisible
            inet_ntop(r->ai_family, &(ipv4->sin_addr), buffer, sizeof buffer);
            printf("IPv4: %s\n", buffer);
        } else { // Adresse IPv6
            struct sockaddr_in6 *ipv6 = (struct sockaddr_in6 *)r->ai_addr;
            inet_ntop(r->ai_family, &(ipv6->sin6_addr), buffer, sizeof buffer);
            printf("IPv6: %s\n", buffer);
        }
        r = r->ai_next; // Prochaine adresse renseignée par getaddrinfo()
    }
    freeaddrinfo(res); // Libère la mémoire
    return (0);
}

Quand on lance ce programme avec un nom de domaine en argument, on reçoit bien la ou les adresses IP associées :

Résultat d’un programme qui trouve les adresses IP associées à un nom de domaine grâce à getaddrinfo().

Maintenant que l’on sait comment récupérer une adresse IP et la stocker dans la structure appropriée, nous pouvons tourner notre attention vers la préparation de notre socket afin de véritablement établir notre connexion.

Préparer une socket

Enfin, nous pouvons créer un descripteur de fichier pour notre socket. C’est grâce à celui-ci qu’on pourra on pourra lire et écrire pour recevoir et envoyer respectivement des données. C’est l’appel système qui se trouve dans <sys/socket.h>, tout simplement nommé socket(), qu’il nous faut ! Voici son prototype :

int socket(int domain, int type, int protocol);

Les paramètres qu’elle requiert sont les suivants :

  • domain : la famille de protocoles de la socket, généralement PF_INET ou PF_INET6. PF_INET existe pour des raisons historiques et est en pratique identique à AF_INET, de même pour PF_INET6.
  • type : le type de socket, généralement SOCK_STREAM ou SOCK_DGRAM.
  • protocol : le protocole à utiliser avec la socket. En général, il n’y a qu’un protocole valide par type de socket, TCP pour une socket stream ou UDP pour une socket datagramme, donc on peut mettre la valeur à 0.

La fonction socket() nous renvoie le descripteur de fichier de la nouvelle socket. En cas d’échec, il renvoie -1 et indique l’erreur rencontrée dans errno.

En pratique, nous ne renseignerons probablement pas les paramètres de la fonction socket() manuellement, nous mettrons plutôt les valeurs retournées par notre getaddrinfo(), un peu comme ceci :

int status;
int socket_fd;
struct addrinfo hints;
struct addrinfo *res;

// on remplit hints pour préparer l'appel à getaddrinfo()

status = getaddrinfo("www.example.com", "http", &hints, &res);
// on vérifie si getaddrinfo() a échoué ou pas

socket_fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
// on vérifie si socket() a échoué ou pas

Mais ce descripteur de fichier de socket n’est pas encore connecté. Naturellement, nous voulons l’associer à une adresse socket (c’est à dire une adresse IP et un port). Pour ceci, nous avons deux choix :

  • Lier la socket à une adresse distante avec connect(). Ceci lui permet d’agir comme un client, capable de faire des requêtes à un serveur distant.
  • Lier la socket à une adresse locale avec bind(). Dans ce cas, elle pourra agir comme un serveur, capable d’accepter des connexions distantes.
Diagramme de programmation réseau via socket pour serveur et client. Le serveur utilise les fonctions socket, bind, listen, accept, recv, sand et close, tandis que le client utilise les fonctions socket, connect, send, recv et close.
Diagramme du processus de connexion entre un serveur et un client. Fait avec draw.io.

Explorons tout d’abord le côté client, puis nous examinerons le côté serveur.

Côté client : se connecter à un serveur via socket

Pour développer un client, nous avons seulement besoin d’une socket qui se connecte à un serveur distant. Il nous suffit donc d’utiliser l’appel système connect() de la bibliothèque <sys/socket.h> :

int connect(int sockfd, const struct sockaddr *serv_addr,
            socklen_t addrlen);

Ses paramètres sont assez intuitifs :

  • sockfd : le descripteur de fichier que l’on vient de récupérer avec notre appel à socket(),
  • serv_addr : un pointeur vers la structure de données contenant les informations de connexion. Ceci sera soit la sockaddr_in pour une adresse IPv4, soit la sockaddr_in6 pour une adresse IPv6.
  • addrlen : la longueur en octets de la structure précédente, serv_addr.

De façon prévisible, la fonction nous renvoie 0 en cas de succès, -1 en cas d’échec avec le code de l’erreur dans errno.

Encore une fois, toutes les données nécessaires à la connexion se trouve dans la structure renvoyée par getaddrinfo() :

int status;
int socket_fd;
struct addrinfo hints;
struct addrinfo *res;

// on remplit hints pour préparer l'appel à getaddrinfo()

status = getaddrinfo("www.example.com", "http", &hints, &res);
// on vérifie si getaddrinfo() a échoué ou pas

socket_fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
// on vérifie si socket() a échoué ou pas

connect(socket_fd, res->ai_addr, res->ai_addrlen);

Et voilà, le tour est joué ! Notre socket est prête à envoyer et à recevoir des données. Mais avant de voir comment faire ça, voyons comment établir une connexion du côté serveur.

Côté serveur : accepter des connexions client via socket

Si l’on veut développer un serveur, la connexion devra se faire en trois étapes. Tout d’abord, il faudra lier la socket à une adresse et un port de la machine locale. Ensuite, nous voudrons écouter pour détecter des demandes de connexion via la socket. Enfin, il nous faudra un moyen d’accepter les connexions client.

Lier la socket à l’addresse IP

La fonction bind() de <sys/socket.h> permet de lier la socket à une adresse et un port local. Son prototype est pratiquement identique à sa fonction jumelle connect() que nous venons de voir pour le côté client :

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

Tout comme connect(), les paramètres de bind() sont :

  • sockfd : le descripteur de fichier que l’on a récupéré avec notre appel à socket().
  • addr : un pointeur vers la structure de données contenant les informations de connexion. Ceci sera soit la sockaddr_in pour une adresse IPv4, soit la sockaddr_in6 pour une adresse IPv6.
  • addrlen : la longueur en octets de la structure précédente, addr.

Comme prévu, la fonction nous renvoie 0 en cas de succès, -1 en cas d’échec avec le code de l’erreur dans errno.

Écouter via la socket pour détecter des demandes de connexion

Ensuite, nous devons marquer la socket comme étant “passive”, c’est à dire qu’elle sera utilisée pour accepter les demandes de connexion entrantes sur l’adresse et le port auxquels elle a été liée avec bind(). Pour cela, nous utiliserons la fonction listen(), qui se trouve aussi dans <sys/socket.h>.

int listen(int sockfd, int backlog);

La fonction listen() prend deux paramètres :

  • sockfd : le descripteur de fichier de la socket qu’on a récupérer avec notre appel à socket().
  • backlong : un entier qui représente le nombre de connexions autorisées dans la file d’attente. Les connexions entrantes seront placées dans cette file en attendant d’être acceptées. La plupart des systèmes limitent automatiquement ce nombre maximal de demandes de connexion en attente à 20, mais on peut manuellement indiquer le nombre que l’on souhaite.

Si l’appel à listen() ne rencontre aucune erreur, la fonction renvoie 0. Au cas contraire, elle renvoie -1 et indique l’erreur rencontrée dans errno.

Accepter une connexion client

Enfin, nous pouvons accepter les demandes de connexion d’un client. Lorsqu’un client distant se connecte avec connect() sur le port de notre machine sur laquelle notre serveur écoute avec listen(), sa demande de connexion sera mise dans notre file d’attente. Lorsqu’on accepte la demande avec accept(), cette fonction nous renverra un nouveau descripteur de fichier que l’on pourra utiliser pour communiquer avec ce client. On se retrouvera donc avec deux descripteurs de fichier : celui de notre socket originale qui continuera à écouter, et celui liée au client avec lequel on pourra envoyer et recevoir des données.

Le prototype de la fonction accept() de <sys/socket.h> est le suivant :

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

Regardons de plus près ses paramètres :

  • sockfd : le descripteur de fichier de la socket qui écoute, que l’on a récupéré avec notre appel à socket().
  • addr : un pointeur vers une structure sockaddraccept() peut remplir les informations de la socket client. Si l’on ne souhaite pas recevoir l’adresse ou le port du client, on peut mettre NULL ici.
  • addrlen : un pointeur vers un entier qui contient la taille en octets de la structure précédente. accept() ajustera cette valeur si elle est trop grande pour la taille finale de la structure, mais elle tronquera l’adresse si cette valeur est plus petite que la taille de l’adresse.

La fonction accept() renvoie le descripteur de fichier de la nouvelle socket, ou -1 en cas d’erreur avec l’erreur rencontrée dans errno.

Exemple de sockets d’un serveur

Créons un micro-serveur qui accepte une demande de connexion grâce à sa socket et aux appels aux fonctions bind(), listen() et accept() :

  • Avec getaddrinfo()
  • Sans getaddrinfo()
// server.c - un micro-serveur qui accepte une connexion avant de s'arrêter
#include <errno.h>
#include <netdb.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>

#define PORT "4242" // le port de notre serveur
#define BACKLOG 10  // nombre max de demandes de connexion dans la file d'attente

int main(void)
{
    struct addrinfo hints;
    struct addrinfo *res;
    int socket_fd;
    int client_fd;
    int status;
    // sockaddr_storage est une structure qui n'est pas associé à
    // une famille particulière. Cela nous permet de récupérer
    // une adresse IPv4 ou IPv6
    struct sockaddr_storage client_addr;
    socklen_t addr_size;

    // on prépare l'adresse et le port pour la socket de notre serveur
    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_UNSPEC;        // IPv4 ou IPv6, indifférent
    hints.ai_socktype = SOCK_STREAM;    // Connexion TCP
    hints.ai_flags = AI_PASSIVE;        // Remplit l'IP automatiquement

    status = getaddrinfo(NULL, PORT, &hints, &res);
    if (status != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(status));
        return (1);
    }

    // on crée la socket, on a lie et on écoute dessus
    socket_fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
    status = bind(socket_fd, res->ai_addr, res->ai_addrlen);
    if (status != 0) {
        fprintf(stderr, "bind: %s\n", strerror(errno));
        return (2);
    }
    listen(socket_fd, BACKLOG);

    // on accept une connexion entrante
    addr_size = sizeof client_addr;
    client_fd = accept(socket_fd, (struct sockaddr *)&client_addr, &addr_size);
    if (client_fd == -1) {
        fprintf(stderr, "accept: %s\n", strerror(errno));
        return (3);
    }
    printf("New connection! Socket fd: %d, client fd: %d\n", socket_fd, client_fd);

    // on est prêts à communiquer avec le client via le client_fd !

    return (0);
}
// server.c - un micro-serveur qui accepte une connexion avant de s'arrêter
#include <errno.h>
#include <netdb.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>

#define PORT 4242  // le port de notre serveur
#define BACKLOG 10 // nombre max de demandes de connexion dans la file d'attente

int main(void)
{
    struct sockaddr_in sa;
    int socket_fd;
    int client_fd;
    int status;
    // sockaddr_storage est une structure qui n'est pas associé à
    // une famille particulière. Cela nous permet de récupérer
    // une adresse IPv4 ou IPv6
    struct sockaddr_storage client_addr;
    socklen_t addr_size;

    // on prépare l'adresse et le port pour la socket de notre serveur
    memset(&sa, 0, sizeof sa);
    sa.sin_family = AF_INET; // IPv4; utiliser AF_INET6 pour IPv6
    sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1, localhost
    sa.sin_port = htons(PORT);

    // on crée la socket, on a lie et on écoute dessus
    socket_fd = socket(sa.sin_family, SOCK_STREAM, 0);
    status = bind(socket_fd, (struct sockaddr *)&sa, sizeof sa);
    if (status != 0) {
        fprintf(stderr, "bind: %s\n", strerror(errno));
        return (2);
    }
    listen(socket_fd, BACKLOG);

    // on accept une connexion entrante
    addr_size = sizeof client_addr;
    client_fd = accept(socket_fd, (struct sockaddr *)&client_addr, &addr_size);
    if (client_fd == -1) {
        fprintf(stderr, "accept: %s\n", strerror(errno));
        return (3);
    }
    printf("New connection! Socket fd: %d, client fd: %d\n", socket_fd, client_fd);

    // on est prêts à communiquer avec le client via le client_fd !

    return (0);
}

Quand on compile et qu’on lance ce programme, on s’apercevra qu’il semble tourner dans le vide. C’est parce que notre appel à accept() bloque l’exécution en attendant une demande de connexion. Ceci deviendra un important facteur à prendre en compte lorsqu’on essaiera de gérer plusieurs connexions client à la fois. Nous verrons différents moyens d’y remédier en fin d’article.

Pour simuler une demande de connexion, on peut lancer dans un autre terminal l’outil nc (netcat) en lui spécifiant l’adresse de notre machine locale, localhost (ou 127.0.0.1) et le port sur lequel tourne notre petit serveur, dans cet exemple, 4242.

Résultat d’un programme serveur qui détecte une nouvelle connexion grâce à une socket.

Et voilà, notre serveur a bien détecté et accepté la nouvelle connexion, et nous affiche les descripteurs de fichier de notre socket serveur et de la nouvelle socket client.

Envoyer et recevoir des données via socket

Établir une connexion d’un client vers un serveur ou vice-versa ne sert pas à grand chose si l’on se sait pas comment envoyer et recevoir des données. Les plus perspicaces d’entre nous se rendront compte que si les sockets ne sont que des descripteurs de fichiers, on peut sans doute utiliser les appels systèmes read() et write(). Et ils auront tout à fait raison ! Mais il existe d’autres fonctions qui nous permettent un plus grand contrôle sur l’envoi et la réception de nos données…

Envoyer des données via une socket

La fonction send() de la bibliothèque <sys/socket.h> nous permet d’envoyer des données via une socket de type “stream”, qui utilise une connexion TCP.

ssize_t send(int socket, const void *buf, size_t len, int flags);

Ses paramètres sont les suivants :

  • socket : le descripteur de fichier de la socket via laquelle envoyer des données. Chez un client, ce sera le fd qu’on a récupéré de notre appel à la fonction socket() ; du côté d’un serveur ce sera plutôt le fd du client qu’on aura récupéré grâce à notre appel à accept().
  • buf : un buffer ou une chaîne de caractères qui contient le message à envoyer.
  • len : un entier qui représente la taille en octets du message à envoyer.
  • flags : un entier qui contient des drapeaux concernant la transmission du message. Une liste des drapeaux valides se trouve sur la page manuel de send(). Ici, il suffira généralement de mettre 0.

La fonction send() nous renvoie le nombre d’octets qui ont été envoyés avec succès. Attention, il se peut que send() ne réussisse pas à tout envoyer d’un coup ! Il nous faudra donc être vigilants et comparer le retour de cette fonction à la longueur du message que l’on souhaite envoyer pour continuer l’envoi du reste plus tard si besoin. Comme d’habitude, cette fonction nous renvoie aussi -1 en cas d’erreur et indique le code erreur dans errno.

Pour les sockets de type datagramme, c’est à dire qui utilisent le protocole sans connexion UDP, il existe une fonction similaire, sendto(). En plus des paramètres que l’on renseigne pour send(), il faut aussi lui fournir l’adresse de destination sous la forme d’une structure de type sockaddr.

Recevoir des données via une socket

Tout comme sa fonction inverse send(), recv() se trouve dans <sys/socket.h>. Elle nous permet de recevoir des données depuis une socket. Son prototype est le suivant :

ssize_t recv(int socket, void *buf, ssize_t len, int flags);

Les paramètres de recv() sont :

  • socket : le descripteur de fichier de la socket via laquelle recevoir des données. Côté client, on récupère ce fd avec un appel à la fonction socket() ; côté serveur on le récupère grâce à l’appel à accept().
  • buf : un pointeur vers un buffer, une zone mémoire où stocker les données lues.
  • len : la taille maximale en octets du buffer précédent.
  • flags : les drapeaux liés à la réception du message. En général il suffit de mettre 0 ici. Voir la page manuel de recv() pour la liste des drapeaux disponibles.

Tout comme send(), recv() renvoie le nombre d’octets qu’elle a réussi à stocker dans le buffer. Par contre, si recv() renvoie 0, cela ne peut vouloir dire qu’une chose : l’ordinateur distant a fermé la connexion. Naturellement, la fonction recv() peut aussi renvoyer -1 en cas d’erreur, auquel cas elle renseigne l’erreur rencontrée dans errno.

Il existe aussi une fonction similaire, recvfrom(), pour les sockets de type datagramme qui utilisent le protocole sans connexion UDP. En plus des paramètres que l’on fournit pour recv(), il faut aussi lui indiquer l’adresse de l’origine du message sous la forme d’une structure de type sockaddr.

Fermer la connexion d’une socket

Quand on a terminé d’envoyer et de recevoir des données, on pourra fermer notre socket. Comme tout descripteur de fichier, on peut fermer une socket avec un simple appel à close() de <unistd.h>. Ceci détruit le descripteur de fichier et empêche toute communication vers cette socket : le côté distant recevra une erreur s’il tente d’y envoyer ou d’y recevoir quoique ce soit.

Mais une autre fonction mérite d’être mentionnée: shutdown() de <sys/socket.h>. Celle-ci nous permet d’exercer plus de contrôle sur la fermeture d’une socket. Son prototype est :

int shutdown(int sockfd, int how);

Ses paramètres sont assez simples:

  • sockfd : le descripteur de fichier de la socket à fermer.
  • how : un entier qui contient des drapeaux qui indiquent comment fermer la socket. Les drapeaux valides sont :
    • SHUT_RD pour fermer le côté écriture de la socket et empêcher la réception de données.
    • SHUT_WR pour fermer le côté lecture de la socket et empêcher l’envoi de données.
    • SHUT_RDWR pour fermer les deux côtés et empêcher la réception et la transmission de données.

Gardons à l’esprit que shutdown() ne détruit pas le descripteur de fichier de la socket et ne libère pas la mémoire associée, elle modifie simplement ses permissions de lecture et d’écriture. Un appel à close() est donc tout de même nécessaire.

Comme tout appel système qui se respecte, shutdown() renvoie 0 en cas de succès et -1 en cas d’erreur, avec le code erreur dans errno.

Exemple de communication via socket entre serveur et client

Avec tout ce que nous avons appris jusqu’à présent concernant les sockets, créons deux petits programmes. Le premier sera un serveur qui écoutera sur le port 4242 de notre machine. Il acceptera une connexion client et attendra de recevoir un message avant de lui répondre. Le second programme sera un client auquel on fournira en argument un message à envoyer au serveur. Il attendra alors sa réponse avant de fermer la connexion.

  • server.c
  • client.c
// server.c - un micro-serveur qui accepte une connexion client, attend un message, et y répond
#include <errno.h>
#include <netdb.h>
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>

#define PORT 4242  // le port de notre serveur
#define BACKLOG 10 // nombre max de demandes de connexion

int main(void)
{
    printf("---- SERVER ----\n\n");
    struct sockaddr_in sa;
    int socket_fd;
    int client_fd;
    int status;
    struct sockaddr_storage client_addr;
    socklen_t addr_size;
    char buffer[BUFSIZ];
    int bytes_read;

    // on prépare l'adresse et le port pour la socket de notre serveur
    memset(&sa, 0, sizeof sa);
    sa.sin_family = AF_INET; // IPv4
    sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1, localhost
    sa.sin_port = htons(PORT);

    // on crée la socket, on a lit et on écoute dessus
    socket_fd = socket(sa.sin_family, SOCK_STREAM, 0);
    if (socket_fd == -1) {
        fprintf(stderr, "socket fd error: %s\n", strerror(errno));
        return (1);
    }
    printf("Created server socket fd: %d\n", socket_fd);

    status = bind(socket_fd, (struct sockaddr *)&sa, sizeof sa);
    if (status != 0) {
        fprintf(stderr, "bind error: %s\n", strerror(errno));
        return (2);
    }
    printf("Bound socket to localhost port %d\n", PORT);

    printf("Listening on port %d\n", PORT);
    status = listen(socket_fd, BACKLOG);
    if (status != 0) {
        fprintf(stderr, "listen error: %s\n", strerror(errno));
        return (3);
    }

    // on accepte une connexion entrante
    addr_size = sizeof client_addr;
    client_fd = accept(socket_fd, (struct sockaddr *)&client_addr, &addr_size);
    if (client_fd == -1) {
        fprintf(stderr, "client fd error: %s\n", strerror(errno));
        return (4);
    }
    printf("Accepted new connection on client socket fd: %d\n", client_fd);

    // on recoit un message via la socket client
    bytes_read = 1;
    while (bytes_read >= 0) {
        printf("Reading client socket %d\n", client_fd);
        bytes_read = recv(client_fd, buffer, BUFSIZ, 0);
        if (bytes_read == 0) {
            printf("Client socket %d: closed connection.\n", client_fd);
            break ;
        }
        else if (bytes_read == -1) {
            fprintf(stderr, "recv error: %s\n", strerror(errno));
            break ;
        }
        else {
            // Si on a bien reçu un message, on va l'imprimer
            // puis renvoyer un message au client
            char *msg = "Got your message.";
            int msg_len = strlen(msg);
            int bytes_sent;

            buffer[bytes_read] = '\0';
            printf("Message received from client socket %d: \"%s\"\n", client_fd, buffer);

            bytes_sent = send(client_fd, msg, msg_len, 0);
            if (bytes_sent == -1) {
                fprintf(stderr, "send error: %s\n", strerror(errno));
            }
            else if (bytes_sent == msg_len) {
                printf("Sent full message to client socket %d: \"%s\"\n", client_fd, msg);
            }
            else {
                printf("Sent partial message to client socket %d: %d bytes sent.\n", client_fd, bytes_sent);
            }
        }
    }

    printf("Closing client socket\n");
    close(client_fd);
    printf("Closing server socket\n");
    close(socket_fd);

    return (0);
}
// client.c - un micro-client qui envoie un message à un serveur et attend sa réponse
#include <errno.h>
#include <netdb.h>
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>

#define PORT 4242  // le port du serveur auquel on va se connecter

int main(int ac, char **av)
{
    printf("---- CLIENT ----\n\n");
    struct sockaddr_in sa;
    int socket_fd;
    int status;
    char buffer[BUFSIZ];
    int bytes_read;
    char *msg;
    int msg_len;
    int bytes_sent;

    if (ac != 2) {
        printf("Usage: ./client \"Message to send\"");
        return (1);
    }

    // on prépare l'adresse et le port auquel on veut se connecter
    memset(&sa, 0, sizeof sa);
    sa.sin_family = AF_INET; // IPv4
    sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1, localhost
    sa.sin_port = htons(PORT);

    // on crée la socket et on la connecte au serveur distant
    socket_fd = socket(sa.sin_family, SOCK_STREAM, 0);
    if (socket_fd == -1) {
        fprintf(stderr, "socket fd error: %s\n", strerror(errno));
        return (2);
    }
    printf("Created socket fd: %d\n", socket_fd);

    status = connect(socket_fd, (struct sockaddr *)&sa, sizeof sa);
    if (status != 0) {
        fprintf(stderr, "connect error: %s\n", strerror(errno));
        return (3);
    }
    printf("Connected socket to localhost port %d\n", PORT);

    // on envoie un message au serveur
    msg = av[1];
    msg_len = strlen(msg);
    bytes_sent = send(socket_fd, msg, msg_len, 0);
    if (bytes_sent == -1) {
        fprintf(stderr, "send error: %s\n", strerror(errno));
    }
    else if (bytes_sent == msg_len) {
        printf("Sent full message: \"%s\"\n", msg);
    }
    else {
        printf("Sent partial message: %d bytes sent.\n", bytes_sent);
    }

    // on attend de recevoir un message via la socket
    bytes_read = 1;
    while (bytes_read >= 0) {
        bytes_read = recv(socket_fd, buffer, BUFSIZ, 0);
        if (bytes_read == 0) {
            printf("Server closed connection.\n");
            break ;
        }
        else if (bytes_read == -1) {
            fprintf(stderr, "recv error: %s\n", strerror(errno));
            break ;
        }
        else {
            // Si on a bien reçu un message, on va l'imprimer
            buffer[bytes_read] = '\0';
            printf("Message received: \"%s\"\n", buffer);
            break ;
        }
    }

    printf("Closing socket\n");
    close(socket_fd);
    return (0);
}

On va ensuite compiler et lancer le serveur dans un terminal et le client dans un autre. Résultat ?

Résultat de deux programmes de test : un mini serveur et un mini client. Le client et le serveur établissent une connexion et communiquent grâce à leurs sockets.

Le serveur a bien reçu le message qu’on a demandé au client d’envoyer, et a réussi à envoyer sa confirmation ! Le client a ensuite fermé sa socket, chose que le serveur a su détecter puisque recv() lui a renvoyé 0.

Le multiplexage : gérer plusieurs sockets sans bloquer

On aura peut être remarqué lors de nos tests qu’une grande partie des fonctions qu’on utilise avec les sockets, comme accept() et recv() sont bloquantes. C’est à dire qu’elles suspendent notre processus tant qu’elles ne finissent pas leurs exécutions. Si on lance notre micro-serveur tout seul sans jamais s’y connecter avec un client, il restera indéfiniment bloqué au niveau de son accept() puisque cet appel système attend une demande de connection qui ne viendra jamais.

Ceci n’est pas une mauvaise chose en soi, mais cela peut poser problème pour un serveur qui tente de gérer plusieurs clients en même temps. Si on attend de recevoir un message d’un client, cela ne devrait pas nous empêcher d’accepter une nouvelle connexion ou un autre message depuis un autre client. Ceci est un autre un aspect de la programmation concurrente, où un programme doit pouvoir gérer plusieurs choses simultanément.

Heureusement, il y a des méthodes, dites de “multiplexage”, qui nous permettent de rendre nos sockets non-bloquantes, et pour détecter quand elles sont prêtes à être utilisées.

Rendre les sockets non-bloquantes avec fcntl()

Lorsqu’on invoque l’appel système socket() pour récupérer un descripteur de fichier pour notre socket, le noyau du système d’exploitation la crée automatiquement en mode bloquant. Nous pouvons, si on le souhaite, la rendre non-bloquante à l’aide de la fonction de manipulation de fichier fcntl() de <unistd.h> et <fcntl.h>, de cette façon :

...
socket_fd = socket(PF_INET, SOCK_STREAM, 0);
fcntl(socket_fd, F_SETFL, O_NONBLOCK);
...

Quand on rend la socket non-bloquante avec O_NONBLOCK, cela empêche les appels systèmes tels que recv() de suspendre notre programme le temps de leur exécution. S’il n’y a rien à lire dans une socket, recv() renvoie alors immédiatement -1 et indique EAGAIN ou EWOULDBLOCK dans errno. On peut de cette manière boucler sur nos sockets une à une afin de voir si elles ont quelque chose à lire, et sinon on continue. La même chose est possible pour toute fonction bloquante, comme par exemple, accept().

Cependant, boucler sur toutes nos sockets clients de cette manière est une opération très intensive pour notre pauvre CPU, particulièrement s’il y en a des centaines ! Il y a de bien meilleurs moyens de gérer ce problème de blocage, et nous allons les découvrir maintenant.

Surveiller les sockets avec select()

Ce qui serait pratique, ce serait un moyen de surveiller l’ensemble de nos descripteurs de fichiers de sockets pour être avertis lorsque l’un d’entre eux est prêt pour une opération. Après tout, si on sait que la socket est prête, on peut y lire ou y écrire sans crainte de bloquer.

C’est exactement ce que fait la fonction select(). Pour l’utiliser, il nous faudra importer <sys/select.h>, <sys/time.h>, <sys/types.h>, et <unistd.h>. Son prototype est le suivant :

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

Ses paramètres semblent un peu compliqués, alors regardons-les de plus près :

  • nfds : un entier qui indique la valeur du plus grand descripteur de fichier à surveiller, plus un.
  • readfds : un ensemble de descripteurs de fichiers à surveiller pour la lecture, pour vérifier qu’un appel à read() ou recv() ne bloquera pas. Peut être NULL.
  • writefds : un ensemble de descripteurs de fichiers à surveiller pour l’écriture, pour vérifier qu’un appel à write() ou send() ne bloquera pas. Peut être NULL.
  • exceptfds : un ensemble de descripteurs de fichiers à surveiller pour l’occurrence de condition d’exception. Peut être NULL.
  • timeout : un délai après lequel on force select() à terminer son exécution si aucun des descripteurs de fichiers ne changent d’état.

En cas de réussite, select() modifie chacun des sets pour indiquer quels descripteurs de fichiers sont prêts pour une opération. Elle renvoie aussi le nombre total de descripteurs de fichier qui sont prêts parmi les trois ensembles. Si aucun des descripteurs ne sont prêts avant la fin du timeout indiqué, select() peut renvoyer 0.

En cas d’erreur, select() renvoie -1 et indique l’erreur dans errno. Elle ne modifie dans ce cas aucun des ensembles de descripteurs de fichier.

Manipuler les ensembles de descripteurs pour select()

Pour manipuler les ensembles de descripteurs de fichiers que l’on veut surveiller avec select(), on va vouloir faire appel aux macros suivantes :

void FD_CLR(int fd, fd_set *set);   // Retire un fd de l'ensemble
int  FD_ISSET(int fd, fd_set *set); // Vérifie si un fd fait partie de l'ensemble
void FD_SET(int fd, fd_set *set);   // Ajoute un fd à l'ensemble
void FD_ZERO(fd_set *set);          // Met l'ensemble à 0

Le timeout de select()

Le paramètre timeout représente la limite de temps maximal passé dans la fonction select(). Si aucun des descripteurs de fichier dans les ensembles qu’elle surveille ne deviennent prêts à effectuer une opération passé ce délai, select() retournera. On pourra alors faire autre chose, comme par exemple imprimer un message pour indiquer qu’on attend toujours.

La structure à utiliser pour la valeur temporelle, timeval, se trouve dans <sys/time.h> :

struct timeval {
    long    tv_sec;    // secondes
    long    tv_usec;   // microsecondes
};

Si cette valeur de temps est à 0, select() retournera immédiatement ; si on y met NULL, select() pourra bloquer indéfiniment si aucun des descripteurs de fichier ne changent d’état.

Sur certains systèmes Linux, select() modifie la valeur de timeval quand elle termine son exécution pour refléter le temps restant. Ceci est loin d’être universel, donc par souci de portabilité, il ne faudrait pas compter sur cet aspect.

Exemple de surveillance de sockets avec select()

Tentons donc de créer un petit serveur qui surveille avec select() chaque descripteur de fichier de socket connectée pour la lecture. Lorsque l’une d’entre elles est prête à lire, on vérifiera d’abord s’il s’agit de la socket principale de notre serveur, auquel cas il faudra accepter une nouvelle connexion client. En effet, il est très utile de savoir que la socket qui est en train de listen() sera marquée prête pour la lecture si elle a une connexion à accepter ! Si, au contraire, le descripteur de fichier prêt à être lu correspond à une socket client, on y lira le message reçu pour ensuite le relayer à toutes les autres sockets connectées.

// server.c - un petit serveur qui surveille ses sockets avec select() pour accepter des demandes de connexion et relaie les messages de ses clients
#include <errno.h>
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>

#define PORT 4242  // le port de notre serveur

int create_server_socket(void);
void accept_new_connection(int listener_socket, fd_set *all_sockets, int *fd_max);
void read_data_from_socket(int socket, fd_set *all_sockets, int fd_max, int server_socket);

int main(void)
{
    printf("---- SERVER ----\n\n");

    int server_socket;
    int status;

    // Pour surveiller les sockets clients :
    fd_set all_sockets; // Ensemble de toutes les sockets du serveur
    fd_set read_fds;    // Ensemble temporaire pour select()
    int fd_max;         // Descripteur de la plus grande socket
    struct timeval timer;

    // Création de la socket du serveur
    server_socket = create_server_socket();
    if (server_socket == -1) {
        return (1);
    }

    // Écoute du port via la socket
    printf("[Server] Listening on port %d\n", PORT);
    status = listen(server_socket, 10);
    if (status != 0) {
        fprintf(stderr, "[Server] Listen error: %s\n", strerror(errno));
        return (3);
    }

    // Préparation des ensembles de sockets pour select()
    FD_ZERO(&all_sockets);
    FD_ZERO(&read_fds);
    FD_SET(server_socket, &all_sockets); // Ajout de la socket principale à l'ensemble
    fd_max = server_socket; // Le descripteur le plus grand est forcément celui de notre seule socket
    printf("[Server] Set up select fd sets\n");

    while (1) { // Boucle principale
        // Copie l'ensemble des sockets puisque select() modifie l'ensemble surveillé
        read_fds = all_sockets;
        // Timeout de 2 secondes pour select()
        timer.tv_sec = 2;
        timer.tv_usec = 0;

        // Surveille les sockets prêtes à être lues
        status = select(fd_max + 1, &read_fds, NULL, NULL, &timer);
        if (status == -1) {
            fprintf(stderr, "[Server] Select error: %s\n", strerror(errno));
            exit(1);
        }
        else if (status == 0) {
            // Aucun descipteur de fichier de socket n'est prêt pour la lecture
            printf("[Server] Waiting...\n");
            continue;
        }

        // Boucle sur nos sockets
        for (int i = 0; i <= fd_max; i++) {
            if (FD_ISSET(i, &read_fds) != 1) {
                // Le fd i n'est pas une socket à surveiller
                // on s'arrête là et on continue la boucle
                continue ;
            }
            printf("[%d] Ready for I/O operation\n", i);
            // La socket est prête à être lue !
            if (i == server_socket) {
                // La socket est notre socket serveur qui écoute le port
                accept_new_connection(server_socket, &all_sockets, &fd_max);
            }
            else {
                // La socket est une socket client, on va la lire
                read_data_from_socket(i, &all_sockets, fd_max, server_socket);
            }
        }
    }
    return (0);
}

// Renvoie la socket du serveur liée à l'adresse et au port qu'on veut écouter
int create_server_socket(void) {
    struct sockaddr_in sa;
    int socket_fd;
    int status;

    // Préparaton de l'adresse et du port pour la socket de notre serveur
    memset(&sa, 0, sizeof sa);
    sa.sin_family = AF_INET; // IPv4
    sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1, localhost
    sa.sin_port = htons(PORT);

    // Création de la socket
    socket_fd = socket(sa.sin_family, SOCK_STREAM, 0);
    if (socket_fd == -1) {
        fprintf(stderr, "[Server] Socket error: %s\n", strerror(errno));
        return (-1);
    }
    printf("[Server] Created server socket fd: %d\n", socket_fd);

    // Liaison de la socket à l'adresse et au port
    status = bind(socket_fd, (struct sockaddr *)&sa, sizeof sa);
    if (status != 0) {
        fprintf(stderr, "[Server] Bind error: %s\n", strerror(errno));
        return (-1);
    }
    printf("[Server] Bound socket to localhost port %d\n", PORT);
    return (socket_fd);
}

// Accepte une nouvelle connexion et ajoute la nouvelle socket à l'ensemble des sockets
void accept_new_connection(int server_socket, fd_set *all_sockets, int *fd_max)
{
    int client_fd;
    char msg_to_send[BUFSIZ];
    int status;

    client_fd = accept(server_socket, NULL, NULL);
    if (client_fd == -1) {
        fprintf(stderr, "[Server] Accept error: %s\n", strerror(errno));
        return ;
    }
    FD_SET(client_fd, all_sockets); // Ajoute la socket client à l'ensemble
    if (client_fd > *fd_max) {
        *fd_max = client_fd; // Met à jour la plus grande socket
    }
    printf("[Server] Accepted new connection on client socket %d.\n", client_fd);
    memset(&msg_to_send, '\0', sizeof msg_to_send);
    sprintf(msg_to_send, "Welcome. You are client fd [%d]\n", client_fd);
    status = send(client_fd, msg_to_send, strlen(msg_to_send), 0);
    if (status == -1) {
        fprintf(stderr, "[Server] Send error to client %d: %s\n", client_fd, strerror(errno));
    }
}

// Lit le message d'une socket et relaie le message à toutes les autres
void read_data_from_socket(int socket, fd_set *all_sockets, int fd_max, int server_socket)
{
    char buffer[BUFSIZ];
    char msg_to_send[BUFSIZ];
    int bytes_read;
    int status;

    memset(&buffer, '\0', sizeof buffer);
    bytes_read = recv(socket, buffer, BUFSIZ, 0);
    if (bytes_read <= 0) {
        if (bytes_read == 0) {
            printf("[%d] Client socket closed connection.\n", socket);
        }
        else {
            fprintf(stderr, "[Server] Recv error: %s\n", strerror(errno));
        }
        close(socket); // Ferme la socket
        FD_CLR(socket, all_sockets); // Enlève la socket de l'ensemble
    }
    else {
        // Renvoie le message reçu à toutes les sockets connectées
        // à part celle du serveur et celle qui l'a envoyée
        printf("[%d] Got message: %s", socket, buffer);
        memset(&msg_to_send, '\0', sizeof msg_to_send);
        sprintf(msg_to_send, "[%d] says: %s", socket, buffer);
        for (int j = 0; j <= fd_max; j++) {
            if (FD_ISSET(j, all_sockets) && j != server_socket && j != socket) {
                status = send(j, msg_to_send, strlen(msg_to_send), 0);
                if (status == -1) {
                    fprintf(stderr, "[Server] Send error to client fd %d: %s\n", j, strerror(errno));
                }
            }
        }
    }
}

On remarquera ici que nous avons deux ensembles de descripteurs de fichiers : all_sockets, qui contient tous les descripteurs de fichiers de toutes les sockets connectées, et read_fds, l’ensemble qui sera surveillé pour la lecture. L’ensemble all_sockets est vital pour ne pas perdre de descripteurs en cours de route, vu que select() modifie l’ensemble read_fds pour indiquer les descripteurs qui sont prêts. C’est donc dans all_sockets qu’on voudra ajouter les descripteurs de fichier de nos nouvelles connexions, et enlever les sockets qui ont été fermées. Au début de notre boucle principale, on peut alors copier le contenu de all_sockets dans read_fds pour s’assurer que select() surveille bien toutes nos sockets.

Tester la surveillance des sockets avec select()

Testons ce code en lançant notre serveur. Dans d’autres terminaux, nous pouvons utiliser nc comme client pour nous connecter à l’adresse et au port du serveur (ici, localhost port 4242) et y envoyer des messages qui devront apparaître chez les autres clients connectés :

Résultat d’un programme de test pour créer un serveur qui utilise select() pour surveiller les sockets qui sont prêtes à être lues avant de relayer le message reçu à tous ses clients connectés.

Comme on peut le voir, nos messages sont correctement relayés aux autres clients connectés, le tout sans jamais bloquer. Parfois, quand select() ne trouve aucun descripteur de fichier à lire, le serveur imprime "Waiting...", ce qui nous indique que select() marche correctement.

Nous avons donc ici l’ébauche d’un serveur de chat ! Idéalement, on devrait aussi surveiller nos sockets pour l’écriture avant de leur envoyer des données, mais maintenant qu’on comprend le fonctionnement de select(), cela ne devrait pas être trop compliqué à implémenter. Il nous faudrait pour cela ajouter une deuxième boucle après notre appel à select() pour consulter cette fois l’ensemble des descripteurs de fichiers surveillés pour la lecture. Cependant, cette séparation entre les différents événements que l’on souhaite surveiller chez nos sockets n’est pas forcément idéale…

Sonder les sockets avec poll()

Le système de surveillance qu’on peut mettre en place avec select() peut cependant être assez inefficace. En effet, devoir boucler sur trois ensembles de descripteurs de fichier pour savoir lesquels sont prêts pour la lecture, ou l’écriture, ou ceux qui on rencontré une erreur n’est pas terriblement pratique. C’est pourquoi une alternative à select(), qui n’utilise qu’une seule structure pour surveiller les changements de ses descripteurs de fichier, a été développée : poll().

Voici à quoi ressemble le prototype de poll(), qui se trouve dans <poll.h> :

int poll(struct pollfd fds[], nfds_t nfds, int timeout);

Ses paramètres sont les suivants :

  • fds : un tableau de structures de type pollfd qui contiennent les descripteurs de fichiers à sonder et les événements pour lesquels on souhaite être avertis. Nous regarderons cette structure de plus près ci-dessous.
  • nfds : un entier qui représente le nombre d’éléments dans le tableau précédent.
  • timeout : un entier qui représente une valeur temporelle en millisecondes durant laquelle poll() pourra bloquer l’exécution de notre programme pour sonder les sockets. Passé ce délai, poll() terminera son exécution. Renseigner -1 ici permet à poll() de bloquer indéfiniment tant qu’aucun descripteur de fichier n’a changé d’état. Par contre, si l’on indique 0, poll() finira son exécution immédiatement, même si aucun des descripteurs de fichiers qu’elle sonde ne sont prêts.

La fonction poll() renvoie le nombre de descripteurs de fichiers pour lequel elle a détecté un événement. Si poll() renvoie 0, cela veut dire que notre timeout a expiré sans qu’aucun des descripteurs de fichier soient prêts. Bien entendu, en cas d’erreur, poll() renvoie -1 et indique l’erreur rencontrée dans errno.

La structure pollfd

Regardons de plus près la structure pollfd :

struct pollfd {
    int   fd;         // Descripteur de fichier
    short events;     // Événements attendus
    short revents;    // Événements détectés
};

Nous aurons donc un tableau composé de plusieurs structures de ce type, où l’on renseignera le descripteur de fichier de chacune de nos sockets, ainsi que les événements que l’on attend.

Il y a plusieurs événements que l’on peut indiquer dans events. Les deux plus utiles sont les suivantes, mais d’autres sont listées sur la page manuel de poll() :

  • POLLIN pour être averti lorsqu’un descripteur est prêt à être lu avec read() ou recv().
  • POLLOUT pour être averti lorsqu’un descripteur est prêt pour l’écriture avec write() ou send().

On peut bien sûr cumuler les événements attendus avec un OU bitwise.

Une fois que poll() termine son exécution, on pourra regarder le champ revents de chaque élément pour vérifier si POLLIN ou POLLOUT sont indiqués.

Exemple de sondage de sockets avec poll()

Tentons de répliquer notre exemple de serveur précédent en utilisant poll() au lieu de select(). Le serveur fonctionnera de la même manière : on ajoutera nos sockets au tableau de poll() pour détecter quand l’une d’entre elles est disponible pour la lecture. Si c’est la socket principale, on acceptera une nouvelle connexion. Si c’est une socket client, on y lira son message qu’on relaiera à toutes nos autres sockets connectées.

// server-poll.c - un petit serveur qui surveille ses sockets avec poll() pour accepter des demandes de connexion et relaie les messages de ses clients
#include <errno.h>
#include <netdb.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/poll.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>

#define PORT 4242  // le port de notre serveur

int create_server_socket(void);
void accept_new_connection(int server_socket, struct pollfd **poll_fds, int *poll_count, int *poll_size);
void read_data_from_socket(int i, struct pollfd **poll_fds, int *poll_count, int server_socket);
void add_to_poll_fds(struct pollfd *poll_fds[], int new_fd, int *poll_count, int *poll_size);
void del_from_poll_fds(struct pollfd **poll_fds, int i, int *poll_count);

int main(void)
{
    printf("---- SERVER ----\n\n");

    int server_socket;
    int status;

    // Pour surveiller les sockets clients :
    struct pollfd *poll_fds; // Tableau de descripteurs
    int poll_size; // Taille du tableau de descipteurs
    int poll_count; // Nombre actuel de descripteurs dans le tableau

    // Création de la socket du serveur
    server_socket = create_server_socket();
    if (server_socket == -1) {
        return (1);
    }

    // Écoute du port via la socket
    printf("[Server] Listening on port %d\n", PORT);
    status = listen(server_socket, 10);
    if (status != 0) {
        fprintf(stderr, "[Server] Listen error: %s\n", strerror(errno));
        return (3);
    }

    // Préparation du tableau des descripteurs de fichier pour poll()
    // On va commencer avec assez de place pour 5 fds dans le tableau,
    // on réallouera si nécessaire
    poll_size = 5;
    poll_fds = calloc(poll_size + 1, sizeof *poll_fds);
    if (!poll_fds) {
        return (4);
    }
    // Ajoute la socket du serveur au tableau
    // avec alerte si la socket peut être lue
    poll_fds[0].fd = server_socket;
    poll_fds[0].events = POLLIN;
    poll_count = 1;

    printf("[Server] Set up poll fd array\n");

    while (1) { // Boucle principale
        // Sonde les sockets prêtes (avec timeout de 2 secondes)
        status = poll(poll_fds, poll_count, 2000);
        if (status == -1) {
            fprintf(stderr, "[Server] Poll error: %s\n", strerror(errno));
            exit(1);
        }
        else if (status == 0) {
            // Aucun descipteur de fichier de socket n'est prêt
            printf("[Server] Waiting...\n");
            continue;
        }

        // Boucle sur notre tableau de sockets
        for (int i = 0; i < poll_count; i++) {
            if ((poll_fds[i].revents & POLLIN) != 1) {
                // La socket n'est pas prête à être lue
                // on s'arrête là et on continue la boucle
                continue ;
            }
            printf("[%d] Ready for I/O operation\n", poll_fds[i].fd);
            // La socket est prête à être lue !
            if (poll_fds[i].fd == server_socket) {
                // La socket est notre socket serveur qui écoute le port
                accept_new_connection(server_socket, &poll_fds, &poll_count, &poll_size);
            }
            else {
                // La socket est une socket client, on va la lire
                read_data_from_socket(i, &poll_fds, &poll_count, server_socket);
            }
        }
    }
    return (0);
}

int create_server_socket(void) {
    struct sockaddr_in sa;
    int socket_fd;
    int status;

    // Préparaton de l'adresse et du port pour la socket de notre serveur
    memset(&sa, 0, sizeof sa);
    sa.sin_family = AF_INET; // IPv4
    sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // 127.0.0.1, localhost
    sa.sin_port = htons(PORT);

    // Création de la socket
    socket_fd = socket(sa.sin_family, SOCK_STREAM, 0);
    if (socket_fd == -1) {
        fprintf(stderr, "[Server] Socket error: %s\n", strerror(errno));
        return (-1);
    }
    printf("[Server] Created server socket fd: %d\n", socket_fd);

    // Liaison de la socket à l'adresse et au port
    status = bind(socket_fd, (struct sockaddr *)&sa, sizeof sa);
    if (status != 0) {
        fprintf(stderr, "[Server] Bind error: %s\n", strerror(errno));
        return (-1);
    }
    printf("[Server] Bound socket to localhost port %d\n", PORT);

    return (socket_fd);
}

void accept_new_connection(int server_socket, struct pollfd **poll_fds, int *poll_count, int *poll_size)
{
    int client_fd;
    char msg_to_send[BUFSIZ];
    int status;

    client_fd = accept(server_socket, NULL, NULL);
    if (client_fd == -1) {
        fprintf(stderr, "[Server] Accept error: %s\n", strerror(errno));
        return ;
    }
    add_to_poll_fds(poll_fds, client_fd, poll_count, poll_size);

    printf("[Server] Accepted new connection on client socket %d.\n", client_fd);

    memset(&msg_to_send, '\0', sizeof msg_to_send);
    sprintf(msg_to_send, "Welcome. You are client fd [%d]\n", client_fd);
    status = send(client_fd, msg_to_send, strlen(msg_to_send), 0);
    if (status == -1) {
        fprintf(stderr, "[Server] Send error to client %d: %s\n", client_fd, strerror(errno));
    }
}

void read_data_from_socket(int i, struct pollfd **poll_fds, int *poll_count, int server_socket)
{
    char buffer[BUFSIZ];
    char msg_to_send[BUFSIZ];
    int bytes_read;
    int status;
    int dest_fd;
    int sender_fd;

    sender_fd = (*poll_fds)[i].fd;
    memset(&buffer, '\0', sizeof buffer);
    bytes_read = recv(sender_fd, buffer, BUFSIZ, 0);
    if (bytes_read <= 0) {
        if (bytes_read == 0) {
            printf("[%d] Client socket closed connection.\n", sender_fd);
        }
        else {
            fprintf(stderr, "[Server] Recv error: %s\n", strerror(errno));
        }
        close(sender_fd); // Ferme la socket
        del_from_poll_fds(poll_fds, i, poll_count);
    }
    else {
        // Renvoie le message reçu à toutes les sockets connectées
        // à part celle du serveur et celle qui l'a envoyée
        printf("[%d] Got message: %s", sender_fd, buffer);

        memset(&msg_to_send, '\0', sizeof msg_to_send);
        sprintf(msg_to_send, "[%d] says: %s", sender_fd, buffer);
        for (int j = 0; j < *poll_count; j++) {
            dest_fd = (*poll_fds)[j].fd;
            if (dest_fd != server_socket && dest_fd != sender_fd) {
                status = send(dest_fd, msg_to_send, strlen(msg_to_send), 0);
                if (status == -1) {
                    fprintf(stderr, "[Server] Send error to client fd %d: %s\n", dest_fd, strerror(errno));
                }
            }
        }
    }
}

// Ajouter un nouveau descriptor de fichier au tableau de pollfd
void add_to_poll_fds(struct pollfd *poll_fds[], int new_fd, int *poll_count, int *poll_size) {
    // S'il n'y a pas assez de place, il faut réallouer le tableau de poll_fds
    if (*poll_count == *poll_size) {
        *poll_size *= 2; // Double la taille
        *poll_fds = realloc(*poll_fds, sizeof(**poll_fds) * (*poll_size));
    }
    (*poll_fds)[*poll_count].fd = new_fd;
    (*poll_fds)[*poll_count].events = POLLIN;
    (*poll_count)++;
}

// Supprimer un fd du tableau poll_fds
void del_from_poll_fds(struct pollfd **poll_fds, int i, int *poll_count) {
    // Copie le fd de la fin du tableau à cet index
    (*poll_fds)[i] = (*poll_fds)[*poll_count - 1];
    (*poll_count)--;
}

On a ajouté quelques petites fonctions utilitaires pour gérer l’ajout et la suppression de descripteurs de fichier du tableau de poll(), mais à part cela, pas grand chose n’à dû changer par rapport à select(). De plus, le résultat est tout à fait identique à notre exemple avec select(), mais l’exécution de notre programme est maintenant plus efficace comme nous n’avons qu’une seule structure à consulter pour savoir si une socket est prête à lire ou à écrire.

Résultat d’un programme de test pour créer un serveur qui utilise poll() pour surveiller les sockets qui sont prêtes à être lues avant de relayer le message reçu à tous ses clients connectés.

Toutefois, n’oublions pas que pour un vrai serveur, il nous faudra tout de même sonder les sockets avant de leur envoyer des données aussi ! Pour cela, il suffira d’ajouter une condition à l’intérieur de la boucle sur le tableau poll_fds qu’on a déjà, ce qui est bien plus pratique que les multiples boucles que nous impose select().

Autres méthodes

Les systèmes de sondage de sockets select() et poll() peuvent cependant tous deux s’avérer très lents plus il y a de connexions. Pour gérer un très grand volume de connexions, il est souvent préférable de faire appel à une bibliothèque de gestion d’événements comme libevent.


Une autre astuce à partager, une petite question à poser, ou une découverte intéressante à propos des sockets, du sondage de descripteurs de fichier ou de la programmation réseau en général ? Je serai ravie de lire et de répondre à tout ça dans les commentaires. Bon code !

Sources et lectures supplémentaires

  • Manuel du programmeur Linux :
  • Brian “Beej Jorgensen” Hall, 2023, Beej’s Guide to Network Programming Using Internet Sockets [beej.us]
  • Kurose, J. F., Ross, K. W., 2013, Computer Networking: A Top Down Approach, Sixth Edition, Chapter 1: Computer Networks and the Internet, pp. 1-82.
  • Wikipédia, Boutisme [wikipedia.org]
  • StackOverflow, Why do we cast sockaddr_in to sockaddr when calling bind()? [stackoverflow.com]
  • StackOverflow, What are the differences between poll and select? [stackoverflow.com]

Commentaires

Articles connexes

Pipe : une méthode de communication inter-processus

Par défaut, il est difficile de faire communiquer deux processus entre eux.

Lire la suite

Envoyer et intercepter un signal en C

À force d’être confrontés à des segfaults ou a des erreurs de bus, on se sera déjà familiarisé avec l’idée d’un signal informatique.

Lire la suite

Pourquoi un blog est un outil indispensable du développeur

Dans la boite à outils virtuelle de tout développeur, il devrait y avoir au moins un éditeur de texte, un compte GitHub, et un blog.

Lire la suite