Client et serveur web multi-threadés
par Samuel Mimram

 Login :  Mot de passe :

L'objectif de ce TD est de manipuler les threads en OCaml. Leur utilisation principale est de pouvoir gérer les opérations bloquantes. Par exemple, la fonction Unix.read qui permet de lire des données sur un réseau reste en attente tant que des données n'ont pas été reçues. Il est donc nécessaire de faire de telles lectures dans des threads séparés afin de ne pas bloquer tout le programme.

Des compteurs concurrents

Nous allons commencer par un exemple basique qui montre certains phénomènes liés à l'utilisation des threads.

Dans un fichier incr.ml, définir la fonction f suivante, qui incrémente 10000 fois un compteur en affichant à chaque fois sa valeur.

let n = ref 0

let f th =
  for i = 1 to 10000 do
    let x = !n in
    Printf.printf "%d: %d\n%!" th x;
    n := x + 1;
  done
      
Complétez le programe afin de

Pour compiler ce programme, il faudra modifier les propriétés du projet afin d'utiliser les threads :

  1. Lancez plusieurs fois le programme. Est-ce que la sortie est toujours la même ?
  2. Quelle est la valeur attendue pour n en fin de programme ? Quelle valeur obtenez-vous ? Cette valeur est-elle toujours la même ?
  3. Afin de corriger ce problème, utiliser un mutex pour vous assurer que la valeur de n n'aura pas changé entre le moment où on lit sa valeur dans x et celui où on incrémente n. Les fonctions utiles seront Mutex.create pour créer un mutex, Mutex.lock pour prendre un mutex et Mutex.unlock pour le relâcher : on rappelle qu'à tout instant, seul un thread peut avoir pris un mutex. Vérifiez que la valeur finale pour n est bien 100000 sur plusieurs exécutions.

