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...