applications réseau sous Linux - 3e partie - Linux Magazine No 10 - octobre 99

Ecrire des applications réseau sous Linux - 3e partie

Nous allons ce mois-ci aborder un algorithme serveur très utilisé et permettant de gérer des connexions simultanées. Cet algorithme a été fréquemment utilisé dans le monde unix pour programmer des services TCP/IP classiques comme ftp, http, telnet. Il met en oeuvre une fonctionnalité de base de tout système unix : la création de processus.

Cet article présentera tout d'abord l'algorithme serveur et les techniques liées à la gestion des processus sous Linux, puis commentera un exemple en C d'un tel serveur.

Un peu de théorie

L'algorithme parallèle à accès concurrentiel

Cet algorithme met à profit le multitâche du système d'exploitation afin de répondre à plusieurs clients de façon concurrentielle. un seul processus est chargé d'accepter les connexions des clients. Par contre, dès la connexion établie, un processus de traitement est créé pour chaque client. En clair, une secrétaire est chargée de répondre au téléphone. Dès que le téléphone sonne, elle met en contact l'appelant avec un interlocuteur et se remet immédiatement à l'écoute du téléphone. C'est cet interlocuteur qui va traiter la demande de l'appelant.

Créer une socket (socket)

Associer la socket à un numéro de port (bind)

Mettre la socket en mode passif (listen)

Faire à tout jamais :

Accepter les connexions clientes (accept)

Créer un nouveau processus exécutant TraiteClient en parallèle

Fin Faire

TraiteClient :

Tantque le client est connecté faire :

Lire une requête sur la socket active (read)

Traiter la requête

Envoyer une réponse sur la socket active (write)

Fin Tantque

Fermer la socket active (close)

Terminer ce processus

Qu'est-ce qu'un processus ?

Lorsque l'on a l'habitude d'utiliser des systèmes d'exploitation aux fonctionnalités multitâche pauvres, il n'est pas rare de confondre programme et processus. Dès que l'on utilise un système d'exploitation digne de ce nom, la confusion ne doit plus se faire.

Un programme n'est rien d'autre qu'une suite de codes machine compréhensible par le microprocesseur. C'est un objet inerte et on le stocke sur disque dans un vulgaire fichier régulier. L'attribut exécutable est là pour rappeler aux utilisateurs, et surtout au système, que ce fichier contient du code machine.

Lorsque l'on demande l'exécution d'un tel fichier, par exemple en tapant le nom d'une commande sur une console, le système crée un processus. Ce processus est composé d'un pointeur sur le code (le programme), mais aussi d'autres informations : les valeurs des variables, la pile d'exécution, les fichiers ouverts, les valeurs des registres du microprocesseur, etc. Ces informations sont toutes des données qui évoluent au gré de l'exécution du programme et c'est pourquoi un processus est un objet dynamique rendant compte de l'état d'exécution d'un programme. Au passage, signalons que le système est capable de sauvegarder un processus dans un fichier : c'est le fameux fichier core. Ce fichier, souvent volumineux, stocke toutes les informations à un instant précis de l'exécution d'un processus, généralement lorsque survient une erreur fatale. Il est alors possible à un programmeur muni d'un debugger, de visiter un processus après plantage et de vérifier les valeurs des variables, des registres, lors de ce malheureux incident.

Le système possède en mémoire une table de processus qui renseigne sur l'état des processus, les zones mémoire utilisées, les numéros qui leur sont alloués (le PID), etc. La commande ps permet, en quelque sorte, d'afficher les informations de cette table qui sont pertinentes pour un utilisateur.

L'appel système fork

Il existe plusieurs façons de créer un processus par programmation. Le moyen le plus simple consiste sans doute à utiliser la fonction system. Elle permet à la fois de créer un processus, d'exécuter un autre programme et d'attendre sa terminaison. Il n'y a donc pas de concurrence possible entre le processus appelant et le processus créé.

L'appel qui nous intéresse plus particulièrement est le fameux fork. C'est une brique de base de tout système unix. Sa sémantique est simple, mais pourtant je me souviens encore de l'avoir trouvée déconcertante lorsque j'étais étudiant, jeune et insouciant. fork duplique le processus qui l'a invoqué : il recopie dans une nouvelle zone mémoire les variables, la pile d'exécution, les valeurs des registres, etc. Il ne recopie pas le programme, puisque c'est un objet inerte, et il ne fera que le partager entre les deux processus. fork crée aussi une entrée dans la table des processus et associe un numéro au nouveau processus. A cet instant, les deux processus sont identiques : ils exécutent le même programme. Ils possèdent bien chacun leurs propres variables et leur propre pile mais, du fait de la recopie, ces zones contiennent les mêmes valeurs. Cela n'aurait donc aucun intérêt si fork terminait là son travail. Mais une petite chose les différencie : leur entrée dans la table des processus n'est pas la même... Et, notamment, leur PID est diffèrent.

