Dans ce chapitre nous abordons quelques grands principes de fonctionnement des logiciels serveurs. D'abord nous tentons de résumer leurs comportements selon une typologie en quatre modèles génériques, puis nous examinons quelques points techniques remarquables de leur architecture logicielle comme la gestion des tâches multiples, des descripteurs multiples, le fonctionnement en arrière plan (les fameux `` daemon ''), la gestion des logs...
Enfin nous concluons ce chapitre avec une présentation très synthétique du `` serveur de serveurs '' sous Unix, c'est à dire la commande inetd, suivie d'une lecture commentée d'un petit code en langage C qui s'inspire de son fonctionnement, pour mieux comprendre sa stratégie !
L'algorithme intuitif d'un serveur, déduit des schémas (revoir la
page
) d'utilisation
des sockets, pourrait être celui-ci :
Cette démarche, que nous pourrions qualifier de naïve, ne peut convenir qu'à des applications très simples. Considérons l'exemple d'un serveur de fichiers fonctionnant sur ce mode. Un client réseau qui s'y connecte et télécharge pour 10 Go de données accapare le serveur pendant un temps significativement long, même au regard des bandes passantes modernes. Un deuxième client réseau qui attendrait la disponibilité du même serveur pour transférer 1Ko aurait des raisons de s'impatienter !
1.1 Serveurs itératif et concourant
Un serveur itératif (`` iterative server '') désigne une implémentation qui traite une seule requête à la fois.
Un serveur concourant (`` concurrent server '') désigne une implémentation capable de gérer plusieurs tâches en apparence simultanées. Attention, cette fonctionnalité n'implique pas nécessairement que ces tâches concourantes doivent toutes s'exécuter en parallèle...
Dans cette première approche purement algorithmique nous n'abordons
pas la mise en
uvre technique, le
paragraphe 2 s'y consacrera !
D'un point de vue conceptuel, les serveurs itératifs sont plus faciles à concevoir et à programmer que les serveurs concourants, mais le résultat n'est pas toujours satisfaisant pour les clients. Au contraire, les serveurs concourants, s'ils sont d'une conception plus savante, sont d'un usage plus agréable pour les utilisateurs parceque naturellement plus disponibles.
La pile ARPA nous donne le choix entre TCP et UDP. L'alternative n'est pas triviale. Le protocole d'application peut être complètement bouleversé par le choix de l'un ou de l'autre. Avant toute chose il faut se souvenir des caractéristiques les plus marquantes de l'un et de l'autre.
Le mode connecté avec TCP est le plus facile à programmer, de plus il assure que les données sont transmises, sans perte.
Par contre, Il établit un circuit virtuel bi-directionnel dédié à chaque client ce qui monopolise une socket, donc un descripteur, et interdit par construction toute possibilité de `` broadcast ''.
L'établissement d'une connexion et sa terminaison entraîne l'échange de 7 paquets. S'il n'y a que quelques octets à échanger entre le client et le serveur, cet échange est un gaspillage des ressources du réseau.
Il y a plus préoccupant. Si la connexion est au repos , c'est à dire qu'il n'y a plus d'échange entre le client et le serveur, rien n'indique à celui-ci que le client est toujours là ! TCP est silencieux si les deux parties n'ont rien à s'échangerXV1.
Si l'application cliente a été interrompue accidentellementXV2, rien n'indique au serveur que cette connexion est terminée et il maintient la socket et les buffers associés. Que cette opération se répète un grand nombre de fois et le serveur ne répondra plus, faute de descripteur disponible, voire de mémoire libre au niveau de la couche de transport (allocation au niveau du noyau, en fonction de la mémoire totale et au démarrage de la machine) !
Le mode datagramme ou `` non connecté '' avec UDP hérite de tous les désagréments de IP, à savoir perte, duplication et désordre introduit dans l'ordre des datagrammes.
Pourtant malgré ces inconvénients UDP reste un protocole qui offre des avantages par rapport à TCP. Avec un seul descripteur de socket un serveur peut traiter un nombre quelconque de clients sans perte de ressources due à de mauvaises déconnexions. Le `` broadcast '' et le `` multicast '' sont possibles.
Par contre les problèmes de fiabilité du transport doivent être gérés au niveau de l'application. Généralement c'est la partie cliente qui est en charge de la réémission de la requête si aucune réponse du serveur ne lui parvient. La valeur du temps au delà duquel l'application considère qu'il doit y avoir réémission est évidement délicate à établir. Elle ne doit pas être figée aux caractéristiques d'un réseau local particulier et doit être capable de s'adapter aux conditions changeantes d'un internet.
1.3 Quatre modèles de serveurs
Deux comportements de serveurs et deux protocoles de transport combinés induisent quatre modèles de serveurs :
figure XV.01
La terminologie `` tâche esclave '' employée dans les algorithmes qui suivent se veut neutre quant au choix technologique retenu pour les implémenter. Ce qui importe c'est leur nature concourante avec la `` tâche maître '' qui les pilote.
Algorithme itératif - Mode data-gramme :
![\framebox[14cm][l]{
\parbox[l]{14cm}{
\par
\begin{enumerate}
\item Cr\'{e}er un...
...onform\'{e}ment au protocole d'application.
\end{itemize}\end{enumerate}\par
}}](img162.png)
Critique :
Cette forme de serveur est la plus simple, elle n'est pas pour autant inutile. Elle est adaptée quand il y a un tout petit volume d'information à échanger et en tout cas sans temps de calcul pour l'élaboration de la réponse. Le serveur de date `` daytime '' ou le serveur de temps `` time '' en sont d'excellents exemples.
Algorithme Itératif - Mode connecté :
![\framebox[14cm][l]{
\parbox[l]{14cm}{
\par
\begin{enumerate}
\item Cr\'{e}er un...
...ogue est termin\'{e}, fermer la connexion et aller en 3).
\end{enumerate}\par
}}](img163.png)
Critique : Ce type de serveur est peu utilisé. Son usage pourrait être dédié à des relations clients/serveurs mettant en jeu de petits volumes d'informations avec la nécessité d'en assurer à coup sûr le transport. Le temps d'élaboration de la réponse doit rester court.
Le temps d'établissement de la connexion n'est pas négligeable par rapport au temps de réponse du serveur, ce qui le rend peu attractif.
Algorithme concourant - Mode datagramme :
![\framebox[14cm][l]{
\parbox[l]{14cm}{
\hbox{\bf Ma\^{\i}tre~:}
\begin{enumerate...
...che esclave pour \'{e}laborer
la r\'{e}ponse.
\end{itemize} \end{enumerate}}}](img164.png)
![\framebox[14cm][l]{
\parbox[l]{14cm}{
\par
\hbox{\bf Esclave~:}
\begin{enumera...
...protocole\\
de l'application,
\item Terminer la t\^{a}che.
\end{enumerate}}}](img165.png)
Critique : Si le temps d'élaboration de la réponse est rendu indifférent pour cause de création de processus esclave, par contre le coût de création de ce processus fils est prohibitif par rapport à son usage : formuler une seule réponse et l'envoyer. Cet inconvénient l'emporte généralement sur l'avantage apporté par le `` parallélisme ''.
Néanmoins, dans le cas d'un temps d'élaboration de la réponse long par rapport au temps de création du processus esclave, cette solution se justifie.
mm
Algorithme concourant - Mode connecté :
![\framebox[14cm][l]{
\parbox[l]{14cm}{
\par
\hbox{\bf Ma\^{\i}tre~:\hfil}
\begin{...
...{a}che esclave pour traiter la r\'{e}ponse.
\end{itemize}\end{enumerate}\par
}}](img166.png)
![\framebox[14cm][l]{
\parbox[l]{14cm}{
\par
\hbox{\bf Esclave~:\hfil}
\begin{enum...
...pplication,
\item Terminer la connexion et la t\^{a}che.
\end{enumerate}\par
}}](img167.png)
Critique :
C'est le type le plus général de serveur parce-qu'il offre les meilleurs caractéristiques de transport et de souplesse d'utilisation pour le client. Il est sur-dimensionné pour les `` petits '' services et sa programmation soignée n'est pas toujours à la portée du programmeur débutant.
De la partie algorithmique découlent des questions techniques sur le
`` comment le faire ''. Ce paragraphe donne quelques grandes indications
très élémentaires que le lecteur soucieux d'acquérir une vraie
compétence devra compléter par les lectures indiquées au dernier
paragraphe ; la Bibliographie du chapitre (page
).
Notamment il est nécessaire de consulter les ouvrages de W. R. Stevens pour la partie système et David R. Butenhof pour
la programmation des threads.
La suite du texte va se consacrer à éclairer les points suivants :
2.1 Gestion des `` tâches esclaves ''
La gestion des `` tâches esclaves '' signalées dans le paragraphe 1 induit que le programme `` serveur '' est capable de gérer plusieurs actions concourantes, c'est à dire qui ont un comportement qui donne l'illusion à l'utilisateur que sa requête est traitée dans un délai raisonnable, sans devoir patienter jusqu'à l'achèvement de la requête précédente.
C'est typiquement le comportement d'un système d'exploitation qui ordonnance des processus entre-eux pour donner à chacun d'eux un peu de la puissance de calcul disponible (`` time-sharing '').
La démarche qui parait la plus naturelle pour implémenter ces `` tâches esclaves '' est donc de tirer partie des propriétés mêmes de la gestion des processus du système d'exploitation.
Sur un système Unix l'usage de processus est une bonne solution dans un premier choix car ce système dispose de primitives (APIs) bien rodées pour les gérer, en particulier fork(), vfork() et rfork().
Néanmoins, comme le paragraphe suivant le rappelle, l'usage de processus fils n'est pas la panacée car cette solution comporte des désagréments. Deux autres voies existent, non toujours valables partout et dans tous les cas de figure. La première passe par l'usage de processus légers ou `` threads '' (paragraphe 2.3), la deuxième par l'usage du signal SIGIO qui autorise ce que l'on nomme la programmation asynchrone (paragraphe 2.4).
Pour conclure il faut préciser que des tâches esclaves ou concourantes peuvent s'exécuter dans un ordre aléatoire mais pas nécessairement en même temps. Cette dernière caractéristique est celle des tâches parallèles. Autrement dit, les tâches parallèles sont toutes concourantes mais l'inverse n'est pas vrai. Concrètement il faut disposer d'une machine avec plusieurs processeurs pour avoir, par exemple, des processus (ou des `` threads kernel '', si elles sont supportées) qui s'exécutent vraiment de manière simultanée donc sur des processeurs différents. Sur une architecture mono-processeur, les tâches ne peuvent être que concourantes !
Il ne s'agit pas ici de faire un rappel sur la primitive fork() examinée dans le cadre du cours sur les primitives Unix, mais d'examiner l'incidence de ses propriétés sur l'architecture des serveurs.
Le résultat du fork() est la création d'un processus fils qui ne diffère de son père que par les points suivants :
Tout le reste est doublonné, notamment la `` stack '' et surtout la `` heap '' qui peuvent être très volumineuses et donc rendre cette opération pénalisante voire quasi rédhibitoire sur un serveur très chargé (des milliers de processus et de connexions réseaux).
Si le but du fork dans le processus fils est d'effectuer un exec immédiatement, alors il très intéressant d'utiliser plutôt le vfork. Celui-ci ne fait que créer un processus fils sans copier les données. En conséquence, durant le temps de son exécution avant le exec le fils partage strictement les mêmes données que le père (à utiliser avec précaution). Jusqu'à ce que le processus rencontre un exit ou un exec, le processus père reste bloqué (le vfork ne retourne pas).
En allant plus loin dans la direction prise par vfork, le rforkXV3 autorise la continuation du processus père après le fork, la conséquence est que deux processus partagent le même espace d'adressage simultanément. L'argument d'appel du rfork permet de paramètrer ce qui est effectivement partagé ou non. RFMEM, le principal d'entre eux, indique au noyau que les deux processus partagent tout l'espace d'adressage.
Si cette dernière primitive est très riche de potentialitésXV4, elle est également délicate à manipuler : deux (ou plus) entités logicielles exécutant le même code et accédant aux mêmes données sans précaution particulière vont très certainement converger vers de sérieux ennuis de fonctionnement si le déroulement de leurs opérations n'est pas rigoureusement balisé.
En effet, le soucis principal de ce type de programme multi-entités est de veiller à ce qu'aucune de ses composantes ne puisse changer les états de sa mémoire simultanément. Autrement dit, il faut introduire presque obligatoirement un mécanisme de sémaphore qui permette à l'une des entités logicielles de vérouiller l'accès à telle ou telle ressource mémoire pendant le temps nécessaire à son usage.
Cette opération de `` vérouillage '' elle-même pose problème, parceque les entités logicielles pouvent s'exécuter en parallèle (architecture multi-processeurs) et donc il est indispensable que l'acquisition du sémaphore qui protège une ressource commune soit une opération atomique, c'est à dire qui s'exécute en une fois, sans qu'il y ait possibilité que deux (ou plus) entités logicielles tentent avec succès de l'acquérir. C'est toute la problèmatique des mutexXV5.
2.3 Processus légers, les `` threads ''
Les processus légers ou ``threads'' sont une idée du milieu des années 80. La norme Posix a posé les bases de leur développement durable en 1995 (Posix 1.c), on parle dans ce cas des pthreads.
L'idée fondatrice des threads est de ne pas faire de fork mais plutôt de permettre le partage de l'espace d'adressage à autant de contextes d'exécution du même codeXV6 que l'on souhaite.
figure XV.02
Au lieu de créer un nouveau processus on crée une nouvelle thread, ce qui revient (en gros) à ajouter un nouveau contexte d'exécution sur la pile système dans le processus. L'usage de mutex (cf paragraphe 2.2) est fortement recommandé pour sérialiser les accès aux `` sections critiques '' du code.
Sur une machine ayant une architecture mono-processeur, le premier type
de threads est suffisant, mais dès que la machine est construite avec
une architecture smpXV7 ou
cmtXV8(ce qui est de plus en plus
le cas avec la banalisation des configurations à plusieurs processeurs chacun étant lui-même composé de plusieurs c
urs) l'usage
de threads gérables par le noyau devient beaucoup plus intéressant car
il utilise au mieux les ressources de la machine : un même processus
pourrait avoir deux threads, une s'exécutant sur chacun des deux
processeurs (ou plus bien entendu, s'il y a plus de processeurs).
Le principe étant posé, on distingue plusieurs familles d'implémentation.
D'un coté il y a les threads `` user land '' c'est à dire qui sont complètement gérées par le processus utilisateur et de l'autre les threads `` kernel '', qui sont gérées par le noyau. Ces dernières threads sont supportées par les constructeurs de machines à architectures parallèles, traditionnellement Sun (Solaris), Ibm (Aix), et Compaq (ex Digital, avec True64) et plus récemment Hewlett-Packard avec la version 11.xx d'HP-UX. Le problème est très complexe et chaque constructeur développe ses propres stratégies.
Du coté des OS libres le problème a stagné un peu pendant des années car il monopolise beaucoup de programmeurs de haut niveau, non toujours disponibles pour des tâches au long court...Néanmoins la famille des BSD (FreeBSD et NetBSD principalement) bénéficie depuis peu d'une gestion opérationnelle des threads.
Les threads Linux utilisent rfork qui est simple et très efficace. Cette approche n'est pas satisfaisante car chaque thread est exécutée dans un processus différent (pid différent donc) ce qui est contraire aux recommandations POSIX, d'une part, et d'autre par ne permet pas d'utiliser les règles de priorité définies également par POSIX. Une application avec un grand nombre de threads prend l'avantage sur les autres applications par le fait qu'elle consomme en temps cumulé bien plus que les autres processus mono-thread.
Les threads de FreeBSD sont devenues très efficaces et performantes depuis la version 7 du système, à l'issue d'un travail de longue haleine dont l'historique se trouve sur cette page http://www.freebsd.org/smp/.
Conclusion :
Les threads user land ne s'exécutent que sur un seul processeur quelle que soit l'architecture de la machine qui les supporte. Sur une machine de type smp/cmt il faut que le système d'exploitation supporte les threads kernel pour qu'un même processus puisse avoir des sous-tâches sur tous les processeurs existants.
Les paragraphes qui précèdent utilisent un processus ou une thread
pour pouvoir effectuer au moins deux tâches simultanément : écouter le
réseau et traiter une (ou plusieurs) requête(s). Dans le cas d'un serveur
peu sollicité il tout à fait envisageable de mettre en
uvre une
autre technique appellée `` programmation asynchrone ''.
La programmation asynchrone s'appuie sur l'usage du signal, SIGIO (SIGPOLL sur système V), ignoré par défaut, qui prévient le processus d'une activité sur un descripteur.
La gestion des entrées/sorties sur le descripteur en question est alors traitée comme une exception, par un `` handler '' de signaux.
Le signal SIGIO est ignoré par défaut, il faut demander explicitement au noyau de le recevoir, à l'aide d'un appel à la primitive fcntl. Une fois activé, il n'est pas reçu pour les mêmes raisons selon le protocole employé :
UDP :
TCP :
Où l'on voit que cette technique, du moins en TCP, ne peut être envisagée pour que pour des serveurs peu sollicités. Un trop grand nombre d'interruptions possibles nuit à l'efficacité du système (changements de contexte). De plus la distinction entre les causes du signal est difficile à faire, donc ce signal en TCP est quasi inexploitable.
Conclusion :
La dénomination `` programmation asynchrone '' basée seulement sur l'usage du signal SIGIO (versus SIGPOLL) est abusive. Pour être vraiment asynchrones, ces opérations de lecture et d'écriture ne devraient pas être assujetties au retour des primitives read ou writeXV9. Cette technique permet l'écriture du code de petits serveurs basé sur le protocole UDP (En TCP les causes de réception d'un tel signal sont trop nombreuses) sans fork ni thread.
Un serveur qui a la charge de gérer simultanément plusieurs sockets (serveur multi-protocoles par exemple, comme inetd...) se trouve par construction dans une situation où il doit examiner en même temps plusieurs descripteurs (il pourrait s'agir aussi de tubes de communication).
Il est absolument déconseillé dans cette situation de faire du polling. Cette activité consisterait à examiner chaque descripteur l'un après l'autre dans une boucle infinie qui devrait être la plus rapide possible pour être la plus réactive possible face aux requêtes entrantes. Sous Unix cette opération entraîne une consommation exagérée des ressources cpu, au détriment des autres usagers et services.
La primitive select (4.3 BSD) surveille un ensemble de descripteurs, si aucun n'est actif le processus est endormi et ne consomme aucune ressource cpu. Dès que l'un des descripteurs devient actif (il peut y en avoir plusieurs à la fois) le noyau réveille le processus et l'appel de select rend la main à la procédure appelante avec suffisemment d'information pour que celle-ci puisse identifier quel(s) descripteur(s) justifie(nt) son réveil !
#include <sys/types.h>
#include <sys/time.h>
int select (int maxfd, fd_set *readfs,
fd_set *writefs,
fd_set *exceptfs,
struct timeval *timeout) ;
FD_ZERO(fd_set *fdset) ; /* Tous les bits a zero. */
FD_SET(int fd, fd_set *fdset) ; /* Positionne 'fd' dans 'fdset' */
FD_CLR(int fd, fd_set *fdset) ; /* Retire 'fd' de 'fdset' */
FD_ISSET(int fd, fd_set *fdset) ; /* Teste la presence de 'fd' */
struct timeval /* Cf "time.h" */
{
long tv_sec ; /* Nombre de secondes. */
long tv_usec ; /* Nombre de micro-secondes. */
} ;
Le type fd_set est décrit dans <sys/types.h>, ainsi que les macros FD_XXX.
Le prototype de select est dans <sys/time.h>.
La primitive select examine les masques readfs, writefs et exceptfs et se comporte en fonction de timeout :
Remarque : select travaille au niveau de la micro-seconde, ce que ne fait pas sleep (seconde), d'où un usage possible de timer de précision.
A l'appel, le programme précise quels sont les descripteurs à surveiller dans readfs, writefs et exceptfs.
Au retour, la primitive précise quels sont les descripteurs qui sont actifs dans les champs readfs, writefs et exceptfs. Il convient donc de conserver une copie des valeurs avant l'appel si on veut pouvoir les réutiliser ultérieurement. La primitive renvoie -1 en cas d'erreur (à tester systématiquement) ; une cause d'erreur classique est la réception d'un signal (errno==EINTR).
La macro FD_ISSET est utile au retour pour tester quel descripteur est actif et dans quel ensemble.
Le serveur de serveurs inetd (page 4) est un excellent exemple d'utilisation de la primitive.
La primitive poll (System V) permet la même chose que la primitive select, mais avec une approche différente.
#include <poll.h>
int
poll(struct pollfd *fds, unsigned int nfds, int timeout);
struct pollfd {
int fd ; /* Descripteur de fichier */
short events ; /* Evenements attendus */
short revents ; /* Evenements observes */
} ;
La primitive retourne le nombre de descripteurs rendus disponibles pour effectuer des opérations d'entrée/sortie. -1 indique une condition d'erreur. 0 indique l'expiration d'un délai (`` time-out '').
Les champs events et revents sont des masques
de bits qui paramètrent respectivement les souhaits
du programmeur et ce que le noyau retourne.
On utilise principalement :
On s'apperçoit immédiatement que la valeur du paramètre
de timeout n'est pas compatible ni en forme ni en
comportement entre select et poll.
Sous Unix les serveurs sont implémentés le plus souvent sous forme de daemonsXV10. La raison principale est que ce type de processus est le plus adapté à cette forme de service, comme nous allons l'examiner.
Les daemons sont des processus ordinaires, mais :
La conception d'un daemon suit les règles suivantes :
1). Le
processus fils est alors détaché du terminal, ce que l'on
peut visualiser avec un ps -auxw (versus ps -edalf sur un
système V) en examinant la colonne TT : elle contient ?? ;
pour que le troisième argument de open ne soit pas
biaisé par la valeur du umask lorsque cette
primitive sert aussi à créer des fichiers ;
le source ci-après est un exemple de programmation de daemon, les appels à la fonction syslog font référence à un autre daemon nommé syslogd que nous examinons au paragraphe suivant.
diable.c
Du fait de leur fonctionnement détaché d'un terminal, les daemons ne peuvent plus délivrer directement de message par les canaux habituels (perror...). Pour pallier à cette déficience un daemon est spécialisé dans l'écoute des autres daemons (écoute passive :), il s'agit de syslogdXV11.
Pour dialoguer avec ce daemon un programme doit utiliser les fonctionnalités que le lecteur trouvera très bien décrites dans ``man syslog'', sinon le paragraphe 3.4 en donne un aperçu rapide.
La figure XV.3 suivante schématise le circuit de l'information dans le cas d'une utilisation de syslogd.
Le fichier /etc/syslog.conf est le fichier standard de configuration du daemon syslogd. Il est constitué de lignes de deux champs : un déclencheur (selector) et une action. Entre ces deux champs un nombre quelconque de tabulations.
figure XV.03
Si les conditions du déclencheur sont remplies l'action est exécutée, plus précisement :
Les mots clefs possibles pour le type de daemon sont auth,
authpriv, cron, daemon, kern, lpr, mail, news, syslog, user,
uucp, et local0 à local7. Une étoile (
Le niveau de message est l'un des mots clefs suivants : emerg, alert, crit, err, warning, notice, et debug. Une
étoile (
Dans les syslog plus évolués l'administrateur a la possibilité
de dérouter tous les messages contenant un nom de programme
(!nom_du_prog) ou un nom de machine (+nom_de_machine)
) à
la place, signifie n'importe quel mot clef.
) signifie n'importe lequel. Un point
(
) sépare les deux parties du filtre, comme dans
mail.debug.
.
Exemple de fichier /etc/syslog.conf :
*.err;kern.debug;auth.notice;mail.crit /dev/console *.notice;kern.debug;lpr,auth.info;mail.crit /var/log/messages mail.info /var/log/maillog lpr.info /var/log/lpd-errs cron.* /var/cron/log *.err root *.notice;auth.debug root *.alert root *.emerg * *.info |/usr/local/bin/traitinfo !diablotin *.* /var/log/diablotin.log
Résultat de l'exécution de diablotin sur la machine glups, et dans le fichier /var/log/diablotin.log :
... Jan 27 18:52:02 glups diablotin[20254]: Attention, je suis un vrai 'daemon'... Jan 27 18:52:03 glups diablotin[20254]: Je me tue ! ...
3.4 Fonctions syslog
Les prototypes et arguments des fonctions :
#include <syslog.h>
void openlog(const char *ident, int logopt, int facility) ;
void syslog(int priority, const char *message, ...) ;
void closelog(void) ;
Comme dans l'exemple de `` diablotin '', un programme commence par déclarer son intention d'utiliser le système de log en faisant appel à la fonction openlog :
logopt