Portail / Sujets autour de la programmation / Programmation C/C++ sous Linux / Réalisation d'une infrastructure de communication TCP/IP(Sommaire)

Communication point à point TCP/IP.

Développer du code source à destination des communications TCP/IP demande de disposer de connaissances suffisantes en matière de réseau IP. L'objectif de cet article et d'en vulgariser quelques unes afin de pouvoir réaliser ces développements.

Introduction.

La pile IP permet d'abstraire le ou les réseaux physiques sous-jacents qui transportent l'information. Né dans les années 70 sur plate-forme UNIX, il en conserve les limites et les inconvénients. La gestion des spécifictés du support physique est dévolue au pilote. Ce pilote et la pile IP qu'il adresse constituent une extension de l'espace d'adressage du processus. Ce dernier y accède via des sockets (que l'on peut traduire par connecteur). Les sockets sont des "objets kernel" auquel le système d'exploitation affecte un unique identifiant appelé "descripteur de socket" (un simple entier). Ils appartiennent au système d'exploitation et non au processus. Ainsi le crash d'une application peut laisser des sockets actifs et donc des canaux de communication ouverts.

La pile IP dispose de plusieurs protocoles dont ICMP (régulation), UDP (diffusion) et TCP (communication point à point). Nous allons ici nous attarder sur TCP. Dans un premier temps, nous allons nous intéresser à une liaison déjà établie en passant sosu silence la procédure de connexion. Une liaison point à point via le protocole TCP met en relation deux sockets de part et d'autre d'un canal de communication. Ce canal est bi-directionnel ce qui signifie que ce canal comporte deux voies : la voie d'émission de l'un est la voie de réception de l'autre et vice-versa. Il est tout à fait possible de fermer une voie tout en laissant l'autre active (fonction shutdown()).

Difficultés intrinsèques au protocole.

Dans la conception même du protocole, deux diffcultés sont à résoudre :

  1. savoir quand l'hôte distant émet ;
  2. connaitre la taille du flux.

Pour résoudre le premier point, nous avons plusieurs solutions : l'attente bloquante, l'attente bloquante temporisée, la lecture non bloquante, et la communication asynchrone sur la base de signaux. Nous ne nous intérresserons ici qu'au deux premières.

Paradoxalement, un flux sur socket est infini ou plus exactement il est constitué de l'ensemble des octets émis ou reçus tant que le canal de communication est établi. Notez que si le flux est infini (réseau sans mémoire d'état), la mémoire des machines ne l'est pas. Il est donc nécessaire de mettre en place des mécabnismes particuliesr pour faire face aux flux de données volumineuses. Rien dans le protocole standard ne permet de savoir quand une émission est achevée et quand une autre commence. TCP ne connaît pas la notion de "dialogue", c'est votre application qui devra l'implenter. Le distinguo entre deux émissions est fait soit en mesurant le temps entre deux réceptions, soit en transportant dans l'information des données qui précise quand elles s'achèvent. Jouer sur le temps est très risqué car il dépend de nombreux facteurs dont :

On a donc tout intérêt à émettre de courts messages ce qui bien entendu n'est pas toujours possible (émission de fichiers volumineux par exemple).

L'émission ne pose pas réellement de problème car celui qui émet sait à l'avnce ce qu'il envoie. Seule une émission par plusieurs threads vers le même socket peut poser des difficultés de sérialisation des envois. Nous excluons ce cas qui fait généralement appel à de la programmation parallèle.

Réception de données.

Etat des tampons.

Tout socket dispose d'un tampon pour la réception et d'un tampon pour l'émission. UNe façon de savoir si des données sont reçues ou si elles ont toutes été émises est de consulter ces tampons.

int value = 0;
/* 
  Si status est différent de -1, value au retour contient le nombre
  d'octets dans le tampon de réception du socket.
*/
int status = ioctl(socket, SIOCINQ, &value)
int value = 0;
/* 
  Si status est différent de -1, value au retour contient le nombre
  d'octets dans le tampon d'émission du socket.
*/
int status = ioctl(socket, SIOCOUTQ, &value);

L'appel dans une boucle de scrutation du tampon de réception du socket est un des moyens de savoir si des données ont été reçues sans nécessairement devoir passer par une attente bloquante.

Attente bloquante.

La fonction recv() est la fonction dédiée à la lecture du tampon de réception du socket. Par défaut, cette fonction est bloquante. S'il n'y a aucun octet dans le tampon de réception du socket, le thread qui exécute cette fonction est suspendu jusqu'à ce que des octets soient reçus.

