INF583 - Etude des threads et des connexions réseau

Retour au cours.

Au cours de ce TD, nous allons réaliser un serveur web « multi-threadé » relativement simple, implémentant les fonctionnalités majeures du protocole HTTP/1.0 (pour référence, voici le texte de la norme, appelée RFC1945). Il pourra servir à mettre en ligne une hiérarchie de pages et fichiers statiques. L'envoi de données vers le serveur sera par contre impossible (seule la méthode GET de HTTP/1.0 est implémentée). Vous pourrez utiliser le serveur avec n'importe quel navigateur.

Le but de ce TD est l'étude des interfaces de programmation réseau et multi-thread des systèmes UNIX. Un squelette vous est fourni, contenant la trame du serveur et le code permettant de gérer une connexion suivant le protocole HTTP. Vous le complèterez et le modifierez au fur et à mesure du TD. Nous illustrerons quelques techniques d'implémentation améliorant le temps de réponse d'un serveur et lui permettant de gérer plusieurs connexions simultanément.

0. Étude du squelette fourni

Téléchargez le squelette. Regardez quelles fonctions ont été définies et déterminez, en vous aidant de leurs noms et des commentaires, quel est leur rôle. Lisez notamment entièrement la fonction main et ses commentaires. Les endroits à compléter sont marqués du mot HERE (n'essayez pas de le faire pour le moment!).

Dans la suite du TD, compilez ce code avec GCC en utilisant l'une des options -std=c99 et -std=gnu99, et les options suivantes: -Wall -Werror -pthread.

1. Écoute et traitement d'une connexion

Dans ce premier exercice, nous allons écrire le code permettant à un serveur de recevoir des connexions depuis des clients et de les traiter. L'API réseau des systèmes UNIX est celle des sockets BSD, standardisée par POSIX.

Du point de vue du serveur, l'établissement d'une connexion s'effectue en deux grandes étapes :

  1. Création d'un « point de rendez-vous » par lequel les clients contacteront le serveur ;
  2. Réception et traitement des connexions.

Les points de rendez-vous et les extrémités de connexion établies sont indifféremment représentées par des sockets. L'étape 1 consiste en la création d'une socket et l'association d'une adresse de contact à cette socket. L'étape 2 correspond à l'attente de connexions entrantes et à leur traitement.

Rajoutez au squelette le code permettant au serveur de créer une socket de contact, que les clients contacteront pour établir une connexion, en utilisant les appels systèmes socket (2), bind (2) et listen (2) vus en cours. Ce code est à insérer dans la fonction sock_listen.

Rajoutez maintenant le code d'attente d'une connexion. Il vous faudra utiliser l'appel système accept (2). Ce code est à insérer dans la fonction sock_accept.

Lorsque la connexion est établie, faites-la traiter par le code gérant le protocole HTTP (voir les fonctions http_process et worker, qui l'appelle). Il vous faut pour cela ajouter du code dans la boucle de traitement de la fonction main.

Consultez le code des fonctions sock_listen et sock_accept, ainsi que celui de la boucle while de la fonction main, de la correction proposée à l'exercice 3 (sans tenir compte pour l'instant des sections concernant la gestion des threads).

Pour compiler le code du serveur, vous pouvez vous créer un Makefile. Notez que, en définissant la macro C LOG_REQ, vous pouvez, à la compilation, activer du code qui affiche les requêtes reçues par le serveur et les réponses qu'il y apporte. Les requêtes échouant pour des raisons relativement rares font l'objet d'un affichage d'erreur dans tous les cas.

Testez le serveur avec votre navigateur habituel, puis, si possible, avec l'utilitaire UNIX nc, ce qui vous donnera une méthode de test d'applications réseau très utile et vous obligera, dans ce cas précis, à apprendre quelques rudiments de HTTP.

Le format exact d'une requête HTTP n'est pas très complexe, mais il serait trop long à décrire. Toutefois, toute requête commence par une ligne (dite de « requête »), comportant une commande HTTP (GET ou HEAD par exemple) suivi d'un ou plusieurs espaces, d'une URL (sous la forme d'un chemin relatif ou absolu, ou bien une URL complète comportant le protocole, qui doit être bien évidemment HTTP!), d'un ou plusieurs autres espaces, puis finalement d'une chaîne indiquant la version du protocole HTTP reconnue. Cette ligne se termine par un caractère de retour à la ligne, suivant normalement la convention Windows (soit "\r\n", bien qu'un seul des deux caractères soit suffisant pour que la requête soit acceptée par la plupart des serveurs Web). Par exemple :

GET / HTTP/1.0

est une requête d'affichage d'une page correspondant au chemin "/". Il n'y a aucune obligation que ce chemin corresponde à un fichier ou répertoire du système de fichiers de la machine exécutant le serveur Web. Donner une signification au chemin est à la charge de chaque serveur Web. Par commodité, la plupart des serveurs Web interprètent celui-ci comme un chemin dans le système de fichiers à partir d'un répertoire donné, et une requête GET renvoie le contenu du fichier. Cependant, même un serveur Web complet de ce type, supportant par exemple PHP, transforme habituellement un fichier PHP en une version purement HTML. C'est seulement cette dernière qui sera envoyée au client, et non le fichier original. Autrement dit, même ces serveurs manipulent parfois le contenu des fichiers.

Pour une illustration des possibilités du protocole HTTP, consultez par exemple cette page. Des liens vers les différents RFC sont donnés au bas de celle-ci.

2. Acceptation de multiples connexions à la fois

Notre serveur souffre actuellement du défaut de ne pouvoir servir qu'un seul client à la fois. En effet, une fois le code d'attente de connexion exécuté, le code de traitement est appelé et, pendant ce temps, toute autre connexion entrante doit patienter.

Vérifiez ce comportement en essayant d'accéder à une page web par votre navigateur après avoir établi une connexion au serveur « manuellement », en utilisant les utilitaires nc ou telnet, mais sans avoir encore entré de commande HTTP.

Que se passe-t-il si vous attendez une dizaine de secondes après la connexion manuelle avant d'accéder à une page web ? D'où provient ce comportement ? Pourquoi est-il justifié ?

Un accès depuis le navigateur semble bloqué. Le serveur est en effet en attente de réception d'une requête depuis le client nc ou telnet avec qui la connexion a été établie.

L'appel système accept a retourné un numéro de socket correspondant à l'extrémité d'une nouvelle connexion et le traitement de celle-ci a démarré. Dans la fonction http_process, la première boucle do/while essaie de lire assez d'octets pour former une ligne de requête complète (la fonction http_hdr_req_line_complete est chargée de la vérification). Tant que la ligne est incomplète, le processus s'endort (usleep (HTTP_READ_PAUSE)) et essaie à nouveau, au maximum HTTP_READ_RETRIES fois. Au bout d'un temps limité, les essais sont terminés et le serveur coupe la connexion (au moins 10 secondes, avec les valeurs courantes).

Si votre navigateur patiente assez longtemps dans sa tentative d'établissement de la requête, ou si vous relancez celle-ci après une dizaine de secondes, elle finira par aboutir. Ce comportement est très important pour tous serveurs : sans celui-ci, une application peut, par accident ou intentionnellement, provoquer une attaque par déni de service en établissant des connexions qu'elle n'utilise jamais. Ce problème est bien sûr indépendant du nombre de connexions qui peuvent être traitées simultanément par le serveur (même si une valeur élevée diminue les chances d'occurence du problème).

