Programmation Avancée
TP8: un chat écolo
Par défaut, les primitives IO telles que Unix.read
et
Unix.write
sont bloquantes: le noyau suspend l'exécution
du programme qui a invoqué l'appel système jusqu'à ce que
l'opération puisse être effectuée.
Si l'on veut se placer en attente sur plusieurs opérations
(par exemple si l'on écrit un serveur, qui dialogue avec
plusieurs clients) il faut d'autres dispositifs.
On peut s'en tirer dans une certaine mesure avec des threads,
mais cette solution seule ne permet pas toujours assez de
contrôle. De plus, les threads natifs se révèlent parfois
être trop lourds pour pouvoir en allouer un par requête
– point délicat qui dépend cependant beaucoup du noyau,
du nombre de coeurs, etc.
Pour pallier à ce manque, l'interface de programmation UNIX
fournit l'instruction select
. Cet appel système exporte
exactement la fonctionnalité utilisée dans le noyau pour
ordonnancer des threads en attente sur des entrées-sorties:
elle permet d'attendre qu'une ou plusieurs opérations soient
possibles sur des descripteurs de fichiers.
L'appel système select
est un outil essentiel pour écrire
des serveurs efficaces, mais son utilisation oblige à
modifier considérablement le style de programmation.
Dans un langage de programmation fonctionnel, il est cependant
relativement aisé d'écrire un bibliothèque de threads "légers"
ou "verts" autour de cette primitive.
On peut retrouver ainsi (modulo sucre syntaxique) un style
de programmation naturel (continuations/callbacks)
et modulaire (monadique).
Dans ce TP nous allons développer un embryon de bibliothèque de threads verts coopératifs, pour le plaisir de mettre à profit ce qu'on a appris dans le cours. Nous utiliserons notre bibliothèque pour coder un serveur de messagerie instantanée très (très) rudimentaire. Dans la vraie vie, il serait recommandé d'aller voir ailleurs avant de refaire les choses à sa sauce: en OCaml, la bibliothèque de threads légers la plus mature est probablement LWT et plusieurs alternatives existent.
Un certain nombre de fichiers sont donnés comme base pour le TP: récuperer l'archive (ou naviguer ici) et prendre connaissance du Makefile qui sera utile pour le développement modulaire.
Exercice 1: Ordonnanceur de threads légers
Pour commencer, on considère uniquement l'opération read
.
On définit notre ensemble de tâches avec une construction
pour une tâche en attente de la possibilité d'une écriture,
et des constructions génériques pour la tâche vide et la
mise en parallèle de deux tâches:
type task = [ | `Noop | `Par of task * task | `Read of Unix.file_descr * k ] and k = unit -> task
Notre ordonnanceur fournira un unique point d'entrée,
la fonction run : (unit -> task) -> unit
.
Elle prend en argument une fonction (qu'on peut voir
comme la fonction main
du programme) qu'elle exécute
pour avoir la tâche initiale.
Ensuite, tant qu'il y a des tâches `Read
à exécuter,
elle utilise l'appel select
pour attendre sur les
descripteurs de fichiers correspondants, exécute les
tâches dont les descripteurs sont prêts pour une lecture,
et reçoit en retour les nouvelles tâches à exécuter.
On s'appuiera ensuite sur ces bases pour construires des
utilitaires de plus en plus haut niveau.
Par exemple, on ne veut en général pas seulement attendre
qu'une lecture soit possible, mais aussi effectuer la
lecture et récupérer les données.
On fait ceci avec le wrapper suivant de la
primitive Unix.read
, qui attend que la lecture soit
possible, puis effectue la lecture, et si tout va bien
appelle la continuation k
en lui passant le nombre
d'octets lus:
let read fd s i n ?(on_error=default_on_error) k : task = let f () = match try `OK (Unix.read fd s i n) with e -> `KO e with | `OK e -> k e | `KO e -> on_error e in `Read (fd, f)
Pour se faire une idée plus précise de la façon de programmer avec notre librairie, on verra le fichier read.ml.
Question 1
Implémenter run
.
Le scheduler devra maintenir une liste des opérations `Read
en attente, par exemple une variable locale
read : (Unix.file_descr * k) list ref
.
Pour l'utilisation précise de select
, voir
la
référence.
Comme on n'attend que pour des lectures, on utilisera un
appel de la forme let ready,_,_ = Unix.select waiting [] [] (-1.)
.
Question 2
Récupérer ici le code de l'utilitaire read
vu ci-dessus, et l'intégrer à votre module.
Vous pourrez alors tester avec make test_read
.
Comprendre ce que cela fait et s'assurer que le comportement
est satisfaisant.
Question 3
Ajouter les tâches en attente d'écriture, pour obtenir le type complet des tâches:
type task = [ | `Read of Unix.file_descr * k | `Write of Unix.file_descr * k | `Noop | `Par of task*task ] and k = unit -> task (** Wrapper de Unix.write, mêmes paramètres et sémantique *) val write : Unix.file_descr -> string -> int -> int -> ?on_error:(exn -> task) -> (int -> task) -> task (** Comme ci-dessus mais envoie la totalité du buffer: * write_string fd s k = write fd s 0 (String.length s) k *) val write_string : Unix.file_descr -> string -> (int -> task) -> task
Une fois ceci finit il est recommandé de générer la signature
du module (ocamlc -c -i scheduler.ml > scheduler.mli
) et
d'aller l'éditer pour simplifier les types en utilisant
l'alias task
aussi souvent que possible.
Ainsi, les erreurs de type seront plus lisibles.
On pourra aussi cacher toute fonction qu'il n'est pas utile
d'exporter.
Exercice 2: Serveur de chat
Notre ordonnanceur marche pour tout descripteur de fichier, en particulier pour les sockets réseau. Nous allons donc directement pouvoir l'utiliser pour coder un serveur léger.
Question 1
Le fichier tserver.ml contient un serveur rudimentaire qui déploie un thread lourd par client. Le récupérer et l'adapter pour utiliser vos threads légers.
Dans le code founit, lorsqu'un client émet un message, le serveur rediffuse séquentiellement aux autres clients. C'est mauvais: si un client bloque (e.g., panne réseau) alors les clients suivants doivent attendre. Ceci sera simplement corrigé dans votre version, en effectuant les rediffusions en parallèle. Mais attention: comme la rediffusion sera aussi en parallèle avec une éventuelle prochaine réception, ces deux opérations ne peuvent plus partager le même buffer.
On testera avec le client simple flooder.ml.
Question 2
En partant de flooder.ml, écrire un client interactif qui lise sur l'entrée standard et affiche les messages envoyés par le serveur.
Pour aller plus loin
Avec nos threads légers
Ajouter du contrôle: attente sur une disjonction d'évènement, ajout d'un évenèment timeout, simulation des primitives classiques tels que mutexes, conditions, sémaphores...
Permettre la programmation modulaire: enrichir les tâches pour permettre la mise en séquence et en fait le bind monadique.
Blinder le code du scheduler contre les erreurs provenant des tâches, ajouter des priorités, avoir plusieurs threads natifs exécutant les tâches...
Avec le serveur de chat
Côté client: utiliser les contrôles du terminal pour éviter d'interrompre la saisie par l'écriture des messages des autres utilisateurs, utilisation de librairies plus ou moins graphiques... Côté serveur: implémenter des dispositifs anti-flood, gérer les nicknames...