Dans le processus nouvellement créé, que l'on appellera le fils, la fonction fork renvoie la valeur 0. Dans le processus responsable du fork, que l'on nomme le père, la valeur retournée sera celle du PID du fils. Ce PID est, par définition, forcément différent de 0.

Ce qu'il faut bien comprendre, c'est qu'un seul processus - le père - a initié la création de processus mais, au sortir du fork, le fils - qui s'est vu créé par recopie - se retrouve exactement dans le même état que le père, comme s'il avait lui-même exécuté le fork. Le seul moyen qui va permettre au fils de savoir qu'il est le fils et pas le père, c'est de tester la valeur retournée par le fork.

Un simple if va donc permettre de donner aux deux processus des comportements différents et d'exploiter toute la puissance d'une exécution concurrente.

Pour ceux que cela intéresse, il existe six fonctions aux fonctionnalités très proches qui permettent à un processus d'exécuter un autre programme et sur des variables «neuves». Le processus change alors complètement de direction. Ces fonctions sont dites de recouvrement, car il s'agit d'une véritable réinitialisation des données du processus. Elles composent le groupe des fonctions exec (man execp, execlp, etc). Pour exemple, lorsqu'on lance une commande dans un shell, le processus shell exécute un fork, puis le fils créé exécute un exec avec le nom de la commande en paramètre. On pourrait croire que, dans le cas d'un fork suivi d'un exec, le système a réalisé une copie pour rien des variables du père dans le fils. C'est ne pas faire confiance à son cher Linux adoré. En effet, la copie n'est pas réellement faite au moment du fork mais est plutôt demandée au système. Elle n'aura lieu que si le fils tente de modifier les valeurs des variables. Cette technique porte un nom : Copy On Write.

Enfin, il existe une autre possibilité pour gérer la concurrence : l'utilisation des threads. Ils ont été très bien décrits dans un magazine précédent. La programmation est un peu plus délicate mais les programmes sont beaucoup plus légers à gérer pour le système car il n'y a pas de création d'entrée dans la table des processus et selon leur type, il n'y a pas de recopie de zone mémoire.

La mort des processus