Ce comportement permet de disposer d'un code simple et robuste. Malheureusement, le canal peut avoir été fermé sans que ce socket en ait été prévenu (crash du programme distant, panne d'un routeur, etc.). L'attente se transforme alors en attente infinie rendant caduque l'exécution du thread ce qui peut être préjudiciable notamment si ce thread maintient de nombreuses ressources système actives.

Attente bloquante temporisée.

Idéalement, il faudrait pouvoir invoquer la fonction recv() uniquement lorsque l'on est certain de la réception de données. Nous avons vu que la boucle de scrutation du tampon est un moyen d'y parvenir. C'est en général une mauvaise idée car la boucle induit une attente active qui consomme inutilement un important temps CPU. A la place, nous utilisons la fonction poll() qui attend l'arrivée des données pendant une période de temps donnée. Anciennement, on utilisait la méthode select() qui est toujours opérationnelle mais qui est moins discriminante que la fonction poll() ou epoll().

La fonction poll() prend en paramètres une liste de descripteurs de socket ainsi qu'une liste d'èvénments à observer. Cette fonction attend alors jusqu'à l'arrivée d'un des évènements précisé sur au moins l'un des descripteurs de socket ou pendant une durée déterminée si aucun évènement ne se produit. L'intérêt de la fonction poll() sur la fonction select()est évident : on peut gérer finement l'évènement attendu. Voici un exemple "théorique" de mise en oeuvre :

struct pollfd fds;
fds.fd = socket;
fds.events = POLLIN | POLLRDHUP;    // Evènements dont on souhaite être informé
bool end = false;
while(!end) {
    int status = ::poll(&fds, 1, timeout);
    switch(status) {
        case 0: {
                // Délai d'attente dépassé
                cout << "Délai d'attente de la réception dépassé" << endl;
                end = true;
                break;
            }
        case -1: {
                // Echec de la fonction poll
                int code = errno;
                string err = string(strerror(code));
                cout << err << endl;
                end = true;
                break;
            }
        default: {
            // Traitement des évènements dont POLLIN (réception des données)
            ...
        }
    }
}

Grâce à la fonction poll(), on appelle la fonction d'attente bloquante recv() uniquement sur réception de l'évènement POLLIN. On est alors certain du retour immédiat de la fonction recv().

Si aucun des évènements surveillés ne se produit, (cas du retour 0), on sort de la boucle et le thread peut poursuivre son exécution. Le code est un peu plus sophistiqué mais reste malgré tout simple et robuste.

Attente non bloquante.

Nous nous limiterons ici à indiquer comment on peut éviter l'attente bloquante de la fonction recv(). Pour cela, nous allons utiliser la fonction fcntl() qui permet d'indiquer que le socket est placé en mode de réception non bloquante :

int status = fcntl(socket, F_SETFL, fcntl(socket, F_GETFL, 0) | O_NONBLOCK);

Cette méthode récupère le masque des drapeaux associé au socket et lui ajoute le bit qui indique via ce masque que le socket est en mode non bloquant (O_NONBLOCK).

Ce mode peut répondre a des besoins particuliers comme la lecture de données OOB (Out Of Band).

Lecture asynchrone via gestion de signal.

Une autre façon simple de détecter l'arrivée de données sur le socket est de s'appuyer sur le mécanisme des signaux de Linux. On commence par placer le socket en mode asynchrone (mode différent de non bloquant) via la fonction fcntl :

int status = fcntl(socket, F_SETFL, fcntl(socket, F_GETFL, 0) | O_ASYNC);

A partir de cet instant, dès que des données arrivent sur le socket, Linux émet le signal SIGIO. Il faut donc installer un gestionnaire pour ce signal via la fonction sigaction.

Un autre signal très utile mais qui n'impose pas le mode asynchrone du socket est SIGPIPE. Ce signal est émis par Linux en cas de fermeture programmée ou accidentelle du canal (le tristement célèbre broken pipe).

Imaginons que vous disposiez d'un gestionnaire pour ce signal de prototype void sigPipeHandler(int). L'appel signal(SIGPIPE, sigPipeHandler) suffit pour installer ce gestionnaire chargé de gérer toute fermeture imprévue du canal.

Notez que l'emploi des signaux est spécifique à Linux et aux UNIX alors que les autres fonctions vues jusque là sont utilisables sur la plupart des systèmes y compris Windows (a quelques adaptations près bien entendu).

Procédure de connexion.

Procédure de cloture.

Séquence de cloture

Rédaction par Jean-Marie Piatte (1983-2021)