Un tel fonctionnement est inadéquat dans le cadre d'un serveur utilisé pour des sites web générant un important trafic de requêtes. Pour améliorer la réactivité de notre serveur, nous nous proposons de faire traiter chaque requête par un thread différent. Un thread sera créé lors de la réception d'une connexion, la traitera puis se terminera.

Modifiez votre serveur web pour qu'il crée un thread à chaque nouvelle connexion, en utilisant l'API pthread. Chaque thread exécutera la fonction à compléter worker.

Reprenez le protocole de test précédent et vérifiez que le comportement du serveur web est bien celui attendu.

Il vous suffit d'utiliser la fonction pthread_create (voir la page de manuel), en lui passant comme fonction à exécuter la fonction worker du squelette, qui a la signature requise. La seule petite difficulté est le passage à worker du numéro de socket correspondant à la connexion entrante à traiter.

La norme POSIX (c'est également le cas pour Windows et sa fonction CreateThread) impose que la fonction exécutée dans le nouveau thread ne prenne qu'un seul argument, un pointeur de type indéterminé, car cela permet de contourner la vérification de type rigide du C. La technique classique pour passer le nombre de paramètres désiré est de créer une structure dont les champs contiennent tous les paramètres, de créer ensuite un objet de ce type et de passer un pointeur vers lui à la nouvelle fonction. Cette dernière doit réaliser l'opération inverse, à savoir la conversion vers un pointeur du type de votre structure, pour pouvoir extraire les arguments.

Consultez le code donné en correction de l'exercice suivant. Vous y verrez comment la structure thr_args_s sert à passer les arguments à la fonction worker. Le début de cette dernière, avant l'appel à http_process, récupère les arguments depuis la structure, notamment le numéro de socket.

3. Limitation du nombre de connexions simultanées

L'acceptation simultanée de multiples connexions peut entraîner un fort ralentissement du serveur, empêchant ainsi le traitement régulier des connexions préalablement acceptées, voire une monopolisation de toutes les ressources de la machine sous-jacente, pouvant conduire à une défaillance de traitement des requêtes (ressources épuisées, temps de traitement démesuré pour des requêtes simples, refus de requêtes prioritaires ou légitimes, crash complet de la machine, ...). Cet inconvénient est d'ailleurs parfois exploité par des hackers malveillants, lors d'attaques dites de Denial Of Services (DOS).

Nous nous proposons désormais de limiter le nombre total de connexions acceptées simultanément, en vérifiant avant la création d'un nouveau thread qu'un numerus closus ne soit pas dépassé. Lorsque le nombre de threads autorisés est atteint, le serveur n'acceptera plus de connexion jusqu'à ce qu'un thread se termine.

En utilisant les variables conditionelles de pthread, implémentez ce comportement dans votre serveur. Le nombre maximal de threads autorisés simultanément est fixé à la valeur de la macro THR_NB.

Testez votre implémentation à l'aide de nc ou telnet.

Téléchargez le code de la correction ici.

Dans la boucle principale de l'application (boucle while dans main), le code précédant l'appel à sock_accept sert à tester la valeur courante du nombre de threads pouvant être lancés avant d'atteindre le total maximal de threads choisi (THR_NB).

Ce code est protégé par l'acquisition d'un « mutex » (réalisant une EXclusion MUTuelle, parfois aussi appelé improprement « lock »). Ceci est nécessaire car le compteur du nombre de threads pouvant être lancés est à la fois modifié dans main, qui le diminue chaque fois qu'une nouvelle connexion est acceptée, mais aussi par les threads de traitement, qui l'augmentent quand ils ont terminé de traiter leurs requêtes. Ces opérations pouvant se dérouler simultanément, un mutex est nécessaire (il serait également possible d'utiliser à la place des opérations atomiques).

Le code utilise également une variable conditionnelle. Celle-ci sert au thread principal, exécutant main, à se mettre en sommeil tant que le compteur est à 0, signalant qu'il est impossible de lancer un thread supplémentaire pour traiter une nouvelle connexion. L'attente se fait en utilisant la fonction pthread_cond_wait. Notez bien que :

4. Utilisation d'un groupe de threads pré-alloués

La création d'un thread se révèle coûteuse dans le cadre d'une utilisation intensive. Nous nous proposons donc maintenant de créer tous les threads voulus dès le lancement de notre serveur, et donc préalablement à l'acceptation des connexions.

Lorsqu'une connexion survient, notre serveur affectera le traitement de celle-ci à un des threads. Si aucun thread n'est disponible à ce moment, le serveur attendra que l'un d'entre eux se libère pour lui assigner le traitement de la nouvelle connexion.

Implémentez ce comportement à l'aide de variables conditionnelles.

Téléchargez le code de la correction ici. Il est instructif de le comparer à la correction de l'exercice précédent (par exemple, à l'aide de la commande diff -w).

Le thread principal transmet le numéro de socket d'une nouvelle connexion entrante dans le champ conn_sock de la structure thrs (de type threads_t). Attention, cependant ! Il ne doit pas écrire le numéro de socket d'une autre connexion tant que le premier n'a pas été récupéré par un thread de traitement, sous peine d'écraser l'ancienne valeur et d'oublier la connexion associée.

La signalisation est réalisée à l'aide de deux variables conditionnelles. La première (champ cv_more_work) est signalée par le thread principal lors de l'arrivée d'une connexion, afin de réveiller l'un des threads de traitement pour qu'il s'occupe de celle-ci. La seconde (champ cv_give_work) est signalée par un thread de traitement qui vient de récupérer la connexion placée dans thrs, ce qui réveille le thread principal, qui comprend, grâce à la valeur conventionnelle -1 dans le champ conn_sock, que le travail est pris en compte et qu'il est possible d'en donner un autre à traiter.

Le code que vous avez développé peut servir de base à un serveur web performant et complet. Vous pouvez par exemple vous lancer dans l'implémentation complète de la norme HTTP/1.1, ou dans l'étude des performances du serveur et réfléchir à des moyens de l'améliorer. Néanmoins, si votre but est seulement de créer un site web, utilisez plutôt Apache, ce sera plus simple et plus rapide!