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. Pour pouvoir les utiliser, il faut modifier les propriétés du projet:

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

  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

Commencez par récupérer le fichier network.ml qui est un module qui encapsule les opérations sur le réseau. Il n'est pas nécessaire que vous compreniez les détails de son implémentation.

Le fichier client.ml contient un navigateur web (très) basique. Tout d'abord, il se connecte au site www.polytechnique.edu sur le port 80 (c'est le port utilisé par défaut par le protocole HTTP), la valeur cnx correspond à cette connexion. Ensuite il envoie la requête

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

      
qui signifie qu'il demande la page index.php 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.

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.

  1. 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 connexion (et vice versa), on lancera donc deux threads : Testez votre programme avec la requête ci-dessus (notez qu'elle se termine par deux retours à la ligne).
  2. 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 Network.read renvoie une chaîne de caractères vide. 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. Dans le corps de la boucle, la fonction Network.accept attend une nouvelle connexion et la renvoie quand il y en a une : le code qui traite cette connexion 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. Ajoutez un service qui permet de calculer des factorielles : on veut que lorsqu'on se connecte sur l'url http://localhost:8080/fact/9, la page affiche
    La factorielle de 9 est 362880.
            
    Bien sûr cela devra aussi fonctionner pour tout nombre autre que 9.
  4. Ajouter un service qui compte le nombre de vues : on veut que lorsqu'on se connecte sur l'url http://localhost:8080/count, la page affiche
    La page a été vue NNN fois.
            
    NNN est le nombre de fois où cette page a été vue depuis le lancement du serveur. Doit-on utiliser un mutex ? Si oui, faites-le.
  5. Optionnel (revenez sur cette question en fin TD s'il vous reste du temps).

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. Si vous ne l'avez pas déjà fait, tapez les commandes suivantes dans un terminal permettant d'utiliser opam (elles prennent quelques minutes) :

# initialisation d'opam (répondez oui à toutes les questions)
opam init
# déclaration des variables d'environement
eval `opam config env`
      
puis installez lwt :
# installation de lwt
opam install lwt
# fermez eclipse puis relancez-le depuis cette console (c'est important)
eclipse&
      

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 :
    # 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 utiliserez 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