Les plus belles choses ont une fin. Même les processus si difficilement créés doivent mourir... Heureusement pour nous, ils meurent plus souvent en phase de développement qu'en phase d'exploitation (bien que..., enfin, c'est vrai sous Linux...).

Quand un processus se termine normalement, sur une erreur ou à la demande d'un utilisateur, on dit qu'il meurt. Ses zones mémoires sont libérées et il n'aura plus de temps machine. Pourtant, il laisse une petite trace de son exécution : son code de retour. Cette valeur est un nombre entier qui rend normalement compte de la bonne terminaison d'un processus ou, au contraire, d'une erreur. L'usage veut que la valeur 0 signale un comportement normal et que toute autre valeur soit un code d'erreur.

Ce code de retour est stocké par le système dans la table des processus. Il en résulte que l'entrée occupée par le processus dans cette table n'est pas immédiatement supprimée. Cette entrée reste valide jusqu'à ce qu'un processus vienne la lire. Durant ce laps de temps, on dit que le processus est dans l'état Zombie. Il ne prend plus de ressources système, mais il est encore présent dans la table. Les processus zombies sont indiqués par un Z dans la colonne STATUS d'un ps.

C'est normalement le rôle du père que d'aller lire le code de retour de ses fils. Si le fils perd son père, il devient automatiquement le fils adoptif du processus de PID 1. Ce processus est un processus système et il se chargera d'aller lire le code de retour. Dans l'exemple d'une commande lancée depuis un shell, c'est le processus shell qui se charge de ce travail.

Quand on écrit des programmes serveur, qui sont faits pour tourner en permanence sur une machine, cet aspect des choses devient primordial. Imaginez un instant que vous développiez un serveur http et qu'à chaque connexion, un processus est créé. Lorsqu'il meurt, il reste dans l'état Zombie. Au bout d'un certain nombre de connexions (donc d'un certain temps), les Zombies auront rempli la table des processus et aucune création de processus ne sera plus possible.

Le monde des processus est ainsi fait : les fils meurent généralement avant leur père...

L'appel système wait

Une fonction, ou plutôt un groupe de fonctions, permet à un processus d'aller lire le code retour d'un processus fils.

La première fonction est wait. Elle attend qu'un processus fils se termine, puis elle va lire son code de retour, qu'elle renvoie en paramètre au père. Il existe aussi waitpid, qui permet d'attendre un fils en particulier grâce à son PID.

Enfin, wait3 et wait4 ont plus d'options et modifient le comportement du wait. Il est par exemple possible de ne pas se bloquer sur la terminaison d'un fils, ainsi le processus père peut continuer à travailler pendant l'exécution du fils.

Si le wait n'est pas bloquant, il se pose un autre problème : quand réaliser le wait ?

Les signaux

Il existe sous Linux un principe de communication basique entre le système - ou éventuellement les utilisateurs - et les processus. Ce mécanisme est celui des signaux. Ce sont des sortes d'interruptions que l'on adresse à un processus. Lorsqu'un processus reçoit un signal, il remet à plus tard ce qu'il est en train de faire, exécute une routine permettant de répondre au signal, puis reprend son travail où il en était (ou presque, ce n'est pas si simple).

Il existe plusieurs signaux différents qui ont des interprétations différentes. Le plus célèbre est le terrible signal numéro 9, ou SIGKILL, qui permet de tuer sauvagement un processus. La commande kill -l vous renseignera sur les noms et numéros des signaux.

Un de ces signaux porte le doux nom de SIGCHLD. Cela signifie signal Child et il est envoyé par le système à un processus dès qu'un de ses fils se termine. Ils ont vraiment pensé à tout...

Donc, en résumé, il suffit de faire un wait lorsqu'on reçoit un SIGCHLD pour supprimer automatiquement le fils qui vient de se terminer. Pour cela, on utilisera la fonction signal qui permet de définir la routine devant s'exécuter lors de la réception d'un signal.

Vous en voulez encore ? Et bien voilà : lorsqu'un processus est bloqué sur une fonction (par exemple, un accept ou un read) et qu'un signal arrive, alors, après exécution de la routine de gestion du signal, il sort de la fonction bloquante. Pour le programmeur, cela signifie qu'il devra, derrière chaque fonction susceptible d'être interrompue, placer un petit test qui le renseignera si la fonction s'est passée correctement ou si elle a été interrompue. La fonction interrompue renvoie normalement le code d'erreur EINTR.

Un petit peu plus ? Dans le cas où plus de deux processus fils se terminent dans un intervalle de temps très court, il est possible qu'un signal SIGCHLD arrive alors que l'on est en train d'exécuter la routine de gestion de SIGCHLD. Pour éviter, dans ce cas-là, de laisser de zombies, il est nécessaire, dans la routine de gestion, de réaliser une boucle qui efface tous les processus fils zombies (et pas un seul).

Une goutte pour la route : l'appel signal est très simple à comprendre, très facile à mettre en oeuvre et disponible sur tous les Unixes. Cependant, ce n'est pas un appel POSIX. Dans le cadre d'une application devant être portée sur plusieurs systèmes d'exploitation, on préférera l'emploi de sigaction, un peu plus lourd à mettre en oeuvre, mais POSIX...

Exemple pratique

Présentation

L'exemple de ce mois est un serveur de pages d'informations. Un peu à la manière d'un service Minitel, il envoie des pages de textes sur les clients qui en font la demande. Les clients disposent de mots clés qui permettent de se déplacer dans l'arborescence des pages.

Pour tester ce service du coté client, on utilisera la commande telnet suivi d'un nom de machine et du port associé au service (6767 par défaut). On peut tester la faculté du serveur à gérer plusieurs clients simultanément en ouvrant plusieurs xterm et en exécutant un telnet dans chaque fenêtre.

Exemple d'utilisation :

serveur & (Lancement du serveur sur le port 6767)

xterm -e telnet localhost 6767 & (Lancement client 1)

xterm -e telnet localhost 6767 & (Lancement client 2)

xterm -e telnet localhost 6767 & (Lancement client 3)

Le mot clé «bye» permet de sortir d'un client.

Configuration

Le programme serveur doit d'abord être configuré au moyen de quelques fichiers textes. Le fichier central doit s'appeler arbo.txt et doit se trouver dans le répertoire courant. Il décrit l'arborescence du serveur, c'est-à-dire les pages d'informations à envoyer aux clients ainsi que les raccourcis de navigation entre ces pages.

Exemple :

p:000:accueil.txt

s:001:c

s:002:i

p:001:cava.txt

s:000:a

p:002:info.txt

s:000:a

Les lignes de la forme p:nnn:xxxxxx décrivent des pages. nnn représente un numéro de page unique. xxxxxx est le nom du fichier contenant la page. Les lignes de la forme s:nnn:xxxxxx décrivent des raccourcis clavier de navigation. nnn est le numéro de la page à laquelle le raccourci mène, xxxxxx est le texte que doit entrer le client pour activer le raccourci. Chaque page possède ses propres raccourcis clavier : les lignes s qui suivent directement une ligne p.

Dans cet exemple, lorsqu'un client se connecte, le texte du fichier accueil.txt lui est envoyé. S'il entre c, il ira sur la page 001, c'est-à-dire qu'il recevra le texte de cava.txt. S'il entre i, toujours sur la page d'accueil, il recevra le texte de info.txt.

Les fichiers accueil.txt, cava.txt et info.txt sont de vulgaires fichiers textes et leur contenu importe peu.

Le code

#include "../libcsock/csock.h"

#include <sys/signal.h>

#include <sys/wait.h>

#include <fcntl.h>

#include <stdio.h>

#include <string.h>

#include <unistd.h>

/* Structures et variables de gestion du service (arborescence) */

typedef struct sAction *pAction;

typedef struct sAction {

char *commande;

int page;

pAction suivant;

} tAction;

typedef struct {

char *fichier;

pAction listecom;

} tPage;

tPage ARBO[100];

/* Chargement de l'arborescence du service */

int ChargeArbo(char *fich) {

FILE *farbo;

char line[256];

int page;

int np;

char *ch = line + 6;

pAction a;

memset(ARBO, 0, sizeof(ARBO));

farbo = fopen(fich, "r");

while (fgets(line, 256, farbo)) {

np = atoi(line + 2);

if (*line) // Enleve le dernier caractere s'il existe

line[strlen(line) - 1] = 0;

switch (line[0]) {

case 'p': // Une ligne decrivant une page

page = np;

ARBO[page].fichier = strdup(ch);

break;

case 's': // Une ligne decrivant une commande

a = (pAction) malloc(sizeof(tAction));

a->page = np;

a->commande = strdup(ch);

a->suivant = ARBO[page].listecom;

ARBO[page].listecom = a;

break;

default:

fclose(farbo);

return 0;

}

}

fclose(farbo);

return 1;

}

/* Envoi d'une page (fichier) sur une socket */

void envoi_page(int s, int p) {

int f;

char buf[128];

int n;

f = open(ARBO[p].fichier, O_RDONLY);

if (f != -1) {

while ((n = read(f, buf, 128)) > 0)

write(s, buf, n);

close(f);

}

}

/* Lecture de caracteres sur une socket (sorte de fgets) */

int get_requete(int sock, char *req, int reqlen) {

int n, nmax;

char *pbuf;

do {

nmax = reqlen - 1;

pbuf = req;

// realise un fgets

while (nmax && (n = read(sock, pbuf, 1)) > 0 &&

*pbuf != '\n') {

pbuf++;

nmax--;

}

if (n <= 0)

/* la lecture n'a pas eu lieu correctement */

return 0;

else if (!nmax)

/* Le nombre de caracteres maximum a ete

atteint : on les ignore et on revient en

ecoute de caracteres */

continue;

else {

/* On a recu une requete : on enleve les

caracteres de fin de ligne, on ajoute un

zero terminal */

while (pbuf > req && (*pbuf == '\n' || *pbuf == '\r'))

pbuf--;

*++pbuf = 0;

return 1;

}

} while (1);

}

/* Gere le protocole : en fonction de la page active et de la commande, renvoie un numero de page */

int decode_requete(int p, char *com) {

pAction a;

if (!strcasecmp(com, "bye"))

return -1;

a = ARBO[p].listecom;

while (a && strcasecmp(a->commande, com))

a = a->suivant;

if (a)

return a->page;

else

return p;

}

/* Traite un client : envoie des pages au fur et

a mesure des commandes du client */

#define buflen 100

int TraiteClient(int sock) {

char buf[buflen];

int page = 0;

do {

envoi_page(sock, page);

if (get_requete(sock, buf, buflen))

page = decode_requete(page, buf);

else

page = -1;

} while (page >= 0);

close(sock);

exit(0);

}

/* Le serveur pere elimine ses fils devenus zombies */

void zombie(int sig){

signal(SIGCHLD, zombie);

while (wait3(NULL, WNOHANG, NULL) > 0);

}

/* Mise en place du service, attente des connexion

clientes,

creation de processus pour chaque connexion cliente */

int main(int argc, char *argv[]) {

char *service = "6767"; // n° de port par défaut int msock; // Socket passive serveur (master)

int ssock; // Socket de communication (slave)

struct sockaddr_in sin; // structure décrivant la socket active

int lsin = sizeof(sin);

signal(SIGCHLD, zombie); // On active la suppression des zombies

switch (argc) { // Recuperation des arguments

case 1:

break;

case 2:

service = argv[1];

break;

default:

fprintf(stderr, "usage: %s [port]\n", argv[0]);

exit(1);

}

if (!ChargeArbo("arbo.txt")) {

fprintf(stderr, "Fichier arborescence manquant ou

non correct.\n");

exit(1);

}

// Creation de la socket passive

msock = server_socket(service, "tcp", 5);

// Boucle infinie

while (1) {

ssock = accept(msock, (struct sockaddr *)&sin, &lsin);

if (ssock < 0) {

// Si un signal (SIGCHLD) est venu perturbe l'accept

// alors on revient sur l'accept.

if (errno == EINTR)

continue;

error_socket("accept: %s\n", strerror(errno));

}

printf("Connexion depuis: %s\n", inet_ntoa(sin.sin_addr));

// Creation d'un nouveau processus

if (!fork()) {

// fork == 0 => je suis dans le fils

close(msock);

TraiteClient(ssock);

}

else // fork != 0 => je suis dans le pere (ou erreur)

close(ssock);

}

}

Compilation

On doit disposer de la bibliothèque libcsock vue dans le numéro précèdent et faire un Makefile approprié :

serveur : serveur.c

gcc -o serveur serveur.c -L../libcsock -lcsock

L'option «-L» renseigne l'éditeur de lien sur le répertoire qui contient licsock.a. Ici, c'est un répertoire frère nommé libcsock.

Il est à noter que le programme présenté contient à la fois le code du père et le code du fils. Evidemment, le père - chargé de l'acceptation des connexions - n'exécute jamais le code du fils et le (ou les) fils s'enferme(nt) dans la fonction TraiteClient, qui se termine par un exit. Il ne serait pas plus efficace de créer deux programmes différents car, de toute façon, le code du père et celui du fils doivent se trouver simultanément en mémoire pour traiter les clients.

Ce programme n'inclut pas de tests d'erreurs, notamment en ce qui concerne les dépassements d'indices des tableaux : le fichier arbo.txt doit contenir au maximum 100 pages, les lignes doivent faire un maximum de 256 caractères, etc. Ceci a le mérite de rendre le code plus concis, au détriment de sa stabilité. Ce genre « d'oubli », plus ou moins conscient, est à prohiber dans un développement réel, car il est susceptible de conduire à des trous de sécurité importants.

Conclusion

Le squelette du programme présenté peut facilement être réutilisé tel quel pour le développement de serveurs multiclients. Cependant, il souffre de deux inconvénients majeurs :

4 Le temps de création des processus fils peut devenir une charge importante pour le système. Ce problème peut être contourné par une création anticipée des processus. Par exemple, le serveur Web Apache crée, à son lancement, un certain nombre de processus d'avance. La charge système de création des processus est donc concentrée au moment du lancement du serveur et non en phase d'exploitation. L'utilisation des threads est une autre technique permettant de réduire le coût de cette création.

4 Rien n'est prévu pour la communication entre clients. Les clients ne partagent aucune zone de données. Il faut donc mettre en place un mécanisme de communication entre processus fils, si l'on désire une telle fonctionnalité (par exemple, pour faire un chat). L'utilisation des IPC peut être une solution. Les threads viennent, là aussi, au secours du programmeur en permettant à tous les processus fils de partager le même segment de données.

Ces deux problèmes se voient donc résolus par l'emploi des threads et il est logique de penser que de plus en plus de serveurs utiliseront cette technique. Il existe néanmoins une autre solution, algorithmique cette fois : faire en sorte qu'un seul processus ait la charge de répondre aux appels et de traiter toutes les communications avec les clients.

Cet algorithme fera l'objet du prochain article.

Alain Basty

 


© Copyright 2000 Diamond Editions/Linux magazine France. - Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.1or any later version published by the Free Software Foundation; A copy of the license is included in the section entitled "GNU Free Documentation License".