Dans la suite, on s'efforcera de systématiquement protéger par des mutex toute référence (ou plus généralement toute structure de données muable) qui peut être accédée de façon concurrente par deux threads, dont l'un cherche à écrire (deux lectures concurrentes n'ont pas à être protégées).

Déposez votre fichier :

Le nom du fichier à déposer
Il faut se connecter avant de pouvoir déposer

Un navigateur web

Le fichier client.ml contient un navigateur web (très) basique : il se connecte au site www.polytechnique.edu sur le port 80 (c'est le port utilisé par défaut par le protocole HTTP) et envoie la requête

GET /index.html HTTP/1.1
host: www.polytechnique.edu

      
qui signifie qu'il demande la page index.html en utilisant la version 1.1 du protocole HTTP, sur l'hôte www.polytechnique.edu. Puis il lit la réponse et l'affiche sur la sortie standard.

On ne vous demande pas de comprendre en détails ce code mais voici tout de même quelques éléments d'explications :

Une remarque : en salle machine, n'essayez pas de changer le site www.polytechnique.edu par un autre, cela ne fonctionnerait pas à cause du proxy.

On souhaite maintenant modifier ce code pour que la requête ne soit pas toujours la même, et soit entrée par l'utilisateur sur l'entrée standard. Pendant ce temps, il se peut que des données soient reçues sur la socket (et vice versa), on lancera donc deux threads :

Lorsque vous testerez votre programme, notez que la requête ci-dessus se termine par deux retours à la ligne.

Dans un second temps, modifiez votre programme pour qu'il s'arrête lorsque le serveur termine la connexion : c'est le cas lorsque le Unix.read indique que 0 octets ont été lus. Pour tester, il suffit d'envoyer une requête incorrecte suivie de deux retours à la ligne, par exemple:

blop

      
(qui n'est pas une requête valide du protocole HTTP), ce à quoi le serveur web répond
HTTP/1.1 400 Bad Request
Server: SysDSI/6.6.6
Date: Tue, 02 May 2017 15:47:27 GMT
Content-Type: text/html
Content-Length: 173
Connection: close

<html>
<head><title>400 Bad Request</title></head>
<body bgcolor="white">
<center><h1>400 Bad Request</h1></center>
<hr><center>SysDSI/6.6.6</center>
</body>
</html>
      
avant de fermer la connexion.

Déposez votre fichier :

Le nom du fichier à déposer
Il faut se connecter avant de pouvoir déposer

Un serveur web

Après le client, passons au serveur web ! Vous pouvez partir du fichier server.ml. Ce serveur écoute sur le port 8080 de la machine locale. À chaque demande de page, le serveur répond

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 9

Bonjour !
      
ce qui signfie qu'il renvoie un fichier texte qui contient Bonjour !, quelle que soit l'url. Vous pouvez par exemple, regarder les pages http://localhost:8080/ ou http://localhost:8080/blabla. Ici, localhost désigne la machine locale (qui correspond à l'IP 127.0.0.1).

  1. Modifiez le serveur afin qu'il puisse traiter plusieurs requêtes de façon concurrente : le corps de la boucle traitant le résultat de Unix.accept (la fonction qui accepte une nouvelle connexion et renvoie la socket dédiée à celle-ci) doit être exécuté dans un thread séparé.
  2. Afin de comparer l'implémentation avec et sans threads, rajoutez une ligne
    Unix.sleep 1;
            
    dans le traitement des requêtes pour simuler un long calcul ou un accès coûteux au disque (qui bloquerait le thread courant pendant une seconde), puis lancez la commande
    ab -n 9 -c 3 http://127.0.0.1:8080/
            
    qui simule 9 téléchargements de pages, dont 3 se font de façon concurrente (le programme ab permet d'effctuer divers tests sur des serveurs web). Comparez entre une implémentation avec et sans threads le temps total (Time taken for tests) et le nombre de requête abouties par seconde (Requests per second) : on pourra passer simplement de l'une à l'autre des implémentations en remplaçant le lancement de thread par un appel de fonction.
  3. Nous allons maintenant modifier le serveur pour que le contenu renvoyé soit lu dans un fichier dépendant de la requête. Lorsque vous allez sur une page http://localhost:8080/bonjour.txt, nous avons vu que la requête envoyée par le navigateur commence par une ligne de la forme
    GET /bonjour.txt HTTP/1.1
            
    Écrivez une une fonction get_file : string -> string qui extrait la chaîne de caractères bonjour.txt de la requête. On aura ainsi
    # get_file "GET /bonjour.txt HTTP/1.1...";;
    - : string = "bonjour.txt"
            
    On pourra utiliser les fonctions du module String comme String.index_from pour trouver la position de l'espace suivant le nom de fichier et String.sub pour extraire le nom du fichier.
  4. Modifiez ensuite votre serveur pour qu'il lise le contenu de la requête dans un fichier : si on demande http://localhost:8080/bonjour.txt, le serveur renverra le contenu du fichier bonjour.txt (créez un tel fichier avec un contenu quelconque pour tester). Les fonctions pour lire le contenu d'un fichier se trouvent dans le module Unix. À titre d'exemple, voici un programme qui affiche le contenu du fichier bonjour.txt sur la sortie standard:
    let () =
      (* On crée le buffer qu'on va utiliser pour lire le fichier. *)
      let buflen = 1024 in
      let buf = String.create buflen in
      let loop = ref true in
      (* On ouvre le fichier. *)
      let f = Unix.openfile "bonjour.txt" [Unix.O_RDONLY] 0o644 in
      while !loop do
        (* On lit des données dans le fichier. *)
        let n = Unix.read f buf 0 buflen in
        (* On écrit ce qu'on a lu sur la sortie standard. *)
        assert (Unix.write Unix.stdout buf 0 n = n);
        (* Si on a lu 0 octets alors on est en fin de fichier. *)
        if n = 0 then loop := false
      done;
      (* On ferme le fichier. *)
      Unix.close f
            
    De plus Unix.stat permet d'obenir la taille d'un fichier (que le serveur doit renvoyer dans le champ Content-Length) :
    # (Unix.stat "bonjour.txt").Unix.st_size;;
    - : int = 30
            
    Essayez aussi de vous connecter au serveur de votre voisin en remplaçant localhost par le nom de sa machine dans l'URL.
  5. Optionnel (revenez sur cette question en fin TD s'il vous reste du temps).
    De nombreuses extensions sont bien sûr possibles. Par exemple:

Déposez votre fichier :

Le nom du fichier à déposer
Il faut se connecter avant de pouvoir déposer

Des threads légers

À chaque requête, un thread système est créé par notre serveur, ce qui se révèle trop coûteux s'il y a un grand nombre de connexions simultanées. Afin de parer à cette difficulté la librairie lwt a été créée pour fournir des threads légers (lwt = lightweight threads) qui peuvent être lancés en très grand nombre (celle-ci a été initialement créée pour implémenter le serveur web ocsigen).

Afin de pouvoir utiliser la librairie lwt, nous allons utiliser opam qui permet de télécharger et compiler automatiquement des librairies OCaml. Tapez les commandes suivantes dans un terminal :

# on charge le chemin d'opam
export PATH=/usr/local/opam/bin:$PATH
# le proxy
export ALL_PROXY=kuzh:8080
# initialisation d'opam
opam init
# déclaration des variables d'environement
eval `opam config env`
# installation de OCaml
opam switch 4.04.1
# installation de lwt
opam install lwt
# fermez eclipse puis relancez-le depuis cette console (c'est important)
eclipse&
      
(c'est un peu long, environ 5 minutes, n'hésitez pas à commencer les questions optionnelles ci-dessus pendant ce temps).

Nous allons maintenant configurer Eclipse pour qu'il utilise notre OCaml fraichement installé.

Ouf nous pouvons maintenant commencer à travailler...

  1. La première chose à savoir sur lwt c'est que les entrées/sorties doivent se faire en utilisant les fonctions du module Lwt_io (au lieu de la librairie standard d'OCaml comme les modules Printf ou Unix). Le seconde c'est que pour lancer un thread lwt il faut utiliser Lwt_main.run. Testez le programme suivant, dans un fichier bonjour.ml qui affiche bonjour :
    let f () =
      Lwt_io.print "Bonjour\n"
    
    let () =
      Lwt_main.run (f ())
          
  2. Nous allons modifier notre programme pour qu'il demande le prénom de l'utilisateur et affiche « Bonjour prénom ». Nous allons utiliser la fonction suivante pour afficher le message :
    let bonjour nom =
      Lwt_io.print ("Bonjour " ^ nom ^ " !\n")
          
    et la lecture du prénom sera faite à l'aide de
    Lwt_io.read_line Lwt_io.stdin
          
    Essayez d'écrire le programme demandé. Quel est le problème ?
  3. On notera que les types des expressions ci-dessus sont les suivants :
    $ ocaml -I +../lwt bigarray.cma lwt.cma lwt-log.cma unix.cma lwt-unix.cma
            OCaml version 4.04.1
    
    # let bonjour nom = Lwt_io.print ("Bonjour " ^ nom ^ " !\n");;
    val bonjour : string -> unit Lwt.t = <fun>
    # Lwt_io.read_line Lwt_io.stdin;;
    - : string Lwt.t = <abstr>
          
    Par exemple le type string Lwt.t indique un thread lwt qui renvoie une valeur de type string. Essayez de les combiner en utilisant la fonction Lwt.bind dont le type est le suivant :
    # Lwt.bind;;
    - : 'a Lwt.t -> ('a -> 'b Lwt.t) -> 'b Lwt.t = <fun>
          
  4. Plutôt que d'écrire ce code un peu lourd, on peut utiliser l'opérateur infixe >>= défini dans le module Lwt : plutôt que d'écrire
    Lwt.bind a f
          
    on préférera écrire
    a >>= f
          
    après avoir préalablement déclaré l'opérateur >>= par
    open Lwt
          
    Modifiez le programme précédent afin d'utiliser >>= au lieu de Lwt.bind.
  5. Modifiez à nouveau le programme ci-dessus afin qu'il affiche « Quel est votre prénom ? » avant de lire le prénom. Si vous obtenez le warning
    Warning 10: this expression should have type unit.
          
    utilisez >>= afin de le supprimer.

Déposez votre fichier :

Le nom du fichier à déposer
Il faut se connecter avant de pouvoir déposer

À titre d'exercice, nous allons maintenant « convertir » le programme de la première question (qui lance 10 threads qui incrémentent un compteur) afin d'utiliser lwt au lieu de la librairie standard (pour le threads et les entrées/sorties). Vous repartirez de votre fichier incr.ml, puis appliquerez la recette suivante :

  1. enlevez les boucles (for et while) en utilisant des fonctions récursives à la place,
  2. faites vos affichages avec Lwt_io.printf au lieu de Printf.printf,
  3. utilisez Lwt.bind pour faire en sorte que le programme type : la règle générale est qu'il faut transformer un code de la forme
    let x = f y in
    g
              
    dans lequel x a un type de la forme 'a Lwt.t en
    f y >>= fun x ->
    g
              
    il est aussi parfois nécessaire d'utiliser la fonction Lwt.return de type
    # Lwt.return;;
    - : 'a -> 'a Lwt.t = <fun>
              
    qui transforme une valeur en un thread lwt qui renvoie cette valeur,
  4. pour lancer plusieurs threads et attendre qu'ils terminent, on utilisera la fonction Lwt.join dont le type est
    # Lwt.join;;
    - : unit Lwt.t list -> unit Lwt.t = <fun>
              
  5. enfin on exécute le thread lwt à l'aide de Lwt_main.run.

Déposez votre fichier :

Le nom du fichier à déposer
Il faut se connecter avant de pouvoir déposer

Finalement, convertissez le code de votre serveur afin d'utiliser lwt. Vous repartirez du fichier server.ml que vous avez écrit à la question précédente et appliquerez la recette ci-dessus et utilisrez de plus le module Lwt_unix au lieu du module Unix (les fonctions portent le même nom dans les deux modules).

Déposez votre fichier :

Le nom du fichier à déposer
Il faut se connecter avant de pouvoir déposer