Programmation du système Unix
en Objective Caml

Xavier Leroy et Didier Rémy1

©1991, 1992, 2003, 2004, 2005, 2006, 2008.2

CreativeCommons License

Version monolitique, Postscript, PDF; les Sources et leur licence.

Résumé: Ce document est un cours d’introduction à la programmation du système Unix, mettant l’accent sur la communication entre les processus. La principale nouveauté de ce travail est l’utilisation du langage Objective Caml, un dialecte du langage ML, à la place du langage C qui est d’ordinaire associé à la programmation système. Ceci donne des points de vue nouveaux à la fois sur la programmation système et sur le langage ML.

Introduction

Ces notes sont issues d’un cours de programmation système que Xavier Leroy a enseigné en première année du Magistère de Mathématiques Fondamentales et Appliquées et d’Informatique de l’École Normale Supérieure en 1994. Cette première version utilisait le langage Caml-Light [1]. Didier Rémy en a fait une traduction pour le langage OCaml [2] pour un cours enseigné en Majeure d’Informatique à l’École Polytechnique de 2003 à 2006. À cette occasion, Gilles Roussel, Fabrice Le Fessant et Maxence Guesdon qui ont aidé à ce cours ont également contribué à améliorer ces notes. Cette version comporte des ajouts et quelques mises à jour: en presque une décennie certains ordres de grandeur ont décalé leur virgule d’un chiffre; aussi, la toile était seulement en train d’être tissée et l’exemple, aujourd’hui classique, du relais HTTP aurait presqu’eut un coté précurseur en 1994. Mais surtout le langage OCaml a gagné en maturité depuis et a été utilisé dans de véritables applications système, telles que Unison [16].

La tradition veut que la programmation du système Unix se fasse dans le langage C. Dans le cadre de ce cours, il a semblé plus intéressant d’utiliser un langage de plus haut niveau, Caml en l’occurrence, pour expliquer la programmation du système Unix.

La présentation Caml des appels systèmes est plus abstraite, utilisant toute la puissance de l’algèbre de types de ML pour représenter de manière claire les arguments et les résultats, au lieu de devoir tout coder en termes d’entiers et de champs de bits comme en C. En conséquence, il est plus facile d’expliquer la sémantique des appels systèmes, sans avoir à se perdre dans les détails de l’encodage des arguments et des résultats. (Voir par exemple la présentation de l’appel wait, page ??.)

De plus, OCaml apporte une plus grande sécurité de programmation que C, en particulier grâce au typage statique et à la clarté de ses constructions de base. Ces traits, qui peuvent apparaître au programmeur C chevronné comme de simples éléments de confort, se révèlent cruciaux pour les programmeurs inexpérimentés comme ceux auxquels ce cours s’adresse.

Un deuxième but de cette présentation de la programmation système en OCaml est de montrer le langage OCaml à l’œuvre dans un domaine qui sort nettement de ses applications usuelles, à savoir la démonstration automatique, la compilation, et le calcul symbolique. OCaml se tire plutôt bien de l’expérience, essentiellement grâce à son solide noyau impératif, complété ponctuellement par les autres traits plus novateurs du langage (polymorphisme, fonctions d’ordre supérieur, exceptions). Le fait que OCaml combine programmation applicative et programmation impérative, au lieu de les exclure mutuellement, rend possible l’intégration dans un même programme de calculs symboliques compliqués et d’une bonne interface avec le système.

Ces notes supposent le lecteur familier avec le système OCaml, et avec l’utilisation des commandes Unix, en particulier du shell. On se reportera à la documentation du système OCaml [2] pour toutes les questions relatives au langage, et à la section 1 du manuel Unix ou à un livre d’introduction à Unix [5, 6] pour toutes les questions relatives à l’utilisation d’Unix.

On décrit ici uniquement l’interface “programmatique” du système Unix, et non son implémentation ni son architecture interne. L’architecture interne de BSD 4.3 est décrite dans [8]; celle de System V, dans [9]. Les livres de Tanenbaum [11, 12] donnent une vue d’ensemble des architectures de systèmes et de réseaux.

Le système Objective OCaml dont la bibliothèque d’interface avec Unix présentée ici fait partie intégrante est en accès libre à l’URL http://caml.inria.fr/ocaml/.

Chapter 1  Généralités

1.1  Les modules Sys et Unix

Les fonctions qui donnent accès au système depuis OCaml sont regroupées dans deux modules. Le premier module, Sys, contient les quelques fonctions communes à Unix et aux autres systèmes d’exploitation sous lesquels tourne OCaml. Le second module, Unix, contient tout ce qui est spécifique à Unix. On trouvera dans l’annexe C l’interface du module Unix.

Par la suite, on fait référence aux identificateurs des modules Sys et Unix sans préciser de quel module ils proviennent. Autrement dit, on suppose qu’on est dans la portée des directives open Sys et open Unix. Dans les exemples complets (ceux dont les lignes sont numérotées), on met explicitement les open, afin d’être vraiment complet.

Les modules Sys et Unix peuvent redéfinir certains identificateurs du module Pervasives et cacher leur anciennes définitions. Par exemple, Pervasives.stdin est différent de Unix.stdin. Les anciennes définitions peuvent toujours être obtenues en les préfixant.

Pour compiler un programme OCaml qui utilise la bibliothèque Unix, il faut faire:

     
   ocamlc -o prog unix.cma mod1.ml mod2.ml mod3.ml

en supposant que le programme prog est composé des trois modules mod1, mod2 et mod3. On peut aussi compiler séparément les modules:

     
   ocamlc -c mod1.ml
   ocamlc -c mod2.ml
   ocamlc -c mod3.ml

puis faire pour l’édition de lien:

     
   ocamlc -o prog unix.cma mod1.cmo mod2.cmo mod3.cmo

Dans les deux cas, l’argument unix.cma représente la bibliothèque Unix écrite en OCaml.

Pour utiliser le compilateur natif plutôt que le bytecode, on remplace ocamlc par ocamlopt et unix.cma par unix.cmxa.

On peut aussi accéder au système Unix depuis le système interactif (le “toplevel”). Si le lien dynamique des bibliothèques C est possible sur votre plate-forme, il suffit de lancer le toplevel ocaml et de taper la directive

     
   #load "unix.cma";;

Sinon, il faut d’abord créer un système interactif contenant les fonctions systèmes pré-chargées:

     
   ocamlmktop -o ocamlunix unix.cma

Ce système se lance ensuite par:

     
   ./camlunix

1.2  Interface avec le programme appelant

Lorsqu’on lance un programme depuis un shell (interpréteur de commandes), le shell transmet au programme des arguments et un environnement. Les arguments sont les mots de la ligne de commande qui suivent le nom de la commande. L’environnement est un ensemble de chaînes de la forme variable=valeur, représentant les liaisons globales de variables d’environnements: les liaisons faites avec setenv var=val dans le cas du shell csh, ou bien avec var=val; export var dans le cas du shell sh.

Les arguments passés au programme sont placés dans le vecteur de chaînes argv:

     
   Sys.argv : string array

L’environnement du programme tout entier s’obtient par la fonction environment:

     
   Unix.environment : unit -> string array

Une manière plus commode de consulter l’environnement est par la fonction getenv:

     
   Unix.getenv : string -> string

getenv v renvoie la valeur associée à la variable de nom v dans l’environnement, et déclenche l’exception Not_found si cette variable n’est pas liée. Exemple:

Comme premier exemple, voici le programme echo qui affiche la liste de ses arguments, comme le fait la commande Unix de même nom.

     
   let echo() =
     let len = Array.length Sys.argv in
     if len > 1 then
       begin
         print_string Sys.argv.(1);
         for i = 2 to len - 1 do
           print_char ' ';
           print_string Sys.argv.(i);
         done;
         print_newline();
       end;;
   echo();;


Un programme peut terminer prématurément par l’appel exit:

     
   val exitint -> 'a

L’argument est le code de retour à renvoyer au programme appelant. La convention est de renvoyer zéro comme code de retour quand tout s’est bien passé, et un code de retour non nul pour signaler une erreur. Le shell sh, dans les constructions conditionnelles, interprète le code de retour 0 comme le booléen “vrai” et tout code de retour non nul comme le booléen “faux”. Lorsqu’un programme termine normalement après avoir exécuté toutes les phrases qui le composent, il effectue un appel implicite à exit 0. Lorsqu’un programme termine prématurément parce qu’une exception levée n’a pas été rattrapée, il effectue un appel implicite à exit 2. La fonction exit vide toujours les tampons des canaux ouverts en écriture. La fonction at_exit permet d’enregistrer d’autres actions à effectuer au moment de la terminaison du programme.

     
   val at_exit : (unit -> unit) -> unit

La fonction enregistrée la dernière est appelée en premier. L’enregistrement d’une fonction avec at_exit ne peut pas être ultérieurement annulé. Cependant, ceci n’est pas une véritable restriction, car on peut facilement obtenir cet effet en enregistrant une fonction dont l’exécution dépend d’une variable globale.

1.3  Traitement des erreurs

Sauf mention du contraire, toutes les fonctions du module Unix déclenchent l’exception Unix_error en cas d’erreur.

     
   exception Unix_error of error * string * string

Le deuxième argument de l’exception Unix_error est le nom de l’appel système qui a déclenché l’erreur. Le troisième argument identifie, si possible, l’objet sur lequel l’erreur s’est produite; par exemple, pour un appel système qui prend en argument un nom de fichier, c’est ce nom qui se retrouve en troisième position dans Unix_error. Enfin, le premier argument de l’exception est un code d’erreur, indiquant la nature de l’erreur. Il appartient au type concret énuméré error (voir page ?? pour une description complète):

     
   type error = E2BIG | EACCES | EAGAIN | ...  | EUNKNOWNERR of int

Les constructeurs de ce type reprennent les mêmes noms et les mêmes significations que ceux employés dans la norme POSIX plus certaines erreurs de UNIX98 et BSD. Toutes les autres erreurs sont rapportées avec le constructeur EUNKNOWNERR.

Étant donné la sémantique des exceptions, une erreur qui n’est pas spécialement prévue et interceptée par un try se propage jusqu’au sommet du programme, et le termine prématurément. Qu’une erreur imprévue soit fatale, c’est généralement la bonne sémantique pour des petites applications. Il convient néanmoins de l’afficher de manière claire. Pour ce faire, le module Unix fournit la fonctionnelle handle_unix_error.

     
   val handle_unix_error : ('a -> 'b) -> 'a -> 'b

L’appel handle_unix_error f x applique la fonction f à l’argument x. Si cette application déclenche l’exception Unix_error, un message décrivant l’erreur est affiché, et on sort par exit 2. L’utilisation typique est

     
   handle_unix_error prog ();;

où la fonction prog : unit -> unit exécute le corps du programme prog.

Pour référence, voici comment est implémentée handle_unix_error.

     
   open Unix;;
   let handle_unix_error f arg =
     try
       f arg
     with Unix_error(errfun_namearg) ->
       prerr_string Sys.argv.(0);
       prerr_string ": \"";
       prerr_string fun_name;
       prerr_string "\" failed";
       if String.length arg > 0 then begin
         prerr_string " on \"";
         prerr_string arg;
         prerr_string "\""
       end;
       prerr_string ": ";
       prerr_endline (error_message err);
       exit 2;;

Les fonctions de la forme prerr_xxx sont comporte comme les fonction print_xxx mais à la différence qu’elles écrivent dans le flux d’erreur stderr au lieu d’écrire dans le flux standard stdout. De plus prerr_endline vide le tampon stderr (alors que print_endline ne le fait pas).

La primitive error_message, de type error -> string, renvoie un message décrivant l’erreur donnée en argument (ligne 16). L’argument numéro zéro de la commande, Sys.argv.(0), contient le nom de commande utilisé pour invoquer le programme (ligne 6).

La fonction handle_unix_error traite des erreurs fatales, i.e. des erreurs qui arrêtent le programme. C’est un avantage de OCaml d’obliger les erreurs à être prises en compte, ne serait-ce qu’au niveau le plus haut provoquant l’arrêt du programme. En effet, toute erreur dans un appel système lève une exception, et le fil d’exécution en cours est interrompu jusqu’à un niveau où elle est explicitement rattrapée et donc traitée. Cela évite de continuer le programme dans une situation incohérente.

Les erreurs de type Unix_error peuvent aussi, bien sûr, être filtrée sélectivement. Par exemple, on retrouvera souvent plus loin la fonction suivante

     
   let rec restart_on_EINTR f x =
     try f x with Unix_error (EINTR__) -> restart_on_EINTR f x

qui est utilisé pour exécuter une fonction et la relancer automatiquement lorsque elle est interrompue par un appel système (voir 4.5).

1.4  Fonctions de bibliothèque

Nous verrons au travers d’exemples que la programmation système reproduit souvent les mêmes motifs. Nous seront donc souvent tentés de définir des fonctions de bibliothèque permettant de factoriser les parties communes et ainsi de réduire le code de chaque application à sa partie essentielle.

Alors que dans un programme complet on connaît précisément les erreurs qui peuvent être levées et celles-ci sont souvent fatales (on arrête le programme), on ne connaît pas en général le contexte d’exécution d’une fonction de bibliothèque. On ne peut pas supposer que les erreurs sont fatales. Il faut donc laisser l’erreur retourner à l’appelant qui pourra décider d’une action appropriée (arrêter le programme, traiter ou ignorer l’erreur). Cependant, la fonction de libraire ne va pas en général pas se contenter de regarder l’erreur passer, elle doit maintenir le système dans un état cohérent. Par exemple, une fonction de bibliothèque qui ouvre un fichier puis applique une opération sur le descripteur associé à ce fichier devra prendre soin de refermer le descripteur dans tous les cas de figure, y compris lorsque le traitement du fichier provoque une erreur. Ceci afin d’éviter une fuite mémoire conduisant à l’épuisement des descripteurs de fichiers.

De plus le traitement appliqué au fichier peut être donné par une fonction reçu en argument et on ne sait donc pas précisément quand ni comment le traitement peut échouer (mais l’appelant en général le sait). On sera donc souvent amené à protéger le corps du traitement par un code dit de «finalisation» qui devra être exécuté juste avant le retour de la fonction que celui-ci soit normal ou exceptionnel.

Il n’y a pas de construction primitive de finalisation try ... finalize dans le langage OCaml mais on peut facilement la définir1:

     
   let try_finalize f x finally y =
     let res = try f x with exn -> finally yraise exn in
     finally y;
     res

Cette fonction reçoit le corps principal f et le traitement de finalisation finally, chacun sous la forme d’une fonction, et deux paramètres x et y à passer respectivement à chacune des deux fonctions pour les lancer. Le corps du programme f x est exécuté en premier et son résultat est gardé de coté pour être retourné après l’exécution du code de finalisation finally y. Lorsque le corps du programme échoue, i.e. lève une exception exn, alors le code de finalisation est exécuté puis l’exception exn est relancée. Si à la fois le code principal et le code de finalisation échouent, l’exception lancée est celle du code de finalisation (on pourrait faire le choix inverse).

Note

Dans le reste du cours, nous utiliserons une bibliothèque auxiliaire Misc qui regroupe quelques fonctions d’usage général, telle que try_finalize, souvent utilisées dans les exemples et que nous introduirons au besoin. L’interface du module Misc est donnée en appendice ??. Pour compiler les exemples du cours, il faut donc dans un premier temps rassembler les définitions du module Misc et le compiler.

Le module Misc contient également certaines fonctions ajoutées à titre d’illustration qui ne sont pas utilisées directement dans le cours. Elles enrichissent simplement la bibliothèque Unix ou en redéfinissent le comportement de certaines fonctions. Le module Misc doit prendre priorité sur le module Unix.

Exemples

Le cours comporte de nombreux exemples. Ceux-ci ont été compilés avec OCaml, version . Certains programmes doivent être légèrement modifiés pour être adaptés à une version plus ancienne.

Les exemples sont essentiellement de deux types: soit ce sont des fonctions réutilisables d’usage assez général, dites «fonctions de bibliothèque», soit ce sont de petites applications. Il est important de faire la différence entre ces deux types d’exemples. Dans le premier cas, on voudra laisser le contexte d’utilisation de la fonction le plus large possible, et on prendra donc soin de bien spécifier son interface et de bien traiter tous les cas particuliers. Dans le second cas, une erreur est souvent fatale et entraîne l’arrêt du programme en cours. Il suffit alors de rapporter correctement la cause de l’erreur, sans qu’il soit besoin de revenir à un état cohérent, puisque le programme sera arrêté immédiatement après le report de l’erreur.


1
Une construction primitive n’en serait pas moins avantageuse.

Chapter 2  Les fichiers

Le terme “fichier” en Unix recouvre plusieurs types d’objets:

La représentation d’un fichier contient à la fois les données contenues dans le fichier et des informations sur le fichier (aussi appelées méta-données) telles que son type, les droits d’accès, les dernières dates d’accès, etc.

2.1  Le système de fichiers

En première approximation, le système de fichier est un arbre. La racine est notée '/'. Les arcs sont étiquetés par des noms (de fichiers), formés d’une chaîne de caractères quelconques à l’exception des seuls caractères '\000' et '/', mais il est de bon usage d’éviter également les caractères non imprimables ainsi que les espaces. Les nœuds non terminaux du système de fichiers sont appelés répertoires: il contiennent toujours deux arcs .  et . .  qui désignent respectivement le répertoire lui-même et le répertoire parent. Les autres nœuds sont parfois appelés fichiers, par opposition aux répertoires, mais cela reste ambigu, car on peut aussi désigner par fichier un nœud quelconque. Pour éviter toute ambiguïté, on pourra parler de « fichiers non répertoires ».

Les nœuds du système de fichiers sont désignés par des chemins. Ceux-ci peuvent se référer à l’origine de la hiérarchie et on parlera de chemins absolus, ou à un répertoire (en général le répertoire de travail). Un chemin relatif est une suite de noms de fichiers séparés par le caractère '/'; un chemin absolu est un chemin relatif précédé par le caractère '/' (notez le double usage de ce caractère comme séparateur de chemin et comme le nom de la racine).

La bibliothèque Filename permet de manipuler les chemins de façon portable. Notamment Filename.concat permet de concaténer des chemins sans faire référence au caractère '/', ce qui permettra au code de fonctionner également sur d’autres architectures (par exemple le caractère de séparation des chemins est ’\’ sous Windows). De même, le module Filename donne des noms currentdir et parentdir pour désigner les arcs .  et . . . Les fonctions Filename.basename et Filename.dirname extraient d’un chemin p un préfixe d et un suffixe b tel que les chemins p et d/b désignent le même fichier, d désigne le répertoire dans lequel se trouve le fichier et b le nom du fichier dans ce répertoire. Les opérations définies dans Filename opèrent uniquement sur les chemins indépendemment de leur existence dans la hiérarchie.

En fait, la hiérarchie n’est pas un arbre. D’abord les répertoires conventionnels .  et . .  permettent de s’auto-référencer et de remonter dans la hiérarchie, donc de créer des chemins menant d’un répertoire à lui-même. D’autre part les fichiers non répertoires peuvent avoir plusieurs antécédents. On dit alors qu’il a plusieurs «liens durs». Enfin, il existe aussi des «liens symboliques» qui se prêtent à une double interprétation. Un lien symbolique est un fichier non répertoire dont le contenu est un chemin. On peut donc interpréter un lien symbolique comme un fichier ordinaire et simplement lire son contenu, un lien. Mais on peut aussi suivre le lien symbolique de façon transparente et ne voir que le fichier cible. Cette dernière est la seule interprétation possible lorsque le lien apparaît au milieu d’un chemin: Si s est un lien symbolique dont la valeur est le chemin ℓ, alors le chemin p/s/q désigne le fichier ℓ/q si ℓ est un lien absolu ou le fichier ou p/ℓ/q si ℓ est un lien relatif.


    
  • Liens inverses omis
  • Liens durs
    7 a deux antécédents 2 et 6
  • Liens symboliques
    10 désigne 5
    11 ne désigne aucun nœud
  • Chemins équivalents de 9 à 8?
    . . /usr/lib
    . /. . /usr/lib, etc.
    foo/lib
Figure 2.1: Un petit exemple de hiérarchie de fichiers

La figure 2.1 donne un exemple de hiérarchie de fichiers. Le lien symbolique 11 désigné par le chemin /tmp/bar, dont la valeur est le chemin relatif . . /gnu, ne désigne aucun fichier existant dans la hiérarchie (à cet instant).

En général un parcours récursif de la hiérarchie effectue une lecture arborescente de la hiérarchie:

Si l’on veut suivre les liens symboliques, on est alors ramené à un parcourt de graphe et il faut garder trace des nœuds déjà visités et des nœuds en cours de visite.

Chaque processus a un répertoire de travail. Celui-ci peut être consulté par la commande getcwd et changé par la commande chdir. Il est possible de restreindre la vision de la hiérarchie. L’appel chroot p fait du nœud p, qui doit être un répertoire, la racine de la hiérarchie. Les chemins absolus sont alors interprétés par rapport à la nouvelle racine (et le chemin . .  appliqué à la nouvelle racine reste bien entendu à la racine).

2.2  Noms de fichiers, descripteurs de fichiers

Il y a deux manières d’accéder à un fichier. La première est par son nom, ou chemin d’accès à l’intérieur de la hiérarchie de fichiers. Un fichier peut avoir plusieurs noms différents, du fait des liens durs. Les noms sont représentés par des chaînes de caractères (type string). Voici quelques exemples d’appels système qui opèrent au niveau des noms de fichiers:

unlink fefface le fichier de nom f (comme la commande rm -f f)
link f1   f2crée un lien dur nommé f2 sur le fichier de nom f1 (comme la commande ln f1   f2)
symlink f1   f2crée un lien symbolique nommé f2 sur le fichier de nom f1 (comme la commande ln -s f1   f2)
rename f1   f2renomme en f2 le fichier de nom f1 (comme la commande mv f1   f2).

L’autre manière d’accéder à un fichier est par l’intermédiaire d’un descripteur. Un descripteur représente un pointeur vers un fichier, plus des informations comme la position courante de lecture/écriture dans ce fichier, des permissions sur ce fichier (peut-on lire? peut-on écrire?), et des drapeaux gouvernant le comportement des lectures et des écritures (écritures en ajout ou en écrasement, lectures bloquantes ou non). Les descripteurs sont représentés par des valeurs du type abstrait file_descr.

Les accès à travers un descripteur sont en grande partie indépendants des accès via le nom du fichier. En particulier, lorsqu’on a obtenu un descripteur sur un fichier, le fichier peut être détruit ou renommé, le descripteur pointera toujours sur le fichier d’origine.

Au lancement d’un programme, trois descripteurs ont été préalloués et liés aux variables stdin, stdout et stderr du module Unix:

stdin : file_descrl’entrée standard du processus 
stdout : file_descrla sortie standard du processus 
stderr : file_descrla sortie d’erreur standard du processus

Lorsque le programme est lancé depuis un interpréteur de commandes interactif et sans redirections, les trois descripteurs font référence au terminal. Mais si, par exemple, l’entrée a été redirigée par la notation cmd  < f, alors le descripteur stdin fait référence au fichier de nom f pendant l’exécition de la commande cmd. De même cmd > f (respectivement cmd 2> f) fait en sorte que le descripteur stdout (respectivement stderr) fasse reférence au fichier f pendant l’exécution de la commande cmd.

2.3  Méta-données, types et permissions

Les appels système stat, lstat et fstat retournent les méta-données sur un fichier, c’est-à-dire les informations portant sur le nœud lui-même plutôt que son contenu. Entre autres, ces informations décrivent l’identité du fichier, son type du fichier, les droits d’accès, les dates des derniers d’accès, plus un certain nombre d’informations supplémentaires.

     
   val stat  : string -> stats
   val lstat : string -> stats
   val fstat : file_descr -> stats

Les appels stat et lstat prennent un nom de fichier en argument. L’appel fstat prend en argument un descripteur déjà ouvert et donne les informations sur le fichier qu’il désigne. La différence entre stat et lstat se voit sur les liens symboliques: lstat renvoie les informations sur le lien symbolique lui-même, alors que stat renvoie les informations sur le fichier vers lequel pointe le lien symbolique.


st_dev : intUn identificateur de la partition disque où se trouve le fichier
st_ino : intUn identificateur du fichier à l’intérieur de sa partition. Le couple (st_dev, st_ino) identifie de manière unique un fichier dans le système de fichier.
st_kind : file_kindLe type du fichier. Le type file_kind est un type concret énuméré, de constructeurs:
S_REGfichier normal
S_DIRrépertoire
S_CHRfichier spécial de type caractère
S_BLKfichier spécial de type bloc
S_LNKlien symbolique
S_FIFOtuyau
S_SOCKprise
st_perm : intLes droits d’accès au fichier
st_nlink : intPour un répertoire: le nombre d’entrées dans le répertoire. Pour les autres: le nombre de liens durs sur ce fichier.
st_uid : intLe numéro de l’utilisateur propriétaire du fichier.
st_gid : intLe numéro du groupe propriétaire du fichier.
st_rdev : intL’identificateur du périphérique associé (pour les fichiers spéciaux).
st_size : intLa taille du fichier, en octets.
st_atime : intLa date du dernier accès au contenu du fichier. (En secondes depuis le 1ier janvier 1970, minuit).
st_mtime : intLa date de la dernière modification du contenu du fichier. (Idem.)
st_ctime : intLa date du dernier changement de l’état du fichier: ou bien écriture dans le fichier, ou bien changement des droits d’accès, du propriétaire, du groupe propriétaire, du nombre de liens.
Tableau 2.1: Champs de la structure stats

Le résultat de ces trois appels est un objet enregistrement (record) de type stats décrit dans la table 2.1.

Identification

Un fichier est identifié de façon unique par la paire composé de son numéro de périphérique (typiquement la partition sur laquelle il se trouve) st_dev et de son numéro d’inode st_ino.

Propriétaires

Un fichier a un propriétaire st_uid et un groupe propriétaire st_gid. L’ensemble des utilisateurs et des groupes d’utilisateurs sur la machine est habituellement décrit dans les fichiers /etc/passwd et /etc/groups. On peut les interroger de façon portable par nom à l’aide des commandes getpwnam et getgrnam ou par numéro à l’aide des commandes getpwuid et getgrgid.

Le nom de l’utilisateur d’un processus en train de tourner et l’ensemble des groupes auxquels il appartient peuvent être récupérés par les commandes getlogin et getgroups.

L’appel chown modifie le propriétaire (deuxième argument) et le groupe propriétaire (troisième argument) d’un fichier (premier argument). Seul le super utilisateur a le droit de changer arbitrairement ces informations. Lorsque le fichier est tenu par un descripteur, on utilisera fchown en passant les descripteur au lieu du nom de fichier.

Droits

Les droits sont codés sous forme de bits dans un entier et le type file_perm est simplement une abréviation pour le type int: Les droits comportent une information en lecture, écriture et exécution pour l’utilisateur, le groupe et les autres, plus des bits spéciaux. Les droits sont donc représentés par un vecteur de bits:

 
 
Special


 
 
User


 
 
Group


 
 
Other


 
 


0oSUGO

où pour chacun des champs user, group et other on indique dans l’ordre les droits en lecture (r), écriture (w) et exécution (x). Les permissions sur un fichier sont l’union des permissions individuelles:

Bit (octal)Notation ls -lDroit
0o100--x------exécution, pour le propriétaire
0o200-w-------écriture, pour le propriétaire
0o400r--------lecture, pour le propriétaire
0o10-----x--- exécution, pour les membres des groupes du propriétaire
0o20----w---- écriture, pour les membres des groupes du propriétaire
0o40---r---- lecture, pour les membres des groupes du propriétaire
0o1--------xexécution, pour les autres utilisateurs
0o2-------w-écriture, pour les autres utilisateurs
0o4------r--lecture, pour les autres utilisateurs
0o1000--------tle bit t sur le groupe (sticky bit)
0o2000-----s---le bit s sur le groupe (set-gid)
0o4000--s------le bit s sur l’utilisateur (set-uid)

Le sens des droits de lecture et d’écrire est évident ainsi que le droit d’exécution pour un fichier. Pour un répertoire, le droit d’exécution signifie le droit de se placer sur le répertoire (faire chdir sur ce répertoire). Le droit de lecture sur un répertoire est nécessaire pour en lister son contenu mais pas pour en lire ses fichiers ou sous-répertoires (mais il faut alors en connaître le nom).

Les bits spéciaux ne prennent de sens qu’en présence du bit x (lorsqu’il sont présents sans le bit x, ils ne donnent pas de droits supplémentaires). C’est pour cela que leur représentation se superpose à celle du bit x et on utilise les lettres S et T au lieu de s et t lorsque le bit x n’est pas simultanément présent. Le bit t permet aux sous-répertoires créés d’hériter des droits du répertoire parent. Pour un répertoire, le bit s permet d’utiliser le uid ou le gid de propriétaire du répertoire plutôt que de l’utilisateur à la création des répertoires. Pour un fichier exécutable, le bit s permet de changer au lancement l’identité effective de l’utilisateur (setuid) ou du groupe (setgid). Le processus conserve également ses identités d’origine, à moins qu’il ait les privilèges du super utilisateur, auquel cas, setuid et setgid changent à la fois son identité effective et son identité d’origine. L’identité effective est celle sous laquelle le processus s’exécute. L’identité d’origine est maintenue pour permettre au processus de reprendre ultérieurement celle-ci comme effective sans avoir besoin de privilèges. Les appels système getuid et getgid retournent les identités d’origine et geteuid et getegid retournent les identités effectives.

Un processus possède également un masque de création de fichiers représenté de la même façon. Comme son nom l’indique, le masque est spécifie des interdictions (droits à masquer): lors de la création d’un fichier tous les bits à 1 dans le masque de création sont mis à zéro dans les droits du fichier créé. Le masque peut être consulté et changé par la fonction

     
   val umask : int -> int

Comme pour de nombreux appels système qui modifient une variable système, l’ancienne valeur de la variable est retournée par la fonction de modification. Pour simplement consulter la valeur, il faut donc la modifier deux fois, une fois avec une valeur arbitraire, puis remettre l’ancienne valeur en place. Par exemple, en faisant:

     
           let m = umask 0 in ignore (umask m); m

Les droits d’accès peuvent être modifiés avec l’appel chmod.

On peut également tester les droits d’accès «dynamiquement» avec l’appel système access

     
   type access_permission = R_OK | W_OK | X_OK | F_OK
     
   val access : string -> access_permission list -> unit

où les accès demandés sont représentés pas le type access_permission dont le sens est immédiat sauf pour F_OK qui signifie seulement que le fichier existe (éventuellement sans que le processus ait les droits correspondants).

Notez que access peut retourner une information plus restrictive que celle calculée à partir de l’information statique retournée par lstat car une hiérarchie de fichiers peut être montrée avec des droits restreints, par exemple en lecture seule. Dans ce cas, access refusera le droit d’écrire alors que l’information contenue dans les méta-données relative au fichier peut l’autoriser. C’est pour cela qu’on parle d’information «dynamique» (ce que le processus peut réellement faire) par opposition à «statique» (ce que le système de fichier indique).

2.4  Opérations sur les répertoires

Seul le noyau écrit dans les répertoires (lorsque des fichiers sont créés). Il est donc interdit d’ouvrir un répertoire en écriture. Dans certaines versions d’Unix on peut ouvrir un répertoire en lecture seule et le lire avec read, mais d’autres versions l’interdise. Cependant, même si c’est possible, il est préférable de ne pas le faire car le format des entrées des répertoires varie suivant les versions d’Unix, et il est souvent complexe. Les fonctions suivantes permettent de lire séquentiellement un répertoire de manière portable:

     
   val opendir   : string -> dir_handle
   val readdir   : dir_handle -> string
   val rewinddir : dir_handle -> unit
   val closedir  : dir_handle -> unit

La fonction opendir renvoie un descripteur de lecture sur un répertoire. La fonction readdir lit la prochaine entrée d’un répertoire (ou déclenche l’exception End_of_file si la fin du répertoire est atteinte). La chaîne renvoyée est un nom de fichier relatif au répertoire lu. La fonction rewinddir repositionne le descripteur au début du répertoire.

Pour créer un répertoire, ou détruire un répertoire vide, on dispose de:

     
   val mkdir : string -> file_perm -> unit
   val rmdir : string -> unit

Le deuxième argument de mkdir encode les droits d’accès donnés au nouveau répertoire. Notez qu’on ne peut détruire qu’un répertoire déjà vide. Pour détruire un répertoire et son contenu, il faut donc d’abord aller récursivement vider le contenu du répertoire puis détruire le répertoire.

Par exemple, on peut écrire une fonction d’intérêt général dans le module Misc qui itère sur les entrées d’un répertoire.

     
   let iter_dir f dirname =
     let d = opendir dirname in
     try while true do f (readdir ddone
     with End_of_file -> closedir d

2.5  Exemple complet: recherche dans la hiérarchie

La commande Unix find permet de rechercher récursivement des fichiers dans la hiérarchie selon certains critères (nom, type et droits du fichier) etc. Nous nous proposons ici de réaliser d’une part une fonction de bibliothèque Findlib.find permettant d’effectuer de telles recherches et une commande find fournissant une version restreinte de la commande Unix find n’implantant que les options -follow et -maxdepth.

Nous imposons l’interface suivante pour la bibliothèque Findlib:

     
   val find :
     (Unix.error * string * string -> unit) ->
     (string -> Unix.stats -> bool) -> bool -> int -> string list ->
     unit

L’appel de fonction "find" handler action follow depth roots parcourt la hiérarchie de fichiers à partir des racines indiquées dans la liste roots (absolues ou relatives au répertoire courant au moment de l’appel) jusqu’à une profondeur maximale depth en suivant les liens symboliques si le drapeau follow est vrai. Les chemins trouvés sous une racine r incluent r comme préfixe. Chaque chemin trouvé p est passé à la fonction action. En fait, action reçoit également les informations Unix.stat p si le drapeau follow est vrai ou Unix.lstat p sinon. La fonction action retourne un booléen indiquant également dans le cas d’un répertoire s’il faut poursuivre la recherche en profondeur (true) ou l’interrompre (false).

La fonction handler sert au traitement des erreurs de parcours, nécessairement de type Unix_error: les arguments de l’exception sont alors passés à la fonction handler et le parcours continue. En cas d’interruption, l’exception est remontée à la fonction appelante. Lorsqu’une exception est levée par les fonctions action ou handler, elle arrête le parcours de façon abrupte et est remontée immédiatement à l’appelant.

Pour remonter une exception Unix_error sans qu’elle puisse être attrapée comme une erreur de parcours, nous la cachons sous une autre exception.

     
   exception Hidden of exn
   let hide_exn f x = try f x with exn -> raise (Hidden exn);;
   let reveal_exn f x = try f x with Hidden exn -> raise exn;;

Voici le code de la fonction de parcours.

     
   open Unix;;
   let find on_error on_path follow depth roots =
     let rec find_rec depth visiting filename =
       try
         let infos = (if follow then stat else lstatfilename in
         let continue = hide_exn (on_path filenameinfos in
         let id = infos.st_devinfos.st_ino in
         if infos.st_kind = S_DIR && depth > 0 && continue &&
           (not follow || not (List.mem id visiting))
         then
           let process_child child =
             if (child <> Filename.current_dir_name &&
                 child <> Filename.parent_dir_namethen
               let child_name = Filename.concat filename child in
               let visiting =
                 if follow then id :: visiting else visiting in
               find_rec (depth-1) visiting child_name in
           Misc.iter_dir process_child filename
       with Unix_error (ebc) -> hide_exn on_error (ebcin
     reveal_exn (List.iter (find_rec depth [])) roots;;

Les répertoires sont identifiés par la paire id (ligne 21) constituée de leur numéro de périphérique et de leur numéro d’inode. La liste visiting contient l’ensemble des répertoires en train d’être visités. En fait cette information n’est utile que si l’on suit les liens symboliques (ligne 19).

On peut maintenant en déduire facilement la commande find.

     
   let find () =
     let follow = ref false in
     let maxdepth = ref max_int in
     let roots = ref [] in
     let usage_string  =
       ("Usage: " ^ Sys.argv.(0) ^ " [files...] [options...]"in
     let opt_list =  [
       "-maxdepth"Arg.Int ((:=) maxdepth), "max depth search";
       "-follow"Arg.Set follow"follow symbolic links";
     ] in
     Arg.parse opt_list (fun f -> roots := f :: !rootsusage_string;
     let action p infos = print_endline ptrue in
     let errors = ref false in
     let on_error (ebc) =
       errors := trueprerr_endline (c ^ ": " ^ Unix.error_message ein
     Findlib.find on_error action !follow !maxdepth
       (if !roots = [] then [ Filename.current_dir_name ]
        else List.rev !roots);
     if !errors then exit 1;;
   
   Unix.handle_unix_error find ();;

L’essentiel du code est constitué par l’analyse de la ligne de commande, pour laquelle nous utilisons la bibliothèque Arg.

Bien que la commande find implantée ci-dessus soit assez restreinte, la fonction de bibliothèque Findlib.find est quant à elle très générale, comme le montre l’exercice suivant.

Exercice 1   Utiliser la bibliothèque Findlib pour écrire un programme find_but_CVS équivalent à la commande Unix find . -type d -name CVS -prune -o -print qui imprime récursivement les fichiers à partir du répertoire courant mais sans voir (ni imprimer, ni visiter) les répertoires de nom CVS.
(Voir le corrigé)
     
   let main() =
     let action p infos =
       let b = not (infos.st_kind = S_DIR || Filename.basename p = "CVS"in
       if b then print_endline pb in
     let errors = ref false in
     let error (e,c,b) =
       errors:= trueprerr_endline (b ^ ": " ^ error_message ein
     Findlib.find error action false max_int [ "." ];;
   handle_unix_error main()
Exercice 2   La fonction getcwd n’est pas un appel système mais définie en bibliothèque. Donner une implémentation «primitive» de getcwd. Décrire le principe de l’algorithme.
(Voir le corrigé)
Voici quelques indications: on remonte à partir de la position courante vers la racine en constituant à l’envers le chemin que l’on cherche. La racine est identifié comme l’unique répertoire nœud donc le parent est égal à lui-même (les chemins parentdir et currentdir sont équivalent lorsqu’ils sont pris relatifs à la racine). Pour trouver le nom qui désigne un répertoire r depuis son répertoire parent, il faut parcourir l’ensemble des noms du répertoire parent et identifier le fils qui désigne r. Puis écrire l’algorithme (on évitera de répéter plusieurs fois le même appel système).

2.6  Ouverture d’un fichier

La primitive openfile permet d’obtenir un descripteur sur un fichier d’un certain nom (l’appel système correspond est open, mais open est un mot clé en OCaml).

     
   val openfile : string -> open_flag list -> file_perm -> file_descr

Le premier argument est le nom du fichier à ouvrir. Le deuxième argument est une liste de drapeaux pris dans le type énuméré open_flag, et décrivant dans quel mode le fichier doit être ouvert, et que faire s’il n’existe pas. Le troisième argument de type file_perm indique avec quels droits d’accès créer le fichier, le cas échéant. Le résultat est un descripteur de fichier pointant vers le fichier indiqué. La position de lecture/écriture est initialement fixée au début du fichier.

La liste des modes d’ouverture (deuxième argument) doit contenir exactement un des trois drapeaux suivants:

O_RDONLYouverture en lecture seule 
O_WRONLYouverture en lecture seule 
O_RDWRouverture en lecture et en écriture

Ces drapeaux conditionnent la possibilité de faire par la suite des opérations de lecture ou d’écriture à travers le descripteur. L’appel openfile échoue si on demande à ouvrir en écriture un fichier sur lequel le processus n’a pas le droit d’écrire, ou si on demande à ouvrir en lecture un fichier que le processus n’a pas le droit de lire. C’est pourquoi il ne faut pas ouvrir systématiquement en mode O_RDWR.

La liste des modes d’ouverture peut contenir en plus un ou plusieurs des drapeaux parmi les suivants:

O_APPENDouverture en ajout 
O_CREATcréer le fichier s’il n’existe pas
O_TRUNCtronquer le fichier à zéro s’il existe déjà
O_EXCLéchouer si le fichier existe déjà
O_NONBLOCKouverture en mode non bloquant 
 
O_NOCTTYne pas fonctionner en mode terminal de contrôle
O_SYNCeffectuer les écritures en mode synchronisé
O_DSYNCeffectuer les écritures de données en mode synchronisé
O_RSYNCeffectuer les lectures en mode synchronisé

Le premier groupe indique le comportement à suivre selon que le fichier existe ou non.

Si O_APPEND est fourni, le pointeur de lecture/écriture sera positionné à la fin du fichier avant chaque écriture. En conséquence, toutes les écritures s’ajouteront à la fin du fichier. Au contraire, sans O_APPEND, les écritures se font à la position courante (initialement, le début du fichier).

Si O_TRUNC est fourni, le fichier est tronqué au moment de l’ouverture: la longueur du fichier est ramenée à zéro, et les octets contenus dans le fichier sont perdus. Les écritures repartent donc d’un fichier vide. Au contraire, sans O_TRUNC, les écritures se font par dessus les octets déjà présents, ou à la suite.

Si O_CREAT est fourni, le fichier est créé s’il n’existe pas déjà. Le fichier est créé avec une taille nulle, et avec pour droits d’accès les droits indiqués par le troisième argument, modifiés par le masque de création du processus. (Le masque de création est consultable et modifiable par la commande umask, et par l’appel système de même nom).

Exemple:

la plupart des programmes prennent 0o666 comme troisième argument de openfile, c’est-à-dire rw-rw-rw- en notation symbolique. Avec le masque de création standard de 0o022, le fichier est donc créé avec les droits rw-r--r--. Avec un masque plus confiant de 0o002, le fichier est créé avec les droits rw-rw-r--.

Si O_EXCL est fourni, openfile échoue si le fichier existe déjà. Ce drapeau, employé en conjonction avec O_CREAT, permet d’utiliser des fichiers comme verrous (locks).1 Un processus qui veut prendre le verrou appelle openfile sur le fichier avec les modes O_EXCL et O_CREAT. Si le fichier existe déjà, cela signifie qu’un autre processus détient le verrou. Dans ce cas, openfile déclenche une erreur, et il faut attendre un peu, puis réessayer. Si le fichier n’existe pas, openfile retourne sans erreur et le fichier est créé, empêchant les autres processus de prendre le verrou. Pour libérer le verrou, le processus qui le détient fait unlink dessus. La création d’un fichier est une opération atomique: si deux processus essayent de créer un même fichier en parallèle avec les options O_EXCL et O_CREAT, au plus un seul des deux seulement peut réussir. Évidemment cette méthode n’est pas très satisfaisante car d’une part le processus qui n’a pas le verrou doit être en attente active, d’autre part un processus qui se termine anormalement peux laisser le verrou bloqué.

Exemple:

pour se préparer à lire un fichier:

     
   openfile filename [O_RDONLY] 0

Le troisième argument peut être quelconque, puisque O_CREAT n’est pas spécifié. On prend conventionnellement 0. Pour écrire un fichier à partir de rien, sans se préoccuper de ce qu’il contenait éventuellement:

     
   openfile filename [O_WRONLYO_TRUNCO_CREAT] 0o666

Si le fichier qu’on ouvre va contenir du code exécutable (cas des fichiers créés par ld), ou un script de commandes, on ajoute les droits d’exécution dans le troisième argument:

     
   openfile filename [O_WRONLYO_TRUNCO_CREAT] 0o777

Si le fichier qu’on ouvre est confidentiel, comme par exemple les fichiers “boîte aux lettres” dans lesquels mail stocke les messages lus, on le crée en restreignant la lecture et l’écriture au propriétaire uniquement:

     
   openfile filename [O_WRONLYO_TRUNCO_CREAT] 0o600

Pour se préparer à ajouter des données à la fin d’un fichier existant, et le créer vide s’il n’existe pas:

     
   openfile filename [O_WRONLYO_APPENDO_CREAT] 0o666


Le drapeau O_NONBLOCK assure que si le support est un tuyau nommé ou un fichier spécial, alors l’ouverture du fichier ainsi que les lectures et écritures ultérieur se feront en mode non bloquant.


Le drapeau O_NOCTYY assure que si le support est un terminal de contrôle (clavier, fenêtre, etc.), alors celui-ci ne devient pas le terminal de contrôle du processus appelant.


Le dernier groupe de drapeaux indique comment synchroniser les opérations de lectures et écritures. Par défaut, ces opérations ne sont pas synchronisées.

Si O_DSYNC est fourni, les données sont écrites de façon synchronisée de telle façon que la commande est bloquante et ne retourne que lorsque toutes les écritures auront été effectuées physiquement sur le support (disque en général).

Si O_SYNC est fourni, ce sont à la fois les données et les informations sur le fichier qui sont synchronisées.

Si O_RSYNC est fourni en présence de O_DSYNC les lectures des données sont également synchronisées: il est assuré que toutes les écritures en cours (demandées mais pas nécessairement enregistrées) sur ce fichier seront effectivement écrites sur le support avant la prochaine lecture. Si O_RSYNC est fourni en présence de O_SYNC cela s’applique également aux informations sur le fichier.

2.7  Lecture et écriture

Les appels systèmes read et write permettent de lire et d’écrire les octets d’un fichier. Pour des raisons historiques, l’appel système write est relevé en OCaml sous le nom single_write:

     
   val read  : file_descr -> string -> int -> int -> int
   val single_write : file_descr -> string -> int -> int -> int

Les deux appels read et single_write ont la même interface. Le premier argument est le descripteur sur lequel la lecture ou l’écriture doit avoir lieu. Le deuxième argument est une chaîne de caractères contenant les octets à écrire (cas de single_write), ou dans laquelle vont être stockés les octets lus (cas de read). Le troisième argument est la position, dans la chaîne de caractères, du premier octet à écrire ou à lire. Le quatrième argument est le nombre d’octets à lire ou à écrire. Le troisième argument et le quatrième argument désignent donc une sous-chaîne de la chaîne passée en deuxième argument. (Cette sous-chaîne ne doit pas déborder de la chaîne d’origine; read et single_write ne vérifient pas ce fait.)


L’entier renvoyé par read ou single_write est le nombre d’octets réellement lus ou écrits.

Les lectures et les écritures ont lieu à partir de la position courante de lecture/écriture. (Si le fichier a été ouvert en mode O_APPEND, cette position est placée à la fin du fichier avant toute écriture.) Cette position est avancée du nombre d’octets lus ou écrits.

Dans le cas d’une écriture, le nombre d’octets effectivement écrits est normalement le nombre d’octets demandés, mais il y a plusieurs exceptions à ce comportement: (i) dans le cas où il n’est pas possible d’écrire les octets (si le disque est plein, par exemple); (ii) lorsqu’on écrit sur un descripteur de fichiers qui référence un tuyau ou une prise placé dans le mode entrées/sorties non bloquantes, les écritures peuvent être partielles; enfin, (iii) OCaml qui fait une copie supplémentaire dans un tampon auxiliaire et écrit celui-ci limite la taille du tampon auxiliaire à une valeur maximale (qui est en général la taille utilisée par le système pour ses propres tampons) ceci pour éviter d’allouer de trop gros tampons; si le le nombre d’octets à écrire est supérieure à cette limite, alors l’écriture sera forcément partielle même si le système aurait assez de ressource pour effectuer une écriture totale.

Pour contourner le problème de la limite des tampons, OCaml fournit également une fonction write qui répète plusieurs écritures tant qu’il n’y a pas eu d’erreur d’écriture. Cependant, en cas d’erreur, la fonction retourne l’erreur et ne permet pas de savoir le nombre d’octets effectivement écrits. On utilisera donc plutôt la fonction single_write que write parce qu’elle préserve l’atomicité (on sait exactement ce qui a été écrit) et est donc plus fidèle à l’appel système d’Unix (voir également l’implémentation de single_write décrite dans le chapitre suivant 5.7).

Nous verrons dans le chapitre suivant que lorsqu’on écrit sur un descripteur de fichier qui référence un tuyau ou une prise qui est placé dans le mode entrées/sorties bloquantes et que l’appel est interrompu par un signal, l’appel single_write retourne une erreur EINTR.

Exemple:

supposant fd lié à un descripteur ouvert en écriture,

     
   write fd "Hello world!" 3 7

écrit les caractères “"lo worl"” dans le fichier correspondant, et renvoie 7.

Dans le cas d’une lecture, il se peut que le nombre d’octets effectivement lus soit strictement inférieur au nombre d’octets demandés. Premier cas: lorsque la fin du fichier est proche, c’est-à-dire lorsque le nombre d’octets entre la position courante et la fin du fichier est inférieur au nombre d’octets requis. En particulier, lorsque la position courante est sur la fin du fichier, read renvoie zéro. Cette convention “zéro égal fin de fichier” s’applique aussi aux lectures depuis des fichiers spéciaux ou des dispositifs de communication. Par exemple, read sur le terminal renvoie zéro si on frappe ctrl-D en début de ligne.

Deuxième cas où le nombre d’octets lus peut être inférieur au nombre d’octets demandés: lorsqu’on lit depuis un fichier spécial tel qu’un terminal, ou depuis un dispositif de communication comme un tuyau ou une prise. Par exemple, lorsqu’on lit depuis le terminal, read bloque jusqu’à ce qu’une ligne entière soit disponible. Si la longueur de la ligne dépasse le nombre d’octets requis, read retourne le nombre d’octets requis. Sinon, read retourne immédiatement avec la ligne lue, sans forcer la lecture d’autres lignes pour atteindre le nombre d’octets requis. (C’est le comportement par défaut du terminal; on peut aussi mettre le terminal dans un mode de lecture caractère par caractère au lieu de ligne à ligne. Voir section 2.13 ou page ?? pour avoir tous les détails.)

Exemple:

l’expression suivante lit au plus 100 caractères depuis l’entrée standard, et renvoie la chaîne des caractères lus.

     
   let buffer = String.create 100 in
   let n = read stdin buffer 0 100 in
     String.sub buffer 0 n

Exemple:

la fonction really_read ci-dessous a la même interface que read, mais fait plusieurs tentatives de lecture si nécessaire pour essayer de lire le nombre d’octets requis. Si, ce faisant, elle rencontre une fin de fichier, elle déclenche l’exception End_of_file.

     
   let rec really_read fd buffer start length =
     if length <= 0 then () else
       match read fd buffer start length with
         0 -> raise End_of_file
       | r -> really_read fd buffer (start + r) (length - r);;

2.8  Fermeture d’un descripteur

L’appel système close ferme le descripteur passé en argument.

     
   val close : file_descr -> unit

Une fois qu’un descripteur a été fermé, toute tentative de lire, d’écrire, ou de faire quoi que ce soit avec ce descripteur échoue. Il est recommandé de fermer les descripteurs dès qu’ils ne sont plus utilisés. Ce n’est pas obligatoire; en particulier, contrairement à ce qui se passe avec la bibliothèque standard Pervasives, il n’est pas nécessaire de fermer les descripteurs pour être certain que les écritures en attente ont été effectuées: les écritures faites avec write sont immédiatement transmises au noyau. D’un autre côté, le nombre de descripteurs qu’un processus peut allouer est limité par le noyau (plusieurs centaines à quelques milliers). Faire close sur un descripteur inutile permet de le désallouer, et donc d’éviter de tomber à court de descripteurs.

2.9  Exemple complet: copie de fichiers

On va programmer une commande file_copy, à deux arguments f1 et f2, qui recopie dans le fichier de nom f2 les octets contenus dans le fichier de nom f1.

     
   open Unix;;
   
   let buffer_size = 8192;;
   let buffer = String.create buffer_size;;
   
   let file_copy input_name output_name =
     let fd_in = openfile input_name [O_RDONLY] 0 in
     let fd_out = openfile output_name [O_WRONLYO_CREATO_TRUNC] 0o666 in
     let rec copy_loop () =
       match read fd_in buffer 0 buffer_size with
         0 -> ()
       | r -> ignore (write fd_out buffer 0 r); copy_loop () in
     copy_loop ();
     close fd_in;
     close fd_out;;
     
   
   let copy () =
     if Array.length Sys.argv = 3 then begin
       file_copy Sys.argv.(1) Sys.argv.(2);
       exit 0
     end else begin
       prerr_endline
         ("Usage: " ^Sys.argv.(0)^ " <input_file> <output_file>");
       exit 1
     end;;
   
   handle_unix_error copy ();;

L’essentiel du travail est fait par la fonction file_copy des lignes 6–15. On commence par ouvrir un descripteur en lecture seule sur le fichier d’entrée (ligne 7), et un descripteur en écriture seule sur le fichier de sortie (ligne 8). Le fichier de sortie est tronqué s’il existe déjà (option O_TRUNC), et créé s’il n’existe pas (option O_CREAT), avec les droits rw-rw-rw- modifiés par le masque de création. (Ceci n’est pas satisfaisant: si on copie un fichier exécutable, on voudrait que la copie soit également exécutable. On verra plus loin comment attribuer à la copie les mêmes droits d’accès qu’à l’original.) Dans les lignes 9–13, on effectue la copie par blocs de buffer_size caractères. On demande à lire buffer_size caractères (ligne 10). Si read renvoie zéro, c’est qu’on a atteint la fin du fichier d’entrée, et la copie est terminée (ligne 11). Sinon (ligne 12), on écrit les r octets qu’on vient de lire sur le fichier de destination, et on recommence. Finalement, on ferme les deux descripteurs. Le programme principal (lignes 17–24) vérifie que la commande a reçu deux arguments, et les passe à la fonction file_copy.

Toute erreur pendant la copie, comme par exemple l’impossibilité d’ouvrir le fichier d’entrée, parce qu’il n’existe pas ou parce qu’il n’est pas permis de le lire, ou encore l’échec d’une écriture par manque de place sur le disque, se traduit par une exception Unix_error qui se propage jusqu’au niveau le plus externe du programme, où elle est interceptée et affichée par handle_unix_error.

Exercice 3   Ajouter une option -a au programme, telle que file_copy -a f1 f2 ajoute le contenu de f1 à la fin de f2 si f2 existe déjà.
(Voir le corrigé)
Si l’option -a est fournie, il faut faire
     
           openfile output_name [O_WRONLYO_CREATO_APPEND] 0o666
à la place de
     
           openfile output_name [O_WRONLYO_CREATO_TRUNC] 0o666
Le parsing de l’option est laissé au lecteur.

2.10  Coût des appels système. Les tampons.

Dans l’exemple file_copy, les lectures se font par blocs de 8192 octets. Pourquoi pas octet par octet? ou mégaoctet par mégaoctet? Pour des raisons d’efficacité. La figure 2.2 montre la vitesse de copie, en octets par seconde, du programme file_copy, quand on fait varier la taille des blocs (la variable buffer_size) de 1 octet a 8 mégaoctets, en doublant à chaque fois.

Pour de petites tailles de blocs, la vitesse de copie est à peu près proportionnelle à la taille des blocs. Cependant, la quantité de données transférées est la même quelle que soit la taille des blocs. L’essentiel du temps ne passe donc pas dans le transfert de données proprement dit, mais dans la gestion de la boucle copy_loop, et dans les appels read et write. En mesurant plus finement, on voit que ce sont les appels read et write qui prennent l’essentiel du temps. On en conclut donc qu’un appel système, même lorsqu’il n’a pas grand chose à faire (read d’un caractère), prend un temps minimum d’environ 4 micro-secondes (sur la machine employée pour faire le test—un Pentium 4 à 2.8 GHz), disons 1 à 10 micro-secondes. Pour des blocs d’entrée/sortie de petite taille, c’est ce temps d’appel système qui prédomine.

Pour des blocs plus gros, entre 4K et 1M, la vitesse est constante et maximale. Ici, le temps lié aux appels systèmes et à la boucle de copie est petit devant le temps de transfert des données. D’autre part la taille du tampon devient supérieur à la tailles des caches utilisés par le système. Et le temps passé par le système à gérer le transfert devient prépondérant sur le coût d’un appel système2

Enfin, pour de très gros blocs (8M et plus), la vitesse passe légèrement au-dessous du maximum. Entre en jeu ici le temps nécessaire pour allouer le bloc et lui attribuer des pages de mémoire réelles au fur et à mesure qu’il se remplit.


Figure 2.2: Vitesse de copie en fonction de la taille des blocs

Moralité: un appel système, même s’il fait très peu de travail, coûte cher — beaucoup plus cher qu’un appel de fonction normale: en gros, de 2 à 20 micro-secondes par appel système, suivant les architectures. Il est donc important d’éviter de faire des appels système trop fréquents. En particulier, les opérations de lecture et d’écriture doivent se faire par blocs de taille suffisante, et non caractère par caractère.

Dans des exemples comme file_copy, il n’est pas difficile de faire les entrées/sorties par gros blocs. En revanche, d’autres types de programmes s’écrivent naturellement avec des entrées caractère par caractère (exemples: lecture d’une ligne depuis un fichier, analyse lexicale), et des sorties de quelques caractères à la fois (exemple: affichage d’un nombre). Pour répondre aux besoins de ces programmes, la plupart des systèmes fournissent des bibliothèques d’entrées-sorties, qui intercalent une couche de logiciel supplémentaire entre l’application et le système d’exploitation. Par exemple, en OCaml, on dispose du module Pervasives de la bibliothèque standard, qui fournit deux types abstraits in_channel et out_channel, analogues aux descripteurs de fichiers, et des opérations sur ces types, comme input_char, input_line, output_char, ou output_string. Cette couche supplémentaire utilise des tampons (buffers) pour transformer des suites de lectures ou d’écritures caractère par caractère en une lecture ou une écriture d’un bloc. On obtient donc de bien meilleures performances pour les programmes qui procèdent caractère par caractère. De plus, cette couche supplémentaire permet une plus grande portabilité des programmes: il suffit d’adapter cette bibliothèque aux appels système fournis par un autre système d’exploitation, et tous les programmes qui utilisent la bibliothèque sont immédiatement portables vers cet autre système d’exploitation.

2.11  Exemple complet: une petite bibliothèque d’entrées-sorties

Pour illustrer les techniques de lecture/écriture par tampon, voici une implémentation simple d’un fragment de la bibliothèque Pervasives de OCaml. L’interface est la suivante:

     
   type in_channel
   exception End_of_file
   val open_in : string -> in_channel
   val input_char : in_channel -> char
   val close_in : in_channel -> unit
   type out_channel
   val open_out : string -> out_channel
   val output_char : out_channel -> char -> unit
   val close_out : out_channel -> unit

Commençons par la partie “lecture”. Le type abstrait in_channel est implémenté comme suit:

     
   open Unix;;
   
   type in_channel =
     { in_bufferstring;
       in_fdfile_descr;
       mutable in_posint;
       mutable in_endint };;
   exception End_of_file

La chaîne de caractères du champ in_buffer est le tampon proprement dit. Le champ in_fd est un descripteur de fichier (Unix), ouvert sur le fichier en cours de lecture. Le champ in_pos est la position courante de lecture dans le tampon. Le champ in_end est le nombre de caractères valides dans le tampon.

Les champs in_pos et in_end vont être modifiés en place à l’occasion des opérations de lecture; on les déclare donc mutable.

     
   let buffer_size = 8192;;
   let open_in filename =
     { in_buffer = String.create buffer_size;
       in_fd = openfile filename [O_RDONLY] 0;
       in_pos = 0;
       in_end = 0 };;

À l’ouverture d’un fichier en lecture, on crée le tampon avec une taille raisonnable (suffisamment grande pour ne pas faire d’appels système trop souvent; suffisamment petite pour ne pas gâcher de mémoire), et on initialise le champ in_fd par un descripteur de fichier Unix ouvert en lecture seule sur le fichier en question. Le tampon est initialement vide (il ne contient aucun caractère du fichier); le champ in_end est donc initialisé à zéro.

     
   let input_char chan =
     if chan.in_pos < chan.in_end then begin
       let c =  chan.in_buffer.[chan.in_posin
         chan.in_pos <- chan.in_pos + 1;
         c
     end else begin
       match read chan.in_fd chan.in_buffer 0 buffer_size
       with 0 -> raise End_of_file
          | r -> chan.in_end <- r;
                 chan.in_pos <- 1;
                 chan.in_buffer.[0]
     end;;

Pour lire un caractère depuis un in_channel, de deux choses l’une. Ou bien il reste au moins un caractère dans le tampon; c’est-à-dire, le champ in_pos est strictement inférieur au champ in_end. Alors on renvoie le prochain caractère du tampon, celui à la position in_pos, et on incrémente in_pos. Ou bien le tampon est vide. On fait alors un appel système read pour remplir le tampon. Si read retourne zéro, c’est que la fin du fichier a été atteinte; on déclenche alors l’exception End_of_file. Sinon, on place le nombre de caractères lus dans le champ in_end. (On peut avoir obtenu moins de caractères que demandé, et donc le tampon peut être partiellement rempli.) Et on renvoie le premier des caractères lus.

     
   let close_in chan =
     close chan.in_fd;;

La fermeture d’un in_channel se réduit à la fermeture du descripteur Unix sous-jacent.


La partie “écriture” est très proche de la partie “lecture”. La seule dissymétrie est que le tampon contient maintenant des écritures en retard, et non plus des lectures en avance.

     
   type out_channel =
     { out_bufferstring;
       out_fdfile_descr;
       mutable out_posint };;
   
   let open_out filename =
     { out_buffer = String.create 8192;
       out_fd = openfile filename [O_WRONLYO_TRUNCO_CREAT] 0o666;
       out_pos = 0 };;
   
   let output_char chan c =
     if chan.out_pos < String.length chan.out_buffer then begin
       chan.out_buffer.[chan.out_pos] <- c;
       chan.out_pos <- chan.out_pos + 1
     end else begin
       ignore (write chan.out_fd chan.out_buffer 0 chan.out_pos);
       chan.out_buffer.[0] <- c;
       chan.out_pos <- 1
     end;;
   
   let close_out chan =
     ignore (write chan.out_fd chan.out_buffer 0 chan.out_pos);
     close chan.out_fd;;

Pour écrire un caractère sur un out_channel, ou bien le tampon n’est pas plein, et on se contente de stocker le caractère dans le tampon à la position out_pos, et d’avancer out_pos; ou bien le tampon est plein, et dans ce cas on le vide dans le fichier par un appel write, puis on stocke le caractère à écrire au début du tampon.

Quand on ferme un out_channel, il ne faut pas oublier de vider le contenu du tampon (les caractères entre les positions 0 incluse et out_pos exclue) dans le fichier. Autrement, les écritures effectuées depuis la dernière vidange seraient perdues.

Exercice 4   Implémenter une fonction
     
   val output_string out_channel -> string -> unit
qui se comporte comme une série de output_char sur chaque caractère de la chaîne, mais est plus efficace.
(Voir le corrigé)
L’idée est de recopier la chaîne à sortir dans le tampon. Il faut tenir compte du cas où il ne reste pas assez de place dans le tampon (auquel cas il faut vider le tampon), et aussi du cas où la chaîne est plus longue que le tampon (auquel cas il faut l’écrire directement). Voici une possibilité.
     
   let output_string chan s =
     let avail = String.length chan.out_buffer - chan.out_pos in
     if String.length s <= avail then begin
       String.blit s 0 chan.out_buffer chan.out_pos (String.length s);
       chan.out_pos <- chan.out_pos + String.length s
     end
     else if chan.out_pos = 0 then begin
       ignore (write chan.out_fd s 0 (String.length s))
     end
     else begin
       String.blit s 0 chan.out_buffer chan.out_pos avail;
       let out_buffer_size = String.length chan.out_buffer in
       ignore (write chan.out_fd chan.out_buffer 0 out_buffer_size);
       let remaining = String.length s - avail in
       if remaining < out_buffer_size then begin
         String.blit s avail chan.out_buffer 0 remaining;
         chan.out_pos <- remaining
       end else begin
         ignore (write chan.out_fd s avail remaining);
         chan.out_pos <- 0
       end
     end;;

2.12  Positionnement

L’appel système lseek permet de changer la position courante de lecture et d’écriture.

     
   val lseek : file_descr -> int -> seek_command -> int

Le premier argument est le descripteur qu’on veut positionner. Le deuxième argument est la position désirée. Il est interprété différemment suivant la valeur du troisième argument, qui indique le type de positionnement désiré:

SEEK_SETPositionnement absolu. Le deuxième argument est le numéro du caractère où se placer. Le premier caractère d’un fichier est à la position zéro.
SEEK_CURPositionnement relatif à la position courante. Le deuxième argument est un déplacement par rapport à la position courante. Il peut être négatif aussi bien que positif.
SEEK_ENDPositionnement relatif à la fin du fichier. Le deuxième argument est un déplacement par rapport à la fin du fichier. Il peut être négatif aussi bien que positif.

L’entier renvoyé par lseek est la position absolue du pointeur de lecture/écriture (après que le positionnement a été effectué).

Une erreur se déclenche si la position absolue demandée est négative. En revanche, la position demandée peut très bien être située après la fin du fichier. Juste après un tel positionnement, un read renvoie zéro (fin de fichier atteinte); un write étend le fichier par des zéros jusqu’à la position demandée, puis écrit les données fournies.

Exemple:

pour se placer sur le millième caractère d’un fichier:

     
   lseek fd 1000 SEEK_SET

Pour reculer d’un caractère:

     
   lseek fd (-1) SEEK_CUR

Pour connaître la taille d’un fichier:

     
   let file_size = lseek fd 0 SEEK_END in ...

Pour les descripteurs ouverts en mode O_APPEND, le pointeur de lecture/écriture est automatiquement placé à la fin du fichier avant chaque écriture. L’appel lseek ne sert donc à rien pour écrire sur un tel descripteur; en revanche, il est bien pris en compte pour la lecture.

Le comportement de lseek est indéterminé sur certains types de fichiers pour lesquels l’accès direct est absurde: les dispositifs de communication (tuyaux, prises), mais aussi la plupart des fichiers spéciaux (périphériques), comme par exemple le terminal. Dans la plupart des implémentations d’Unix, un lseek sur de tels fichiers est simplement ignoré: le pointeur de lecture/écriture est positionné, mais les opérations de lecture et d’écriture l’ignorent. Sur certaines implémentations, lseek sur un tuyau ou sur une prise déclenche une erreur.

Exercice 5   La commande tail affiche les N dernières lignes d’un fichier. Comment l’implémenter efficacement si le fichier en question est un fichier normal? Comment faire face aux autres types de fichiers? Comment ajouter l’option -f? (cf. man tail).
(Voir le corrigé)
L’implémentation naïve de tail est de lire le fichier séquentiellement, depuis le début, en gardant dans un tampon circulaire les N dernières lignes lues. Quand on atteint la fin du fichier, on affiche le tampon. Il n’y a rien de mieux à faire quand les données proviennent d’un tuyau ou d’un fichier spécial qui n’implémente pas lseek. Si les données proviennent d’un fichier normal, il vaut mieux lire le fichier en partant de la fin: avec lseek, on lit les 4096 derniers caractères; on les balaye pour compter les retours à la ligne; s’il y en a au moins N, on affiche les N lignes correspondantes et on sort; sinon, on recommence en ajoutant les 4096 caractères précédents, etc.

Pour ajouter l’option -f, il suffit, une fois qu’on a affiché les N dernières lignes, de se positionner à la fin du fichier, et d’essayer de lire (par read) à partir de là. Si read réussit à lire quelque chose, on l’affiche aussitôt et on recommence. Si read renvoie 0, on attend un peu (sleep 1), et on recommence.

2.13  Opérations spécifiques à certains types de fichiers

En Unix, la communication passe par des descripteurs de fichiers que ceux-ci soient matérialisés (fichiers, périphériques) ou volatiles (communication entre processus par des tuyaux ou des prises). Cela permet de donner une interface uniforme à la communication de données, indépendante du média. Bien sûr, l’implémentation des opérations dépend quant à elle du média. L’uniformité trouve ses limites dans la nécessité de donner accès à toutes les opérations offertes par le média. Les opérations générales (ouverture, écriture, lecture, etc.) restent uniformes sur la plupart des descripteurs mais certaines opérations ne fonctionnent que sur certains types de fichiers. En revanche, pour certains types de fichiers dits spéciaux, qui permettent de traiter la communication avec les périphériques, même les opérations générales peuvent avoir un comportement ad-hoc défini par le type et les paramètres du périphérique.

Fichiers normaux

On peut raccourcir un fichier ordinaire par les appels suivants:

     
   val truncate  : string -> int -> unit
   val ftruncate : file_descr -> int -> unit

Le premier argument désigne le fichier à tronquer (par son nom, ou via un descripteur ouvert sur ce fichier). Le deuxième argument est la taille désirée. Toutes les données situées à partir de cette position sont perdues.

Liens symboliques

La plupart des opérations sur fichiers “suivent” les liens symboliques: c’est-à-dire, elles s’appliquent au fichier vers lequel pointe le lien symbolique, et non pas au lien symbolique lui-même. Exemples: openfile, stat, truncate, opendir. On dispose de deux opérations sur les liens symboliques:

     
   val symlink  : string -> string -> unit
   val readlink : string -> string

L’appel symlink   f1   f2 créé le fichier f2 comme étant un lien symbolique vers f1. (Comme la commande ln -s   f1   f2.) L’appel readlink renvoie le contenu d’un lien symbolique, c’est-à-dire le nom du fichier vers lequel il pointe.

Fichiers spéciaux

Les fichiers spéciaux peuvent être de type caractère ou de type block. Les premiers sont des flux de caractères: on ne peut lire ou écrire les caractères que dans l’ordre. Ce sont typiquement les terminaux, les périphériques sons, imprimantes, etc. Les seconds, typiquement les disques, ont un support rémanent ou temporisé: on peut lire les caractères par blocs, voir à une certaine distance donnée sous forme absolue ou relative par rapport à la position courante. Parmi les fichiers spéciaux, on peut distinguer:

/dev/null
C’est le trou noir qui avale tout ce qu’on met dedans et dont il ne sort rien. Très utile pour ignorer les résultats d’un processus: on redirige sa sortie vers /dev/null (voir le chapitre 5).
/dev/tty*
Ce sont les terminaux de contrôle.
/dev/pty*
Ce sont les pseudo-terminaux de contrôle: ils ne sont pas de vrais terminaux mais les simulent (ils répondent à la même interface).
/dev/hd*
Ce sont les disques.
/proc
Sous Linux, permet de lire et d’écrire certains paramètres du système en les organisant comme un système de fichiers.

Les fichiers spéciaux ont des comportements assez variables en réponse aux appels système généraux sur fichiers. La plupart des fichiers spéciaux (terminaux, lecteurs de bandes, disques, …) obéissent à read et write de la manière évidente (mais parfois avec des restrictions sur le nombre d’octets écrits ou lus). Beaucoup de fichiers spéciaux ignorent lseek.

En plus des appels systèmes généraux, les fichiers spéciaux qui correspondent à des périphériques doivent pouvoir être paramétrés ou commandés dynamiquement. Exemples de telles possibilités: pour un dérouleur de bande, le rembobinage ou l’avance rapide; pour un terminal, le choix du mode d’édition de ligne, des caractères spéciaux, des paramètres de la liaison série (vitesse, parité, etc). Ces opérations sont réalisées en Unix par l’appel système ioctl qui regroupe tous les cas particuliers. Cependant, cet appel système n’est pas relevé en OCaml... parce qu’il est mal défini et ne peut pas être traité de façon uniforme.

Terminaux de contrôle

Les terminaux (ou pseudo-terminaux) de contrôle sont un cas particulier de fichiers spéciaux de type caractère pour lequel OCaml donne accès à la configuration. L’appel tcgetattr prend en argument un descripteur de fichier ouvert sur le fichier spécial en question et retourne une structure de type terminal_io qui décrit le statut du terminal représenté par ce fichier selon la norme POSIX (Voir page ?? pour une description complète).

     
   val tcgetattr : file_descr -> terminal_io
     
   type terminal_io =
     { c_ignbrk : boolc_brk_int : bool; ...;  c_vstop : char }

Cette structure peut être modifiée puis passée à la fonction tcsetattr pour changer les attributs du périphérique.

     
   val tcsetattr : file_descr -> setattr_when -> terminal_io -> unit

Le premier argument est le descripteur de fichier désignant le périphérique. Le dernier argument est une structure de type tcgetattr décrivant les paramètres du périphérique tels qu’on veut les établir. Le second argument est un drapeau du type énuméré setattr_when indiquant le moment à partir duquel la modification doit prendre effet: immédiatement (TCSANOW), après avoir transmis toutes les données écrites (TCSADRAIN) ou après avoir lu toutes les données reçues (TCAFLUSH). Le choix TCSADRAIN est recommandé pour modifier les paramètres d’écriture et TCSAFLUSH pour modifier les paramètres de lecture. Exemple:

Pendant la lecture d’un mot de passe, il faut retirer l’écho des caractères tapés par l’utilisateur si le flux d’entrée standard est connecté à un terminal ou pseudo-terminal.

     
   let read_passwd message =
     match
       try
         let default = tcgetattr stdin in
         let silent =
           { default with
             c_echo = false;
             c_echoe = false;
             c_echok = false;
             c_echonl = false;
           } in
         Some (defaultsilent)
       with _ -> None
     with
     | None -> input_line Pervasives.stdin
     | Some (defaultsilent) ->
         print_string message;
         flush Pervasives.stdout;
         tcsetattr stdin TCSANOW silent;
         try
           let s = input_line Pervasives.stdin in
           tcsetattr stdin TCSANOW defaults
         with x ->
           tcsetattr stdin TCSANOW defaultraise x;;

La fonction read_passwd commence par récupérer la valeur par défaut des paramètres du terminal associé à stdin et construire une version modifiée dans laquelle les caractères n’ont plus d’écho. En cas d’échec, c’est que le flux d’entrée n’est pas un terminal de contrôle, on se contente de lire une ligne. Sinon, on affiche un message, on change le terminal, on lit la réponse et on remet le terminal dans son état normal. Il faut faire attention à bien remettre le terminal dans son état normal également lorsque la lecture a échoué.

Il arrive qu’une application ait besoin d’en lancer une autre en liant son flux d’entrée à un terminal (ou pseudo terminal) de contrôle. Le système OCaml ne fournit pas d’aide pour cela3: il faut manuellement rechercher parmi l’ensemble des pseudo-terminaux (en général, ce sont des fichiers de nom de la forme /dev/tty[a-z][a-f0-9]) et trouver un de ces fichiers qui ne soit pas déjà ouvert, pour l’ouvrir puis lancer l’application avec ce fichier en flux d’entrée.

Quatre autres fonctions permettent de contrôler le flux (vider les données en attente, attendre la fin de la transmission, relancer la communication).

     
   val tcsendbreak : file_descr -> int -> unit

La fonction tcsendbreak envoie une interruption au périphérique. Son deuxième argument est la durée de l’interruption (0 étant interprété comme la valeur par défaut pour le périphérique).

     
   val tcdrain : file_descr -> unit

La fonction tcdrain attend que toutes les données écrites aient été transmises.

     
   val tcflush : file_descr -> flush_queue -> unit

Selon la valeur du drapeau passé en second argument, la fonction tcflush abandonne les données écrites pas encore transmises (TCIFLUSH), ou les données reçues mais pas encore lues (TCOFLUSH) ou les deux (TCIOFLUSH).

     
   val tcflow : file_descr -> flow_action -> unit

Selon la valeur du drapeau passé en second argument, la fonction tcflow suspend l’émission (TCOOFF), redémarre l’émission (TCOON), envoie un caractère de contrôle STOP ou START pour demander que la transmission soit suspendue (TCIOFF) ou relancée (TCION).

     
   val setsid : unit -> int

La fonction setsid place le processus dans une nouvelle session et le détache de son terminal de contrôle.

2.14  Verrous sur des fichiers

Deux processus peuvent modifier un même fichier en parallèle au risque que certaines écritures en écrasent d’autres. Dans certains cas, l’ouverture en mode O_APPEND permet de s’en sortir, par exemple, pour un fichier de log où on se contente d’écrire des informations toujours à la fin du fichier. Mais ce mécanisme ne résout pas le cas plus général où les écritures sont à des positions a priori arbitraires, par exemple, lorsqu’un fichier représente une base de données . Il faut alors que les différents processus utilisant ce fichier collaborent ensemble pour ne pas se marcher sur les pieds. Un verrouillage de tout le fichier est toujours possible en créant un fichier verrou auxiliaire (voir page ??). L’appel système lockf permet une synchronisation plus fine qui en ne verrouillant qu’une partie du fichier.

2.15  Exemple complet: copie récursive de fichiers

On va étendre la commande file_copy pour copier, en plus des fichiers normaux, les liens symboliques et les répertoires. Pour les répertoires, on copie récursivement leur contenu.

On commence par récupérer la fonction file_copy de l’exemple du même nom pour copier les fichiers normaux (page ??).

     
   open Unix
     
   ...
     
   let file_copy input_name output_name =
     
   ...

La fonction set_infos ci-dessous modifie le propriétaire, les droits d’accès et les dates de dernier accès/dernière modification d’un fichier. Son but est de préserver ces informations pendant la copie.

     
   let set_infos filename infos =
     utimes filename infos.st_atime infos.st_mtime;
     chmod filename infos.st_perm;
     try
       chown filename infos.st_uid infos.st_gid
     with Unix_error(EPERM,_,_) ->
       ()

L’appel système utime modifie les dates d’accès et de modification. On utilise chmod et chown pour rétablir les droits d’accès et le propriétaire. Pour les utilisateurs normaux, il y a un certain nombres de cas où chown va échouer avec une erreur “permission denied”. On rattrape donc cette erreur là et on l’ignore.

Voici la fonction récursive principale.

     
   let rec copy_rec source dest =
     let infos = lstat source in
     match infos.st_kind with
       S_REG ->
         file_copy source dest;
         set_infos dest infos
     | S_LNK ->
         let link = readlink source in
         symlink link dest
     | S_DIR ->
         mkdir dest 0o200;
         Misc.iter_dir
           (fun file ->
             if file <> Filename.current_dir_name
                 && file <> Filename.parent_dir_name
             then
               copy_rec
                 (Filename.concat source file)
                 (Filename.concat dest file))
           source;
         set_infos dest infos
     | _ ->
         prerr_endline ("Can't cope with special file " ^ source)

On commence par lire les informations du fichier source. Si c’est un fichier normal, on copie son contenu avec file_copy, puis ses informations avec set_infos. Si c’est un lien symbolique, on lit ce vers quoi il pointe, et on crée un lien qui pointe vers la même chose. Si c’est un répertoire, on crée un répertoire comme destination, puis on lit les entrées du répertoire source (en ignorant les entrées du répertoire vers lui-même Filename.current_dir_name et vers son parent Filename.parent_dir_name, qu’il ne faut certainement pas copier), et on appelle récursivement copy pour chaque entrée. Les autres types de fichiers sont ignorés, avec un message d’avertissement.

Le programme principal est sans surprise:

     
   let copyrec () =
     if Array.length Sys.argv <> 3 then begin
       prerr_endline ("Usage: " ^Sys.argv.(0)^ " <source> <destination>");
       exit 2
     end else begin
       copy_rec Sys.argv.(1) Sys.argv.(2);
       exit 0
     end
   ;;
   handle_unix_error copyrec ();;
Exercice 6   Copier intelligemment les liens durs. Tel que présenté ci-dessus, copyrec duplique N fois un même fichier qui apparaît sous N noms différents dans la hiérarchie de fichiers à copier. Essayer de détecter cette situation, de ne copier qu’une fois le fichier, et de faire des liens durs dans la hiérarchie de destination.
(Voir le corrigé)
Il faut garder une table des fichiers source déjà copiés, qui au couple (st_dev, st_ino) d’un fichier source fait correspondre le nom de son fichier destination. À chaque copie, on consulte cette table pour voir si un fichier source avec le même couple (st_dev, st_ino) a déjà été copié; si oui, on fait un lien dur vers le fichier destination, au lieu de refaire la copie. Pour diminuer la taille de cette table, on peut n’y mettre que des fichiers qui ont plusieurs noms, c’est-à-dire tels que st_nlink > 1.
     
   let copied_files = (Hashtbl.create 53 : ((int * int), stringHashtbl.t)
   
   let rec copy source dest =
     let infos = lstat source in
     match infos.st_kind with
       S_REG ->
         if infos.st_nlink > 1 then begin
           try
             let dest' =
               Hashtbl.find copied_files (infos.st_devinfos.st_ino)
             in link destdest
           with Not_found ->
             Hashtbl.add copied_files (infos.st_devinfos.st_inodest;
             file_copy source dest;
             set_infos dest infos
         end else begin
           file_copy source dest;
           set_infos dest infos
         end
     
     | S_LNK -> ...

2.16  Exemple: Tape ARchive

Le format tar (pour tape archive) permet de représenter un ensemble de fichiers en un seul fichier. (Entre autre il permet de stocker toute une hiérarchie de fichiers sur une bande.) C’est donc d’une certaine façon un mini système de fichiers.

Dans cette section nous décrivons un ensemble de fonctions qui permettent de lire et d’écrire des archives au format tar. La première partie, décrite complètement, consiste à écrire une commande readtar telle que readtar a affiche la liste des fichiers contenus dans l’archive a et readtar a f affiche le contenu du fichier f contenu dans l’archive a. Nous proposons en exercice l’extraction de tous les fichiers contenus dans une archive, ainsi que la fabrication d’une archive à partir d’un ensemble de fichiers.

Description du format

Une archive tar est une suite d’enregistrements, chaque enregistrement représentant un fichier. Un enregistrement est composé d’un entête qui code les informations sur le fichier (son nom, son type, sa taille, son propriétaire etc.) et du contenu du fichier. L’entête est représenté sur un bloc (512 octets) comme indiqué dans le tableau 2.2.


OffsetLength1Codage2  Nom  Description
0  100  chaîne  name  Nom du fichier
100  8  octal  perm  Mode du fichier
108  8  octal  uid  ID de l’utilisateur
116  8  octal  gid  ID du groupe de l’utilisateur
124  12  octal  size  Taille du fichier3
136  12  octal  mtime  Date de la dernière modification
148  8  octal  checksum  Checksum de l’entête
156  1  caractère  kind  Type de fichier
157  100  octal  link  Lien
257  8  chaîne  magic  Signature ("ustar\032\032\0")
265  32  chaîne  user  Nom de l’utilisateur
297  32  chaîne  group  Nom du groupe de l’utilisateur
329  8  octal  major  Identificateur majeur du périphérique
337  8  octal  minor  Identificateur mineur du périphérique
345  167       Padding
1 en octets.
2
tous les champs sont codés sur des chaînes de caractères et
terminés par le caractère nul ’\000’, sauf les champs kind (Type de fichier) et
le champ size (Taille du fichier) (’\000’ optionnel).
  
Tableau 2.2: Représentation de l’entête

Le contenu est représenté à la suite de l’entête sur un nombre entier de blocs. Les enregistrements sont représentés les uns à la suite des autres. Le fichier est éventuellement complété par des blocs vides pour atteindre au moins 20 blocs.

Comme les archives sont aussi conçues pour être écrites sur des supports fragiles et relues plusieurs années après, l’entête comporte un champ checksum qui permet de détecter les archives dont l’entête est endommagé (ou d’utiliser comme une archive un fichier qui n’en serait pas une.) Sa valeur est la somme des codes des caractères de l’entête (pendant ce calcul, on prend comme hypothèse que le le champ checksum, qui n’est pas encore connu est composé de blancs et terminé par le caractère nul).

Le champ kind représente le type des fichiers sur un octet. Les valeurs significatives sont les caractères indiqués dans le tableau ci-dessous4:

’\0’ ou ’0’’1’’2’’3’’4’’5’’6’’7’
REGLINKLNKCHRBLKDIRFIFOCONT

La plupart des cas correspondent au type st_link des types de fichier Unix. Le cas LINK représente des liens durs: ceux-ci ont le même nœud (inode) mais accessible par deux chemins différents; dans ce cas, le lien doit obligatoirement mener à un autre fichier déjà défini dans l’archive. Le cas CONT représente un fichier ordinaire, mais qui est représenté par une zone mémoire contigüe (c’est une particularité de certains systèmes de fichiers, on pourra donc le traiter comme un fichier ordinaire). Le champ link représente le lien lorsque kind vaut LNK ou LINK. Les champs major et minor représentent les numéros majeur et mineur du périphérique dans le cas où le champ kind vaut CHR ou BLK. Ces trois champs sont inutilisés dans les autres cas.

La valeur du champ kind est naturellement représentée par un type concret et l’entête par un enregistrement:

     
   open Sys
   open Unix
   
   type kind =
     | REG
     | LNK of string
     | LINK of string
     | CHR of int * int
     | BLK of int * int
     | DIR
     | FIFO
     | CONT
     
   type header = {
       name : string;
       perm : int;
       uid : int;
       gid : int;
       size : int;
       mtime : int;
       kind : kind;
       user : string;
       group : string
    }
Lecture d’un entête

La lecture d’un entête n’est pas très intéressante, mais elle est incontournable.

     
   exception Error of string * string
   let error err mes = raise (Error (errmes));;
   let handle_error f s =
     try f s with
     | Error (errmes) ->
         Printf.eprintf "Error: %s: %s" err mes;
         exit 2
   
   let substring s offset len =
     let max_length = min (offset + len + 1) (String.length sin
     let rec real_length j =
       if j < max_length && s.[j] <> '\000' then real_length (succ j)
       else j - offset in
     String.sub s offset (real_length offset);;
   
   let integer_of_octal nbytes s offset =
     let i = int_of_string ("0o" ^ substring s offset nbytesin
     if i < 0 then error "Corrupted archive" "integer too large" else i;;
   
   let kind s i =
     match s.[iwith
       '\000' | '0' -> REG
     | '1' -> LINK (substring s (succ i) 99)
     | '2' -> LNK (substring s (succ i) 99)
     | '3' -> CHR (integer_of_octal 8 s 329, integer_of_octal 8 s 329)
     | '4' -> BLK (integer_of_octal 8 s 329, integer_of_octal 8 s 337)
     | '5' -> DIR | '6' -> FIFO | '7' -> CONT
     | _ -> error "Corrupted archive" "kind"
   
   let header_of_string s =
     { name = substring s 0 99;
       perm = integer_of_octal 8 s 100;
       uid = integer_of_octal 8 s 108;
       gid = integer_of_octal 8 s 116;
       size = integer_of_octal 12 s 124;
       mtime = integer_of_octal 12 s 136;
       kind = kind s 156;
       user = substring s 265 32;
       group = substring s 297 32;
     }
   
   let block_size = 512;;
   let total_size size =
     block_size + ((block_size -1 + size) / block_size) * block_size;;

La fin de l’archive s’arrête soit sur une fin de fichier là ou devrait commencer un nouvel enregistrement, soit sur un bloc complet mais vide. Pour lire l’entête, nous devons donc essayer de lire un bloc, qui doit être vide ou complet. Nous réutilisons la fonction really_read définie plus haut pour lire un bloc complet. La fin de fichier ne doit pas être rencontrée en dehors de la lecture de l’entête.

     
   let buffer_size = block_size;;
   let buffer = String.create buffer_size;;
   
   let end_of_file_error() =
     error "Corrupted archive" "unexpected end of file"
   let without_end_of_file f x =
     try f x with End_of_file -> end_of_file_error()
   
   let read_header fd =
     let len = read fd buffer 0 buffer_size in
     if len = 0 ||  buffer.[0] = '\000' then None
     else begin
       if len < buffer_size then
         without_end_of_file (really_read fd buffer len) (buffer_size - len);
       Some (header_of_string buffer)
     end;;
Lecture d’une archive

Pour effectuer une quelconque opération dans une archive, il est nécessaire de lire l’ensemble des enregistrements dans l’ordre au moins jusqu’à trouver celui qui correspond à l’opération à effectuer. Par défaut, il suffit de lire l’entête de chaque enregistrement, sans avoir à en lire le contenu. Souvent, il suffira de lire le contenu de l’enregistrement recherché ou de lire le contenu après coup d’un enregistrement le précédent. Pour cela, il faut garder pour chaque enregistrement une information sur sa position dans l’archive, en plus de son entête. Nous utilisons le type suivant pour les enregistrements:

     
   type record = { header : headeroffset : intdescr : file_descr };;

Nous allons maintenant écrire un itérateur général qui lit les enregistrements (sans leur contenu) et les accumulent. Toutefois, pour être général, nous restons abstrait par rapport à la fonction d’accumulation f (qui peut aussi bien ajouter les enregistrements à ceux déjà lus, les imprimer, les jeter, etc.)

     
   let fold f initial fd  =
     let rec fold_aux offset accu =
       ignore (without_end_of_file (lseek fd offsetSEEK_SET);
       match without_end_of_file read_header fd with
         Some h ->
           let r =
             { header = hoffset = offset + block_sizedescr = fd } in
           fold_aux (offset + total_size h.size) (f r accu)
       | None -> accu in
     fold_aux 0 initial;;

Une étape de fold_aux commence à une position offset avec un résultat partiel accu. Elle consiste à se placer à la position offset, qui doit être le début d’un enregistrement, lire l’entête, construire l’enregistrement r puis recommencer à la fin de l’enregistrement avec le nouveau résultat f r accu (moins partiel). On s’arrête lorsque l’entête est vide, ce qui signifie qu’on est arrivé à la fin de l’archive.

Affichage de la liste des enregistrements d’une archive

Il suffit simplement d’afficher l’ensemble des enregistrements, au fur et à mesure, sans avoir à les conserver:

     
   let list tarfile =
     let fd = openfile tarfile [ O_RDONLY ] 0o0 in
     let add r () = print_string r.header.nameprint_newline() in
     fold add () fd;
     close fd
Affichage du contenu d’un fichier dans une archive

La commande readtar  a  f doit rechercher le fichier de nom f dans l’archive et l’afficher si c’est un fichier régulier. De plus un chemin f de l’archive qui est un lien dur et désigne un chemin g de l’archive est suivi et le contenu de g est affiché: en effet, bien que f et g soient représentés différemment dans l’archive finale (l’un est un lien dur vers l’autre) ils désignaient exactement le même fichier à sa création. Le fait que g soit un lien vers f ou l’inverse dépend uniquement de l’ordre dans lequel les fichiers ont été parcourus à la création de l’archive. Pour l’instant nous ne suivons pas les liens symboliques.

L’essentiel de la résolution des liens durs est effectué par les deux fonctions suivantes, définies récursivement.

     
   let rec find_regular r list =
     match r.header.kind with
     | REG | CONT -> r
     | LINK name -> find_file name list
     | _ -> error r.header.name "Not a regular file"
   and find_file name list =
     match list with
       r :: rest ->
         if r.header.name = name then find_regular r rest
         else find_file name rest
     | [] -> error name "Link not found (corrupted archive)";;

La fonction find_regular trouve le fichier régulier correspondant à un enregistrement (son premier) argument. Si celui-ci est un fichier régulier, c’est gagné. Si c’est un fichier spécial (ou un lien symbolique), c’est perdu. Il reste le cas d’un lien dur: la fonction recherche ce lien dans la liste des enregistrements de l’archive (deuxième argument) en appelant la fonction find_file.

Un fois l’enregistrement trouvé il n’y a plus qu’à afficher son contenu. Cette opération ressemble fortement à la fonction file_copy, une fois le descripteur positionné au début du fichier dans l’archive.

     
   let copy_file file output =
     ignore (lseek file.descr file.offset SEEK_SET);
     let rec copy_loop len =
       if len > 0 then
         match read file.descr buffer 0 (min buffer_size lenwith
           0 -> end_of_file_error()
         | r -> ignore (write output buffer 0 r); copy_loop (len-rin
     copy_loop file.header.size

Il ne reste plus qu’à combiner les trois précédents.

     
   exception Done
   let find_and_copy tarfile filename =
     let fd = openfile tarfile [ O_RDONLY ] 0o0 in
     let found_or_collect r accu =
       if r.header.name = filename then begin
         copy_file (find_regular r accustdout;
         raise Done
       end else r :: accu in
     try
        ignore (fold found_or_collect [] fd);
        error "File not found" filename
     with
     | Done -> close fd

On lit les enregistrements de l’archive (sans lire leur contenu) jusqu’à rencontrer un enregistrement du nom recherché. On appelle la fonction find_regular pour rechercher dans la liste des enregistrements lus celui qui contient vraiment le fichier. Cette seconde recherche, en arrière, doit toujours réussir si l’archive est bien formée. Par contre la première recherche, en avant, va échouer si le fichier n’est pas dans l’archive. Nous avons pris soin de distinguer les erreurs dues à une archive corrompue ou à une recherche infructueuse.

Et voici la fonction principale qui réalise la commande readtar:

     
   let readtar () =
     let nargs = Array.length Sys.argv in
     if nargs = 2 then list Sys.argv.(1)
     else if nargs = 3 then find_and_copy Sys.argv.(1) Sys.argv.(2)
     else
       prerr_endline ("Usage: " ^Sys.argv.(0)^ " <tarfile> [ <source> ]");;
   
   handle_unix_error (handle_error readtar) ();;
Exercice 7   Étendre la commande readtar pour qu’elle suive les liens symboliques, c’est-à-dire pour qu’elle affiche le contenu du fichier si l’archive avait été au préalable extraite et si ce fichier correspond à un fichier de l’archive.

Derrière l’apparence triviale de cette généralisation se cachent quelques difficultés, car les liens symboliques sont des chemins quelconques qui peuvent ne pas correspondre exactement à des chemins de l’archive ou carrément pointer en dehors de l’archive (ils peuvent contenir . . ). De plus, les liens symboliques peuvent désigner des répertoires (ce qui est interdit pour les liens durs).

(Voir le corrigé)

Une solution assez simple consiste à simuler l’effet de l’extraction en créant dans un graphe une image de la hiérarchie des fichiers contenus dans l’archive.

     
   type info = File | Link of string list | Dir of (string * inodelist
   and inode = { mutable record : record optionmutable info : info;}

Les nœuds du système de fichier virtuel dont décrits par le type inode. Le champ info décrit le type de fichier en se limitant aux fichiers ordinaires, liens symboliques et répertoires. Les chemins sont représentés par des listes de chaînes de caractères et les répertoires par des listes qui associent un nœud à chaque nom de fichiers du répertoire. Le champ record le fichier associé au nœud dans l’archive. Ce champ est optionnel, car les répertoires intermédiaires ne sont pas toujours décrits dans l’archive; il est mutable, car un fichier peut apparaître plusieurs fois dans l’archive, et les dernières informations sont prioritaires.

     
   let root () =
     let rec i =
       { record = Noneinfo = Dir [ Filename.current_dir_namei ] }
     in i
   let link inode name nod =
     match inode.info with
     | File | Link _ -> error name "Not a directory"
     | Dir list ->
         try let _ = List.assoc name list in error name "Already exists"
         with Not_found -> inode.info <- Dir ((namenod) :: list)
   
   let mkfile inode name r =
     let f =  { record = rinfo = File } in
     link inode name ff
   let symlink inode name r path =
     let s =  { record = rinfo = Link path } in
     link inode name ss
   let mkdir inode name r =
     let d = mkfile inode name r in
     d.info <-
       Dir [ Filename.current_dir_namedFilename.parent_dir_nameinode ];
     d

Comme en Unix, chaque répertoire contient un lien vers lui-même et un lien vers son parent, sauf le répertoire racine (contrairement à Unix où il est son propre parent). Ce choix nous permet de détecter et d’interdire l’accès en dehors de l’archive très simplement.

     
   let rec find link inode path =
     match inode.infopath with
     | _, [] -> inode
     | Dir listname :: rest ->
         let subnode = List.assoc name list in
         let subnode =
           match subnode.info with
             Link q ->
               if link && rest = [] then subnode else find false inode q
           | _ -> subnode  in
         find link subnode rest
     | __ -> raise Not_found;;

La fonction find effectue une recherche dans l’archive à partir d’un nœud initial inode en suivant le chemin path. Le drapeau link indique si dans le cas où le résultat est un lien symbolique il faut retourner le lien lui-même (true) ou le fichier pointé par le lien (false).

     
   let rec mkpath inode path =
     match inode.infopath with
     | _, [] -> inode
     | Dir listname :: rest ->
         let subnode =
           try List.assoc name list
           with Not_found ->  mkdir inode name None in
         mkpath subnode rest
     | __ -> raise Not_found;;

La fonction mkpath parcourt le chemin path en créant les nœuds manquant le long du chemin.

     
   let explode f =
     let rec dec f p =
       if f = Filename.current_dir_name then p
       else dec (Filename.dirname f) (Filename.basename f :: pin
     dec (if Filename.basename f = "" then Filename.dirname f else f) [];;

La fonction explode décompose un chemin Unix en une liste de chaînes de caractères. Elle retire le “"/"” final qui est toléré dans les archives pour les noms de répertoires.

     
   let add archive r =
     match r.header.kind with
     | CHR (_,_) | BLK (_,_) | FIFO -> ()
     | kind ->
         match List.rev (explode r.header.namewith
         | []  -> ()
         | name :: parent_rev ->
             let inode = mkpath archive (List.rev parent_revin
             match kind with
             | DIR -> ignore (mkdir inode name (Some r))
             | REG | CONT -> ignore (mkfile inode name (Some r))
             | LNK f -> ignore (symlink inode name (Some r) (explode f))
             | LINK f -> link inode name (find true archive (explode f))
             | _ -> assert false;;

La fonction add ajoute l’enregistrement r dans l’archive. L’archive représentée par sa racine est modifiée par effet de bord.

     
   let find_and_copy tarfile filename =
     let fd = openfile tarfile [ O_RDONLY ] 0 in
     let records = List.rev (fold (fun x y -> x :: y) [] fdin
     let archive = root() in
     List.iter (add archiverecords;
     let inode =
       try find false archive (explode filename)
       with Not_found -> error filename "File not found" in
     begin match inode.record with
     | Some ({ header = { kind = (REG | CONT) }} as r) -> copy_file r stdout
     | Some _ -> error filename "Not a regular file"
     | None -> error filename "Not found"
     end;
     close fd;;

On termine comme précédemment.

Exercice 8   Écrire une commande untar telle que untar a extrait et crée tous les fichiers de l’archive a (sauf les fichiers spéciaux) en rétablissant si possible les informations sur les fichiers (propriétaires, droits d’accès) indiqués dans l’archive.

L’arborescence de l’archive ne doit contenir que des chemins relatifs, sans jamais pointer vers un répertoire parent (donc sans pourvoir pointer en dehors de l’archive), mais on devra le détecter et refuser de créer des fichiers ailleurs que dans un sous-répertoire du répertoire courant. L’arborescence est reconstruite à la position où l’on se trouve à l’appel de la commande untar. Les répertoires non mentionnés explicitement dans l’archive qui n’existent pas sont créés avec les droits par défaut de l’utilisateur qui lance la commande.

(Voir le corrigé)

Cet exercice combine l’exercice précédent (exercice 7) et la copie récursive de fichiers (exercice 6).

La seule petite difficulté est la gestion des droits: il faut créer les répertoires de l’archive avec les droits en écriture et ne mettre ceux-ci à leur valeur final qu’après extractions de tous les fichiers.

Écrivons d’abord une fonction annexe pour mkpath p m qui les directory manquant le long du chemin p avec les permissions m (avec la particularité que p peut-être terminé par une / superflu).

     
   let warning mes = prerr_string mes;prerr_newline();;
   open Filename
   let mkpath p perm =
     let normal_path =
       if basename p = "" then dirname p else p in
     let path_to_dir = dirname normal_path in
     let rec make p =
       try ignore (stat p)
       with Unix_error (ENOENT__) ->
         if p = current_dir_name then ()
         else if p = parent_dir_name then
           warning "Ill formed archive: path contains \"..\""
         else begin
           make (dirname p);
           mkdir p perm
         end in
     make path_to_dir;;

Nous définissons également une fonction set_infos analogue à la version utilisée pour la copie de fichiers (section 2.15):

     
   let set_infos header =
     chmod header.name header.perm;
     let mtime = float header.mtime in
     utimes header.name mtime mtime;
     begin match header.kind with
       LNK f -> ()
     | _ ->  chmod header.name header.perm
     end;
     try chown header.name  header.uid header.gid
     with Unix_error(EPERM,_,_) -> ();;

Le corps du programme est la fonction untar_file_collect_dirs qui traite une seule entrée en accumulant les répertoires explicitement créés par l’archive.

     
   let verbose = ref true;;
   let default_dir_perm = 0o777;;
   let default_file_perm = 0o666;;
   
   let protect f x g y = try f xg y with z -> g yraise z
   let file_exists f = try ignore (stat f); true with _ -> false;;
   
   let untar_file_collect_dirs file dirs =
     let fh = file.header in
     if !verbose then begin print_string fh.nameprint_newline() end;
     match fh.kind with
     | CHR (_,_) | BLK(_,_) | FIFO ->
         warning (fh.name ^ "Ignoring special files");
         dirs
     | DIR ->
         mkpath fh.name default_dir_perm;
         if file_exists fh.name then dirs
         else begin mkdir fh.name default_dir_permfh :: dirs end
     | x ->
         mkpath fh.name default_dir_perm;
         begin match x with
         | REG | CONT ->
             let flags = [ O_WRONLYO_TRUNCO_CREAT; ] in
             let out = openfile fh.name flags default_file_perm in
             protect (copy_file fileout close out
         | LNK f ->
             symlink f fh.name
         | LINK f ->
             begin
               try if (stat fh.name).st_kind = S_REG then unlink fh.name
               with Unix_error(_,_,_) -> ();
             end;
             Unix.link f fh.name;
         | _ -> assert false
         end;
         set_infos fh;
         dirs;;

Nous omettons la fin du programme qui consiste à itérer le corps du programme sur l’archive puis à mettre à jour les droits des répertoires en tout dernier.

Exercice 9 (Projet)   Écrire une commande tar telle que tar -xvf a f1 f2 … construise l’archive a à partir de la liste des fichiers f1, f2, etc. et de leurs sous-répertoires.
(Voir le corrigé)
Nous réutilisons les structures de données définies ci-dessus dans la bibliothèque Tarlib. Nous ajoutons au report d’erreurs les messages d’avertissement qui n’arrêtent pas l’archivage et n’altère pas le code de retour.
     
   open Sys
   open Unix
   open Tarlib
   
   let warning path message =  prerr_endline (path ^ ": " ^ message)
Commençons par l’écriture d’un entête de type header dans un buffer de taille suffisante (donc au moins la taille d’un bloc). L’écriture de cette fonction est ennuyante, mais doit être faite avec soin car une seule erreur dans l’écriture de l’entête peut corrompre toute l’archive, comme bien souvent en informatique. On fera attention en particulier de respecter les limites imposées dans l’archivage. Par exemple, la taille des chemins est limitée à 99 octets. Il existe des extensions du format des archives qui permettent de traiter des chemins de taille plus longue, mais ce n’est pas le but du projet.
     
   let write_header_to_buffer source infos kind =
     let size = if kind = REG then infos.st_size else 0 in
     String.fill buffer 0 block_size '\000';
     let put len string offset =
       String.blit string 0 buffer offset (min (String.length stringlenin
     let put_int8 x = put 7 (Printf.sprintf "%07o" xin
     let put_int12 x = put 11 (Printf.sprintf "%011o" xin
     let put_char c offset = buffer.[offset] <- c in
     let put_path s offset =
       if String.length s <= 99 then put 99 s offset
       else raise (Error ("path too long"s)) in
     put_path (if kind = DIR then source ^ "/" else source) 0;
     put_int8 infos.st_perm 100;
     put_int8 infos.st_uid 108;
     put_int8 infos.st_gid 116;
     put_int12 size 124;
     put_int12 (int_of_float infos.st_mtime) 136;
     put 7 "ustar  " 257;
     put 31 (getpwuid infos.st_uid).pw_name 265;
     put 31 (getgrgid infos.st_gid).gr_name 297;
     (* Fields dev and rdev are only used for special files, which we omit *)
     put_char
       begin match kind with
       | REG -> '0'
       | LINK s -> put_path s 157; '1'
       | LNK s ->  put_path s 157; '2'
       | DIR -> '5'
       | _ -> failwith "Special files not implemented"
       end 156;
     let rec sum s i =
       if i < 0 then s else sum (s + Char.code buffer.[i]) (pred iin
     let checksum = sum (Char.code ' ' * 8) (block_size - 1)  in
     put 8 (Printf.sprintf "%06o\000 " checksum) 148;;
À l’inverse, nous créons un entête à partir d’une entrée d’une archive: source est le nom du fichiers; infos sont les informations sur le fichier (de type stats) et kind est le type du fichier dans l’archive (type Tarlib.kind).
     
   let header source infos kind = {
     name = source;
     size = if kind = REG then infos.st_size else 0;
     perm = infos.st_perm;
     mtime = int_of_float infos.st_mtime;
     uid = infos.st_uid;
     gid = infos.st_gid;
     user = (getpwuid infos.st_uid).pw_name;
     group = (getgrgid infos.st_gid).gr_name;
     kind = kind }
Pour écrire un fichier dans l’archive, nous définissons une variante de file_copy qui prend en argument le nombre d’octets à copier et vérifie que la fin de fichier correspond bien à la taille indiquée. Sinon, une erreur est levée: celle-ci correspond à un cas pathologique où le fichier est en train d’être modifié pendant l’archivage. On prend soin de ne jamais dépasser la taille indiquée, ce qui limitera la corruption d’une archive à un seul fichier.
     
   let write_file len source fdout =
     let fdin = openfile source [O_RDONLY] 0 in
     let error () = raise (Error ("File changed size"source)) in
     let rec copy_loop len =
       match read fdin buffer 0 buffer_size with
         0 ->
           close fdinif len > 0 then error ()
       | r ->
           let len = len - r  in
           if len < 0 then (close fdinerror());
           ignore (write fdout buffer 0 r); copy_loop len in
     copy_loop len;;
   
   let padding fd len =
     if len > 0 then ignore (write fd (String.make len '\000') 0 len);;
Nous pouvons maintenant nous attaquer à la lecture de la création de l’archive. Les fichiers déjà enregistrés dans l’archive sont mémorisés dans une table de hache avec leur chemin dans l’archive afin de ne les recopier qu’une seule fois. Nous allons mémoriser également les répertoires déjà enregistrés afin de ne pas les recopier à nouveau: en effet il peut arriver qu’une racine d’archivage soit déjà contenue dans une autre. On évitera de la recopier (bien qu’il ne serait pas grave de le faire).

Une archive en cours d’écriture est donc identifiée par le descripteur dans lequel elle est écrite et ses deux caches. Nous ajoutons un champ qui maintient la taille de l’archive, afin de pouvoir compléter celle-ci pour atteindre une taille minimale.

     
   type archive =
       { regfiles : (int * intstringHashtbl.t;
         dirfiles : (int * intboolHashtbl.t;
         fd : file_descrst : statsmutable size : int }
   
   let try_new_dir archive dir =
     try Hashtbl.find archive.dirfiles dir
     with Not_found -> Hashtbl.add archive.dirfiles dir falsetrue

Voici la fonction principale qui écrit toute une arborescence à partir d’une entrée dans l’archive file passée sur la ligne de commande. Cette fonction n’est pas difficile, mais il faut prendre quelques précautions par rapport aux cas pathologiques. En particulier, nous allons vu comment détecter le cas fichier en train d’être modifié pendant son archivage. Un sous-cas de celui-ci est lorsque l’archive est elle-même est en train d’être archivée...

     
   let verbose = ref true;;
   
   let write_from archive file =
     if not (Filename.is_relative filethen
       raise (Error ("absolute path"file));
     let rec write_rec archive file =
       let source =
         if Filename.basename file = "" then Filename.dirname file else file in
       if !verbose then begin prerr_endline source end;
       let st = lstat source in
       if st.st_ino = archive.st.st_ino && st.st_dev = archive.st.st_dev
       then warning source "Skiping archive itself!"
       else
         let write_header kind =
           write_header_to_buffer source st kind;
           ignore (write archive.fd buffer 0 block_sizein
         match st.st_kind with
           S_REG ->
             begin try
               if st.st_nlink = 1 then raise Not_found;
               let path =
                 Hashtbl.find archive.regfiles (st.st_inost.st_devin
               write_header (LINK path);
             with Not_found ->
               if st.st_nlink > 1 then
                 Hashtbl.add archive.regfiles (st.st_inost.st_devsource;
               write_header REG;
               write_file st.st_size source archive.fd;
               let t =
                 (block_size-1 + st.st_size) / block_size * block_size in
               padding archive.fd (t - st.st_size);
               archive.size <- archive.size + t + block_size;
             end
         | S_LNK ->
             write_header (LNK (readlink source));
         | S_DIR when try_new_dir archive (st.st_inost.st_dev) ->
             write_header DIR;
             Misc.iter_dir
               begin
                 fun file ->
                   if file = Filename.current_dir_name then ()
                   else if file = Filename.parent_dir_name then ()
                   else write_rec archive (source ^ "/" ^ file)
               end
               source
         | S_DIR ->
             warning source "Ignoring directory already in archive."
         | _ ->
             prerr_endline ("Can't cope with special file " ^ sourcein
     write_rec archive file;;

Nous mémorisons les fichiers réguliers qui peuvent avoir de liens durs dans la table regfiles. Ce n’est pas nécessaire pour les fichiers qui n’ont qu’un seul lien (lignes 91 et 96).

Il ne reste plus qu’à finir le programme. En cas d’erreur, il est plus prudent de retirer l’archive erronée.

     
   let min_archive_size = 20 * block_size;;
   
   let build tarfile files =
     let fdremove =
       if tarfile = "-" then stdoutignore
       else openfile tarfile [ O_WRONLYO_CREATO_TRUNC ] 0o666unlink in
     try
       let arch =
            { regfiles = Hashtbl.create 13; dirfiles = Hashtbl.create 13;
              st = fstat fdfd = fdsize =0 } in
       Array.iter (write_from archfiles;
       padding fd (min_archive_size - arch.size);
       close fd
     with z ->
       remove tarfileclose fdraise z;;

Pour terminer il ne reste plus qu’à analyser la ligne de commande.

     
   let usage() =
     prerr_endline "Usage: tar -cvf tarfile file1 [ file2 ... ] ";
     exit 2;;
   
   let tar () =
     let argn = Array.length Sys.argv in
     if argn > 3 && Sys.argv.(1) = "-cvf" then
       build Sys.argv.(2) (Array.sub Sys.argv 3 (argn-3))
     else usage();;
   
   let _ =
     try handle_unix_error tar ()
     with Error (mess) ->
       prerr_endline ("Error: " ^ mes ^ ": " ^ s); exit 1;;

1
Ce n’est pas possible si le fichier verrou réside sur une partition NFS, car NFS n’implémente pas correctement l’option O_CREAT de open.
2
En fait, OCaml limite la tailles des données transférées à 16K (dans la version courante) en répétant plusieurs appels système write pour effectuer le transfert complet—voir la discussion la section 5.7. Mais cette limite est au delà de la taille des caches du système et n’est pas observable.
3
La bibliothèque de Cash [3] fournit de telles fonctions.
4
Ce champ peut également prendre d’autres valeurs pour coder des cas pathologiques, par exemple lorsque la valeur d’un champ déborde la place réservée dans l’entête, ou dans des extensions de la commande tar.

Chapter 3  Les processus

Un processus est un programme en train de s’exécuter. Un processus se compose d’un texte de programme (du code machine) et d’un état du programme (point de contrôle courant, valeur des variables, pile des retours de fonctions en attente, descripteurs de fichiers ouverts, etc.)

Cette partie présente les appels systèmes Unix permettant de créer de nouveaux processus et de leur faire exécuter d’autres programmes.

3.1  Création de processus

L’appel système fork permet de créer un processus.

     
   val fork : unit -> int

Le nouveau processus (appelé “le processus fils”) est un clone presque parfait du processus qui a appelé fork (dit “le processus père” 1): les deux processus (père et fils) exécutent le même texte de programme, sont initialement au même point de contrôle (le retour de fork), attribuent les mêmes valeurs aux variables, ont des piles de retours de fonctions identiques, et détiennent les mêmes descripteurs de fichiers ouverts sur les mêmes fichiers. Ce qui distingue les deux processus, c’est la valeur renvoyée par fork: zéro dans le processus fils, un entier non nul dans le processus père. En testant la valeur de retour de fork, un programme peut donc déterminer s’il est dans le processus père ou dans le processus fils, et se comporter différemment dans les deux processus:

     
   match fork() with
     0 -> (* code execute uniquement par le fils *)
   | _ -> (* code execute uniquement par le pere *)

L’entier non nul renvoyé par fork dans le processus père est l’identificateur du processus fils. Chaque processus est identifié dans le noyau par un numéro, l’identificateur du processus (process id). Un processus peut obtenir son numéro d’identification par l’appel getpid"()".

Le processus fils est initialement dans le même état que le processus père (mêmes valeurs des variables, mêmes descripteurs de fichiers ouverts). Cet état n’est pas partagé entre le père et le fils, mais seulement dupliqué au moment du fork. Par exemple, si une variable est liée à une référence avant le fork, une copie de cette référence et de son contenu courant est faite au moment du fork; après le fork, chaque processus modifie indépendamment “sa” référence, sans que cela se répercute sur l’autre processus.

De même, les descripteurs de fichiers ouverts sont dupliqués au moment du fork: l’un peut être fermé et l’autre reste ouvert. Par contre, les deux descripteurs désignent la même entrée dans la table des fichiers (qui est allouée dans la mémoire système) et partagent donc leur position courante: si l’un puis l’autre lit, chacun lira une partie différente du fichier; de même les déplacement effectués par l’un avec lseek sont immédiatement visibles par l’autre. (Les descripteurs du fils et du père se comportent donc comme les deux descripteurs argument et résultat après exécution de la commande dup, mais sont dans des processus différents au lieu d’être dans le même processus.)

3.2  Exemple complet: la commande leave

La commande leave hhmm rend la main immédiatement, mais crée un processus en tâche de fond qui, à l’heure hhmm, rappelle qu’il est temps de partir.

     
   open Sys;;
   open Unix;;
   
   let leave () =
     let hh = int_of_string (String.sub Sys.argv.(1) 0 2)
     and mm = int_of_string (String.sub Sys.argv.(1) 2 2) in
     let now = localtime(time()) in
     let delay = (hh - now.tm_hour) * 3600 + (mm - now.tm_min) * 60 in
     if delay <= 0 then begin
       print_endline "Hey! That time has already passed!";
       exit 0
     end;
     if fork() <> 0 then exit 0;
     sleep delay;
     print_endline "\007\007\007Time to leave!";
     exit 0;;
   
   handle_unix_error leave ();;

On commence par un parsing rudimentaire de la ligne de commande, pour extraire l’heure voulue. On calcule ensuite la durée d’attente, en secondes (ligne 8). (L’appel time renvoie la date courante, en secondes depuis le premier janvier 1970, minuit. La fonction localtime transforme ça en année/mois/jour/heures/minutes/secondes.) On crée alors un nouveau processus par fork. Le processus père (celui pour lequel fork renvoie un entier non nul) termine immédiatement. Le shell qui a lancé leave rend donc aussitôt la main à l’utilisateur. Le processus fils (celui pour lequel fork renvoie zéro) continue à tourner. Il ne fait rien pendant la durée indiquée (appel sleep), puis affiche son message et termine.

3.3  Attente de la terminaison d’un processus

L’appel système wait attend qu’un des processus fils créés par fork ait terminé, et renvoie des informations sur la manière dont ce processus a terminé. Il permet la synchronisation père-fils, ainsi qu’une forme très rudimentaire de communication du fils vers le père.

     
   val wait : unit -> int * process_status
   val waitpid : wait_flag list -> int -> int * process_status

L’appel système primitif est waitpid et la fonction wait() n’est qu’un racourci pour l’expression waitpid [] (-1). L’appel système waitpid [] p attend la terminaison du processus p, si p>0 est strictement positif, ou d’un sous-ensemble de processus fils, du même groupe si p=0, quelconque si p=−1, ou du groupe −p si p<−1.

Le premier résultat est le numéro du processus fils intercepté par wait. Le deuxième résultat peut être:

WEXITED(r)le processus fils a terminé normalement (par exit ou en arrivant au bout du programme); r est le code de retour (l’argument passé à exit)
WSIGNALED(sig)le processus fils a été tué par un signal (ctrl-C, kill, etc.; voir plus bas pour les signaux); sig identifie le type du signal
WSTOPPED(sig)le processus fils a été stoppé par le signal sig; ne se produit que dans le cas très particulier où un processus (typiquement un debugger) est en train de surveiller l’exécution d’un autre (par l’appel ptrace).

Si un des processus fils a déjà terminé au moment où le père exécute wait, l’appel wait retourne immédiatement. Sinon, wait bloque le père jusqu’à ce qu’un des fils termine (comportement dit “de rendez-vous”). Pour attendre N fils, il faut répéter N fois wait.

La commande waitpid accepte deux options facultatifs comme premier argument: L’option WNOHANG indique de ne pas attendre, s’il il a des fils qui répondent à la demande mais qui n’ont pas encore terminé. Dans ce cas, le premier résultat est 0 et le second non défini. L’option WUNTRACED indique de retourner également les fils qui ont été arrêté par le signal sigstop. La commande lève l’erreur ECHILD si aucun fils du processus appelant ne répond à la spécification p (en particulier, si p vaut −1 et que le processus courrant n’a pas ou plus de fils).

Exemple:

la fonction fork_search ci-dessous fait une recherche linéaire dans un vecteur, en deux processus. Elle s’appuie sur la fonction simple_search, qui fait la recherche linéaire simple.

     
   open Unix
   exception Found;;
   
   let simple_search cond v =
     try
       for i = 0 to Array.length v - 1 do
         if cond v.(ithen raise Found
       done;
       false
     with Found -> true;;
   
   let fork_search cond v =
     let n = Array.length v in
     match fork() with
       0 ->
         let found = simple_search cond (Array.sub v (n/2) (n-n/2)) in
         exit (if found then 0 else 1)
     | _ ->
         let found = simple_search cond (Array.sub v 0 (n/2)) in
         match wait() with
           (pidWEXITED retcode) -> found or (retcode = 0)
         | (pid_)               -> failwith "fork_search";;

Après le fork, le processus fils parcourt la moitié haute du tableau, et sort avec le code de retour 1 s’il a trouvé un élément satisfaisant le prédicat cond, ou 0 sinon (lignes 16 et 17). Le processus père parcourt la moitié basse du tableau, puis appelle wait pour se synchroniser avec le processus fils (lignes 19 et 20). Si le fils a terminé normalement, on combine son code de retour avec le booléen résultat de la recherche dans la moitié basse du tableau. Sinon, quelque chose d’horrible s’est produit, et la fonction fork_search échoue.

En plus de la synchronisation entre processus, l’appel wait assure aussi la récupération de toutes les ressources utilisées par le processus fils. Quand un processus termine, il passe dans un état dit “zombie”, où la plupart des ressources qu’il utilise (espace mémoire, etc) ont été désallouées, mais pas toutes: il continue à occuper un emplacement dans la table des processus, afin de pouvoir transmettre son code de retour au père via l’appel wait. Ce n’est que lorsque le père a exécuté wait que le processus zombie disparaît de la table des processus. Cette table étant de taille fixe, il importe, pour éviter le débordement, de faire wait sur les processus qu’on lance.

Si le processus père termine avant le processus fils, le fils se voit attribuer le processus numéro 1 (init) comme père. Ce processus contient une boucle infinie de wait, et va donc faire disparaître complètement le processus fils dès qu’il termine. Ceci débouche sur une technique utile dans le cas où on ne peut pas facilement appeler wait sur chaque processus qu’on a créé (parce qu’on ne peut pas se permettre de bloquer en attendant la terminaison des fils, par exemple): la technique “du double fork”.

     
   match fork() with
     0 -> if fork() <> 0 then exit 0;
          (* faire ce que le fils doit faire *)
   | _ -> wait();
          (* faire ce que le pere doit faire *)

Le fils termine par exit juste après le deuxième fork. Le petit-fils se retrouve donc orphelin, et est adopté par le processus init. Il ne laissera donc pas de processus zombie. Le père exécute wait aussitôt pour récupérer le fils. Ce wait ne bloque pas longtemps puisque le fils termine très vite.

3.4  Lancement d’un programme

Les appels système execve, execv et execvp lancent l’exécution d’un programme à l’intérieur du processus courant. Sauf en cas d’erreur, ces appels ne retournent jamais: ils arrêtent le déroulement du programme courant et se branchent au début du nouveau programme.

     
   val execve : string -> string array -> string array -> unit
   val execv  : string -> string array -> unit
   val execvp : string -> string array -> unit

Le premier argument est le nom du fichier contenant le code du programme à exécuter. Dans le cas de execvp, ce nom est également recherché dans les répertoires du path d’exécution (la valeur de la variable d’environnement PATH).

Le deuxième argument est la ligne de commande à transmettre au programme exécuté; ce vecteur de chaînes va se retrouver dans Sys.argv du programme exécuté.

Dans le cas de execve, le troisième argument est l’environnement à transmettre au programme exécuté; execv et execvp transmettent inchangé l’environnement courant.

Les appels execve, execv et execvp ne retournent jamais de résultat: ou bien tout se passe sans erreurs, et le processus se met à exécuter un autre programme; ou bien une erreur se produit (fichier non trouvé, etc.), et l’appel déclenche l’exception Unix_error.

Exemple:

les trois formes ci-dessous sont équivalentes:

     
   execve "/bin/ls" [|"ls""-l""/tmp"|] (environment())
   execv  "/bin/ls" [|"ls""-l""/tmp"|]
   execvp "ls"      [|"ls""-l""/tmp"|]

Exemple:

voici un “wrapper” autour de la commande grep, qui ajoute l’option -i (confondre majuscules et minuscules) à la liste d’arguments:

     
   open Sys;;
   open Unix;;
   let grep () =
     execvp "grep"
       (Array.concat
          [ [|"grep""-i"|];
            (Array.sub Sys.argv 1 (Array.length Sys.argv - 1)) ])
   ;;
   handle_unix_error grep ();;

Exemple:

voici un “wrapper” autour de la commande emacs, qui change le type du terminal:

     
   open Sys;;
   open Unix;;
   let emacs () =
     execve "/usr/bin/emacs" Sys.argv
       (Array.concat [ [|"TERM=hacked-xterm"|]; (environment()) ]);;
   handle_unix_error emacs ();;

C’est le même processus qui a fait exec qui exécute le nouveau programme. En conséquence, le nouveau programme hérite de certains morceaux de l’environnement d’exécution du programme qui a fait exec:

3.5  Exemple complet: un mini-shell

Le programme qui suit est un interprète de commandes simplifié: il lit des lignes sur l’entrée standard, les coupe en mots, lance la commande correspondante, et recommence jusqu’à une fin de fichier sur l’entrée standard. On commence par la fonction qui coupe une chaîne de caractères en une liste de mots. Pas de commentaires pour cette horreur.

     
   open Unix;;
   
   let split_words s =
     let rec skip_blanks i =
       if i < String.length s & s.[i] = ' '
       then skip_blanks (i+1)
       else i in
     let rec split start i =
       if i >= String.length s then
         [String.sub s start (i-start)]
       else if s.[i] = ' ' then
         let j = skip_blanks i in
         String.sub s start (i-start) :: split j j
       else
         split start (i+1) in
     Array.of_list (split 0 0);;

On passe maintenant à la boucle principale de l’interpréteur.

     
   let exec_command cmd =
     try execvp cmd.(0) cmd
     with Unix_error(err__) ->
       Printf.printf "Cannot execute %s : %s\n%!"
         cmd.(0) (error_message err);
       exit 255
   
   let print_status program status =
     match status with
       WEXITED 255 -> ()
     | WEXITED status ->
         Printf.printf "%s exited with code %d\n%!" program status;
     | WSIGNALED signal ->
         Printf.printf "%s killed by signal %d\n%!" program signal;
     | WSTOPPED signal ->
         Printf.printf "%s stopped (???)\n%!" program;;

La fonction exec_command exécute une commande avec récupération des erreurs. Le code de retour 255 indique que la commande n’a pas pu être exécutée. (Ce n’est pas une convention standard; on espère que peu de commandes renvoient le code de retour 255.) La fonction print_status décode et imprime l’information d’état retournée par un processus, en ignorant le code de retour 255.

     
   let minishell () =
     try
       while true do
         let cmd = input_line Pervasives.stdin in
         let words = split_words cmd in
         match fork() with
           0 -> exec_command words
         | pid_son ->
             let pidstatus = wait() in
             print_status "Program" status
       done
     with End_of_file ->
       ();;
   
   handle_unix_error minishell ();;

À chaque tour de boucle, on lit une ligne sur l’entrée standard, via la fonction input_line de la bibliothèque standard Pervasives. (Cette fonction déclenche l’exception End_of_file quand la fin de fichier est atteinte, faisant sortir de la boucle.) On coupe la ligne en mots, puis on fait fork. Le processus fils fait exec_command pour lancer la commande avec récupération des erreurs. Le processus père appelle wait pour attendre que la commande termine et imprime l’information d’état renvoyée par wait.

Exercice 10   Ajouter la possibilité d’exécuter des commandes en tâche de fond, si elles sont suivies par &.
(Voir le corrigé)
Dans le cas où la ligne de commande se termine par &, il suffit que le processus père n’appelle pas wait, et passe immédiatement au prochain tour de la boucle. Seule difficulté: le père peut maintenant avoir plusieurs fils qui s’exécutent en même temps (les commandes en arrière-plan qui n’ont pas encore terminé, plus la dernière commande synchrone), et wait peut se synchroniser sur l’un quelconque de ces fils. Dans le cas d’une commande synchrone, il faut donc répéter wait jusqu’à ce que le fils récupéré soit bien celui qui exécute la commande.
     
       while true do
         let cmd = input_line Pervasives.stdin in
         let wordsampersand = parse_command_line cmd in
         match fork() with
           0 -> exec_command words
         | pid_son ->
             if ampersand then ()
             else
               let rec wait_for_son() =
                 let pidstatus = wait () in
                 if pid = pid_son then
                   print_status "Program" status
                 else
                   let p = "Background program " ^ (string_of_int pidin
                   print_status p status;
                   wait_for_son() in
               wait_for_son()
       done

1
Les dénominations politiquement correctes sont “processus parent” et “processus enfant”. Il est aussi accepté d’alterner avec “incarnation mère” et “incarnation fille”.

Chapter 4  Les signaux

Les signaux, ou interruptions logicielles, sont des événements externes qui changent le déroulement d’un programme de manière asynchrone, c’est-à-dire à n’importe quel instant lors de l’exécution du programme. En ceci les signaux s’opposent aux autres formes de communications où les programmes doivent explicitement demander à recevoir les messages externes en attente, par exemple en faisant read sur un tuyau.

Les signaux transportent peu d’information (le type du signal et rien d’autre) et n’ont pas été conçus pour communiquer entre processus mais pour permettre à un processus de recevoir des informations atomiques sur l’évolution de l’environnement extérieur (l’état du système ou d’autres processus).

4.1  Le comportement par défaut

Lorsqu’un processus reçoit un signal, plusieurs comportements sont possibles.

Il y a plusieurs types de signaux, indiquant chacun une condition particulière. Le type énuméré signal en donne la liste. En voici quelques-uns, avec le comportement par défaut associé:

NomSignificationComportement
sighupHang-up (fin de connexion)Terminaison
sigintInterruption (ctrl-C)Terminaison
sigquitInterruption forte (ctrl-\)Terminaison + core dump
sigfpeErreur arithmétique (division par zéro)Terminaison + core dump
sigkillInterruption très forte (ne peut être ignorée)Terminaison
sigsegvViolation des protections mémoireTerminaison + core dump
sigpipeÉcriture sur un tuyau sans lecteursTerminaison
sigalrmInterruption d’horlogeIgnoré
sigtstpArrêt temporaire d’un processus (ctrl-Z)Suspension
sigcontRedémarrage d’un processus arrêtéIgnoré
sigchldUn des processus fils est mort ou a été arrêtéIgnoré

Les signaux reçus par un programme proviennent de plusieurs sources possibles:

4.2  Produire des signaux

L’appel système kill permet d’envoyer un signal à un processus.

     
   val kill : int -> int -> unit

Le paramètre entier est le numéro du processus auquel le signal est destiné. Une erreur se produit si on envoie un signal à un processus n’appartenant pas au même utilisateur que le processus émetteur. Un processus peut s’envoyer des signaux à lui-même. Lorsque l’appel système kill retourne, il est garanti que le signal a été délivré au processus destinataire. C’est-à-dire que si le processus destinataire n’ignore pas et ne masque pas le signal, sa première action sera de traiter un signal (celui-ci ou un autre). Si un processus reçoit plusieurs fois le même signal pendant un laps de temps très court, il peut n’exécuter qu’un seule fois (le code associé à) ce signal. Un programme ne peut donc pas compter le nombre de fois qu’il reçoit un signal, mais seulement le nombre de fois qu’il le traite.

L’appel système alarm permet de produire des interruptions d’horloge.

     
   val alarm : int -> int

L’appel alarm s retourne immédiatement, mais fait envoyer au processus le signal sigalrm (au moins) s secondes plus tard (le temps maximal d’attente n’est pas garanti). L’appel renvoie le nombre de seconde restante jusqu’à la programmation précédente. Si s est nulle, l’effet est simplement d’annuler la précédente programmation de l’alarme.

4.3  Changer l’effet d’un signal

L’appel système signal permet de changer le comportement du processus lorsqu’il reçoit un signal d’un certain type.

     
   val signalint -> signal_behavior -> signal_behavior

Le deuxième argument indique le comportement désiré. Si le deuxième argument est la constante Signal_ignore, le signal est ignoré. Si le deuxième argument est Signal_default, le comportement par défaut est restauré. Si le deuxième argument est Signal_handle f, où f est une fonction de type unit -> unit, la fonction f sera appelée à chaque fois qu’on reçoit le signal.

L’appel fork préserve les comportements des signaux: les comportements initiaux pour le fils sont ceux pour le père au moment du fork. Les appels exec remettent les comportements à Signal_default, à une exception près: les signaux ignorés avant le exec restent ignorés après.

Exemple:

On veut parfois se déconnecter en laissant tourner des tâches de fond (gros calculs, programmes “espions”, etc). Pour ce faire, il faut éviter que les processus qu’on veut laisser tourner ne terminent lorsqu’ils reçoivent le signal SIGHUP envoyé au moment où l’on se déconnecte. Il existe une commande nohup qui fait précisément cela:

nohup   cmd   arg1   …   argn 

exécute la commande cmd   arg1   …   argn en la rendant insensible au signal SIGHUP. (Certains shells font automatiquement nohup sur tous les processus lancés en tâche de fond.) Voici comment l’implémenter en trois lignes:

     
   open Sys;;
   signal sighup Signal_ignore;;
   Unix.execvp argv.(1) (Array.sub argv 1 (Array.length argv - 1));;

L’appel execvp préserve le fait que sighup est ignoré.

Voici maintenant quelques exemples d’interception de signaux.

Exemple:

Pour sortir en douceur quand le programme s’est mal comporté. Par exemple, un programme comme tar peut essayer de sauver une information importante dans le fichier ou détruire le fichier corrompu avant de s’arrêter. Il suffit d’exécuter, au début du programme:

     
   signal sigquit (Signal_handle quit);
   signal sigsegv (Signal_handle quit);
   signal sigfpe  (Signal_handle quit);

où la fonction quit est de la forme:

     
   let quit() =
     (* Essayer de sauver l'information importante dans le fichier *);
     exit 100;;

Exemple:

Pour récupérer les interruptions de l’utilisateur. Certains programmes interactifs peuvent par exemple vouloir revenir dans la boucle de commande lorsque l’utilisateur frappe ctrl-C. Il suffit de déclencher une exception lorsqu’on reçoit le signal SIGINT.

     
   exception Break;;
   let break () = raise Break;;
   ...
   let main_loop() =
     signal sigint (Signal_handle break);
     while true do
       try
         (* lire et exécuter une commande *)
       with Break ->
         (* afficher "Interrompu" *)
     done;;

Exemple:

Pour exécuter des tâches périodiques (animations, etc) entrelacées avec l’exécution du programme principal. Par exemple, voici comment faire “bip” toutes les 30 secondes, quel que soit l’activité du programme (calculs, entrées-sorties).

     
   let beep () = output_char stdout `\007`; flush stdoutalarm 30; ();;
   ...
   signal sigalrm (Signal_handle beep); alarm 30;;

Points de contrôle

Les signaux transmettent au programme une information de façon asynchrone. C’est leur raison d’être, et ce qui les rend souvent incontournables, mais c’est aussi ce qui en fait l’une des grandes difficultés de la programmation système.

En effet, le code de traitement s’exécute à la réception d’un signal, qui est asynchrone, donc de façon pseudo concurrente (c’est-à-dire entrelacée) avec le code principal du programme. Comme le traitement d’un signal ne retourne pas de valeur, leur intérêt est de faire des effets de bords, typiquement modifier l’état d’une variable globale. Il s’ensuit une compétition (race condition) entre le signal et le programme principal pour l’accès à cette variable globale. La solution consiste en général à bloquer les signaux pendant le traitement de ces zones critiques comme expliqué ci-dessous.

Toutefois, OCaml ne traite pas les signaux de façon tout à faire asynchrone. À la réception du signal, il se contente d’enregistrer sa réception et le traitement ne sera effectué, c’est-à-dire le code de traitement associé au signal effectivement exécuté, seulement à certains points de contrôles. Ceux-ci sont suffisamment fréquents pour donner l’illusion d’un traitement asynchrone. Les points de contrôles sont typiquement les points d’allocation, de contrôle de boucles ou d’interaction avec le système (en particulier autour des appels systèmes). OCaml garantit qu’un code qui ne boucle pas, n’alloue pas, et n’interagit pas avec le système ne sera pas entrelacer avec le traitement d’un signal. En particulier l’écriture d’une valeur non allouée (entier, booléen, etc. mais pas un flottant!) dans une référence ne pose pas de problème de compétition.

4.4  Masquer des signaux

Les signaux peuvent être bloqués. Les signaux bloqués ne sont pas ignorés, mais simplement mis en attente, en général pour être délivrés ultérieurement. L’appel système sigprocmask permet de changer le masque des signaux bloqués:

     
   val sigprocmask : sigprocmask_command -> int list -> int list

sigprocmask cmd sigs change l’ensemble des signaux bloqués et retourne la liste des signaux qui étaient bloqués juste avant l’exécution de la commande, ce qui permettra ultérieurement de remettre le masque des signaux bloqués dans son état initial. L’argument sigs est une liste de signaux dont le sens dépend de la commande cmd:

SIG_BLOCKles signaux sigs sont ajoutés aux signaux bloqués.
SIG_UNBLOCKles signaux sigs sont retirés des signaux débloqués.
SIG_SETMASKles signaux sigs sont exactement les signaux bloqués.

Un usage typique de sigprocmask est de masquer temporairement certains signaux.

     
   let old_mask = sigprocmask cmd sigs in
   (* do something *)
   let _ = sigprocmask SIG_SETMASK old_mark

Bien souvent, on devra se protéger contre les erreurs éventuelles en utilisant plutôt le schéma:

     
   let old_mask = sigprocmask cmd sigs in
   let treat() = ((* do something *)in
   let reset() = ignore (sigprocmask SIG_SETMASK old_maskin
   Misc.try_finalize treat () reset ()

4.5  Signaux et appels-système

Attention! un signal non ignoré peut interrompre certains appels système. En général, les appels systèmes interruptibles sont seulement des appels systèmes dits lents, qui peuvent a priori prendre un temps arbitrairement long: par exemple, lectures/écritures au terminal, select (voir plus loin), system, etc. En cas d’interruption, l’appel système n’est pas exécuté et déclenche l’exception EINTR. Noter que l’écriture/lecture dans un fichier ne sont pas interruptibles: bien que ces opérations puissent suspendre le processus courant pour donner la main à un autre le temps que les données soient lues sur le disques, lorsque c’est nécessaire, cette attente sera toujours brève—si le disque fonctionne correctement. En particulier, l’arrivée des données ne dépend que du système et pas d’un autre processus utilisateur.

Les signaux ignorés ne sont jamais délivrés. Un signal n’est pas délivré tant qu’il est masqué. Dans les autres cas, il faut se prémunir contre une interruption possible.

Un exemple typique est l’attente de la terminaison d’un fils. Dans ce cas, le père exécute waitpid [] pidpid est le numéro du fils à attendre. Il s’agit d’un appel système bloquant, donc «lent», qui sera interrompu par l’arrivée éventuelle d’un signal. En particulier, le signal sigchld est envoyé au processus père à la mort d’un fils.

Le module Misc contient la fonction suivante restart_on_EINTR de type (’a -> ’b) -> ’a -> ’b qui permet de lancer un appel système et de le répéter lorsqu’il est interrompu par un signal, i.e. lorsqu’il lève l’exception EINTR.

     
   let rec restart_on_EINTR f x =
     try f x with Unix_error (EINTR__) -> restart_on_EINTR f x

Pour attendre réellement un fils, on pourra alors simplement écrire restart_on_EINTR (waitpid flagspid.

Exemple:

Le père peut aussi récupérer ses fils de façon asynchrone, en particulier lorsque la valeur de retour n’importe pas pour la suite de l’exécution. Cela peut se faire en exécutant une fonction free_children à la réception du signal sigchld. Nous plaçons cette fonction d’usage général dans la bibliothèque Misc.

     
   let free_children signal =
     try while fst (waitpid [ WNOHANG ] (-1)) > 0 do () done
     with Unix_error (ECHILD__) -> ()

Cette fonction appelle la fonction waitpid en mode non bloquant (option WNOHANG) et sur n’importe quel fils, et répète l’appel quand qu’un fils a pu être retourné. Elle s’arrête lorsque il ne reste plus que des fils vivants (zéro est retourné à la place de l’identité du processus délivré) ou lorsqu’il n’y a plus de fils (exception ECHILD). Lorsque le processus reçoit le signal sigchld il est en effet impossible de savoir le nombre de processus ayant terminé, si le signal est émis plusieurs fois dans un intervalle de temps suffisamment court, le père ne verra qu’un seul signal. Noter qu’ici il n’est pas nécessaire de se prémunir contre le signal EINTR car waitpid n’est pas bloquant lorsqu’il est appelé avec l’option WNOHANG.

Dans d’autres cas, ce signal ne peut être ignoré (l’action associée sera alors de libérer tous les fils ayant terminé, de façon non bloquante—on ne sait jamais combien de fois le signal à été émis).

Exemple:

La commande system du module Unix est simplement définie par

     
   let system cmd =
     match fork() with
       0 ->
         begin try
           execv "/bin/sh" [| "/bin/sh""-c"cmd |]; assert false
         with _ -> exit 127
         end
     | id -> snd(waitpid [] id);;

L’assertion qui suit l’appel système execv est là pour corriger une restriction erronée du type de retour de la fonction execv (dans les version antérieures ou égale à 3.07). L’appel système ne retournant pas, aucune contrainte ne doit porter sur la valeur retournée, et bien sûr l’assertion ne sera jamais exécutée.

La commande system de la bibliothèque standard de la bibliothèque C précise que le père ignore les signaux sigint et sigquit et masque le signal sigchld pendant l’exécution de la commande. Cela permet d’interrompre ou de tuer le programme appelé (qui reçoit le signal) sans que le programme principal ne soit affecté pendant l’exécution de la commande.

Nous préférons définir la fonction system comme spécialisation d’une fonction plus générale exec_as_system qui n’oblige pas à faire exécuter la commande par le shell. Nous la plaçons dans le module Misc.

     
   let exec_as_system exec args =
     let old_mask = sigprocmask SIG_BLOCK [sigchld ] in
     let old_int = signal sigint Signal_ignore in
     let old_quit = signal sigquit Signal_ignore in
     let reset() =
       ignore (signal sigint old_int);
       ignore (signal sigquit old_quit);
       ignore (sigprocmask SIG_SETMASK old_maskin
     let system_call () =
       match fork() with
       | 0 ->
           reset();
           begin try
             exec args
           with _ -> exit 127
           end
       | k ->
           snd (restart_on_EINTR (waitpid []) kin
     try_finalize system_call() reset();;
   
   let system cmd =
     exec_as_system (execv "/bin/sh") [| "/bin/sh""-c"cmd |];;

Noter que le changement des signaux doit être effectué avant l’appel à fork. En effet, immédiatement après cet appel, seulement l’un des deux processus fils ou père a la main (en général le fils). Pendant le laps de temps où le fils prend la main, le père pourrait recevoir des signaux, notamment sigchld si le fils termine immédiatement. En conséquence, il faut remettre les signaux à leur valeur initiale dans le fils avant d’exécuter la commande (ligne 13). En effet, l’ensemble des signaux ignorés est préservé par fork et exec et le comportement des signaux est lui-même préservé par fork. La commande exec remet normalement les signaux à leur valeur par défaut, sauf justement si le comportement est d’ignorer le signal.

Enfin, le père doit remettre également les signaux à leur valeur initiale, immédiatement après l’appel, y compris en cas d’erreur, d’où l’utilisation de la commande try_finalize (ligne 20).

4.6  Le temps qui passe

Temps anciens

Dans les premières versions d’Unix, le temps était compté en secondes. Par soucis de compatibilité, on peut toujours compter le temps en secondes. La date elle-même est comptée en secondes écoulées depuis le 1er janvier 1970 à 00:00:00 GMT. Elle est retournée par la fonction:

     
   val time : unit -> float

L’appel système sleep peut arrêter l’exécution du programme pendant le nombre de secondes donné en argument:

     
   val sleep : int -> unit

Cependant, cette fonction n’est pas primitive. Elle est programmable avec des appels systèmes plus élémentaire à l’aide de la fonction alarm (vue plus haut) et sigsuspend:

     
   val sigsuspend : int list -> unit

L’appel sigsuspend l suspend temporairement les signaux de la liste l, puis arrête l’exécution du programme jusqu’à la réception d’un signal non ignoré non suspendu (au retour, le masque des signaux est remis à son ancienne valeur par le système). Exemple:

Nous pouvons maintenant programmer la fonction sleep.

     
   let sleep s =
     let old_alarm = signal sigalrm (Signal_handle (fun s -> ())) in
     let old_mask = sigprocmask SIG_UNBLOCK [ sigalrm ] in
     let _ = alarm s in
     let new_mask = List.filter (fun x -> x <> sigalrmold_mask in
     sigsuspend new_mask;
     let _ = alarm 0 in
     ignore (signal sigalrm old_alarm);
     ignore (sigprocmask SIG_SETMASK old_mask);;

Dans un premier temps, le comportement du signal sigalarm est de ne rien faire. Notez que «ne rien faire» n’est pas équivalent à ignorer le signal. Dans le second cas, le processus ne serait pas réveillé à la réception du signal. Le signal sigalarm est mis dans l’état non bloquant. Puis on se met en attente en suspendant tous les autres signaux qui ne l’étaient pas déjà (old_mask). Après le réveil, on défait les modifications précédentes. (Noter que la ligne 9 aurait pu est placée immédiatement après la ligne 2, car l’appel à sigsuspend préserve le masque des signaux.)

Temps modernes et sabliers

Dans les Unix récents, le temps peut aussi se mesurer en micro-secondes. En OCaml les temps en micro-secondes sont représentés comme des flottants. La fonction gettimeofday est l’équivalent de time pour les temps modernes.

     
   val gettimeofday : unit -> float

Les sabliers

Dans les Unix modernes chaque processus est équipé de trois sabliers, chacun décomptant le temps de façon différente. Les types de sabliers sont:

ITIMER_REALtemps réelsigalrm
ITIMER_VIRTUALtemps utilisateursigvtalrm
ITIMER_PROFutilisateur et système (debug)sigprof

L’état d’un sablier est décrit par le type interval_timer_status qui est une structure à deux champs (de type float représentant le temps).

Un sablier est donc inactif lorsque ses deux champs sont nuls. Les sabliers peuvent être consultés ou modifiés avec les fonctions suivantes:

     
   val getitimer : interval_timer -> interval_timer_status
   val setitimer :
       interval_timer -> interval_timer_status -> interval_timer_status

La valeur retournée par setitimer est l’ancienne valeur du sablier au moment de la modification.

Exercice 11   Pour gérer plusieurs sabliers, écrire une module ayant l’interface suivante:
     
   module type Timer = sig
   
  open Unix
   
  type t
   
  val new_timer : interval_timer -> (unit -> unit) -> t
   
  val get_timer : t -> interval_timer_status
   
  val set_timer : t -> interval_timer_status -> interval_timer_status
   
end
La fonction new_timer k f créé un nouveau sablier de type k déclenchant l’action f, inactif à sa création; la fonction set_timer t permettant de régler le sablier t (l’ancien réglage étant retourné).

Calcul des dates

Les versions modernes d’Unix fournissent également des fonctions de manipulation des dates en bibliothèque: voir la structure tm qui permet de représenter les dates selon le calendrier (année, mois, etc.) ainsi que les fonctions de conversion: gmtime, localtime, mktime, etc. dans l’annexe ??.

4.7  Problèmes avec les signaux

L’utilisation des signaux, en particulier comme mécanisme de communication asynchrone inter-processus, se heurte à un certain nombre de limitations et de difficultés:

Les signaux apportent donc toutes les difficultés de la communication asynchrone, tout en n’en fournissant qu’une forme très limitée. On aura donc intérêt à s’en passer lorsqu’il que c’est possible, par exemple en utilisant select plutôt qu’une alarme pour se mettre en attente. Toutefois, dans certaines situations (langage de commandes, par essence), leur prise en compte est vraiment indispensable.

Les signaux sont peut-être la partie la moins bien conçue du système Unix. Sur certaines anciennes versions d’Unix (en particulier System V), le comportement d’un signal est automatiquement remis à Signal_default lorsqu’il est intercepté. La fonction associée peut bien sûr rétablir le bon comportement elle-même; ainsi, dans l’exemple du “bip” toutes les 30 secondes, il faudrait écrire:

     
   let rec beep () =
     set_signal SIGALRM (Signal_handle beep);
     output_char stdout `\007`; flush stdout;
     alarm 30; ();;

Le problème est que les signaux qui arrivent entre le moment où le comportement est automatiquement remis à Signal_default et le moment où le set_signal est exécuté ne sont pas traités correctement: suivant le type du signal, ils peuvent être ignorés, ou causer la mort du processus, au lieu d’exécuter la fonction associée.

D’autres versions d’Unix (BSD ou Linux) traitent les signaux de manière plus satisfaisante: le comportement associé à un signal n’est pas changé lorsqu’on le reçoit; et lorsqu’un signal est en cours de traitement, les autres signaux du même type sont mis en attente.


1
Ces caractères sont choisis par défaut, mais il est possible de les changer en modifiant les paramètres du terminal, voir section 2.13.

Chapter 5  Communications inter-processus classiques

On a vu jusqu’ici comment manipuler des processus et les faire communiquer avec l’extérieur par l’intermédiaire de fichiers. Le reste de ce cours est consacré au problème de faire communiquer entre eux des processus s’exécutant en parallèle, pour leur permettre de coopérer.

5.1  Les tuyaux

Les fichiers normaux ne fournissent pas un moyen satisfaisant de communication entre processus parallèles. Exemple: dans une situation écrivain/lecteur (un processus écrit des informations, l’autre les lit), si on utilise un fichier comme médium, le lecteur peut constater que le fichier ne grossit plus (read renvoie zéro), mais il ne peut pas savoir si c’est dû au fait que le processus écrivain a terminé, ou bien si l’écrivain est simplement en train de calculer la prochaine information. De plus, le fichier garde trace de toutes les informations transmises, ce qui pose des problèmes, en particulier de place disque.

Les tuyaux (parfois également appelés tubes) fournissent un mécanisme adapté à ce style de communication. Un tuyau se présente sous la forme de deux descripteurs de fichiers. L’un, ouvert en écriture, représente l’entrée du tuyau. L’autre, ouvert en lecture, représente la sortie du tuyau. On crée un tuyau par l’appel système pipe:

     
   val pipe : unit -> file_descr * file_descr

Un appel à pipe retourne une paire (fd_in, fd_out). Le premier résultat fd_in est un descripteur ouvert en lecture sur la sortie du tuyau; le deuxième résultat fd_out est un descripteur ouvert en écriture sur l’entrée du tuyau. Le tuyau proprement dit est un objet interne au noyau, accessible uniquement via ces deux descripteurs. En particulier, le tuyau créé n’a pas de nom dans le système de fichiers.

Un tuyau se comporte comme un tampon géré en file d’attente (first-in, first-out): ce qu’on lit sur la sortie du tuyau, c’est ce qu’on a écrit sur l’entrée, dans le même ordre. Les écritures (write sur le descripteur d’entrée) remplissent le tuyau, et lorsqu’il est plein, bloquent en attendant qu’un autre processus vide le tuyau en lisant depuis l’autre extrémité; ce processus est répété plusieurs fois, si nécessaire, jusqu’à ce que toutes les données fournies à write aient été transmises. Les lectures (read sur le descripteur de sortie) vident le tuyau. Si le tuyau est entièrement vide, la lecture bloque jusqu’à ce qu’un octet au moins soit écrit dedans. Les lectures retournent dès qu’il y a quelque chose dans le tuyau, sans attendre que le nombre d’octets demandés à read soit atteint.

Les tuyaux ne présentent donc aucun intérêt si c’est le même processus qui écrit et qui lit dedans. (Ce processus risque fort de se bloquer à tout jamais sur une écriture trop grosse, ou sur une lecture dans un tuyau vide.) Ce sont donc généralement des processus différents qui écrivent et qui lisent dans un tuyau. Comme le tuyau n’est pas nommé, il faut que ces processus aient été créés par fork à partir du processus qui a alloué le tuyau. En effet, les descripteurs sur les deux extrémités du tuyau, comme tous les descripteurs, sont dupliqués au moment du fork, et font donc référence au même tuyau dans le processus père et dans le processus fils.

Exemple:

le fragment de code ci-dessous est typique.

     
   let (fd_infd_out) = pipe() in
   match fork() with
     0 ->
       close fd_in;
       ... write fd_out buffer1 offset1 count1 ...
   | k ->
       close fd_out;
       ... read fd_in buffer2 offset2 count2 ...

Après le fork, il y a deux descripteurs ouverts sur l’entrée du tuyau (un dans le père, un dans le fils), et de même pour la sortie.

Dans cet exemple, on a choisi de faire du fils l’écrivain, et du père le lecteur. Le fils ferme donc son descripteur fd_in sur la sortie du tuyau (pour économiser les descripteurs, et pour éviter certaines erreurs de programmation). Cela laisse le descripteur fd_in du père inchangé, car les descripteurs sont alloués dans la mémoire du processus et, après le fork, la mémoire du fils et celle du père sont disjointes. Le tuyau, alloué dans la mémoire système, continue à exister puisqu’il y a encore le descripteur fd_in du père ouvert en lecture sur sa sortie. Le père ferme de même son descripteur sur l’entrée du tuyau. La situation est donc la suivante:

Les données que le fils écrit sur fd_out arrivent donc bien jusqu’au descripteur fd_in du père.

Lorsqu’on a fermé tous les descripteurs sur l’entrée d’un tuyau, read sur la sortie du tuyau renvoie zéro: fin de fichier. Lorsqu’on a fermé tous les descripteurs sur la sortie d’un tuyau, write sur l’entrée du tuyau provoque la mort du processus qui fait write. Plus précisément: le noyau envoie un signal SIGPIPE au processus qui fait write, et le comportement par défaut de ce signal est de tuer le processus (voir la partie “signaux” ci-dessous), ou si le comportement du signal SIGPIPE à été modifié alors l’appel système write échoue avec une erreur EPIPE.

5.2  Exemple complet: le crible d’Ératosthène parallèle

L’exemple qui suit est un grand classique de la programmation par processus communicants. Le but du programme est d’énumérer les nombres premiers et de les afficher au fur et à mesure. L’idée est la suivante: initialement, on connecte un processus qui énumère les entiers à partir de 2 sur sa sortie avec un processus “filtre”. Le processus filtre commence par lire un entier p sur son entrée, et l’affiche à l’écran.

Le premier processus filtre lit donc p=2. Ensuite, il crée un nouveau processus filtre, connecté à sa sortie, et il se met à filtrer les multiples de p depuis son entrée; tous les nombres lus qui ne sont pas multiples de p sont réémis sur sa sortie.

Le processus suivant lit donc p=3, l’affiche, et se met à filtrer les multiples de 3. Et ainsi de suite.

Cet algorithme n’est pas directement implémentable en Unix, parce qu’il crée trop de processus (le nombre d’entiers premiers déjà trouvés, plus un). La plupart des systèmes Unix limitent le nombre de processus à quelques dizaines. De plus, dix processus actifs simultanément suffisent à effondrer la plupart des machines mono-processeurs, en raison des coûts élevés de commutation de contextes pour passer d’un processus à un autre. Dans l’implémentation qui suit, chaque processus attend d’avoir lu un certain nombre d’entiers premiers p1, …, pk sur son entrée avant de se transformer en filtre à éliminer les multiples de p1, …, pk. En pratique, k = 1000 ralentit raisonnablement la création de nouveaux processus.

On commence par le processus qui produit les nombres entiers de 2 à k.

     
   open Unix;;
   
   let input_int = input_binary_int
   let output_int = output_binary_int
   
   let generate k output =
     let rec gen m =
       output_int output m;
       if m < k then gen (m+1)
     in gen 2;;

Pour communiquer les entiers en la sortie d’un générateur et l’entrée du filtre suivant on peut utiliser les fonctions suivantes:

     

La fonction output_binary_int est une fonction de la bibliothèque standard qui écrit la représentation d’un entier (sous forme de quatre octets) sur un out_channel. L’entier peut ensuite être relu par la fonction input_binary_int. L’intérêt d’employer ici la bibliothèque standard est double: premièrement, il n’y a pas à faire soi-même les fonctions de conversion entiers/chaînes de quatre caractères (la représentation n’est pas spécifiée, mais il est garanti que pour une version du langage, elle est indépendante de la machine); deuxièmement, on fait beaucoup moins d’appels système, car les entrées/sorties sont temporisées, d’où de meilleures performances. Les deux fonctions ci-dessous construisent un in_channel ou un out_channel qui temporise les lectures ou les écritures sur le descripteur Unix donné en argument:

     
   val in_channel_of_descr  : file_descr -> in_channel
   val out_channel_of_descr : file_descr -> out_channel

Ces fonctions permettent de faire des entrées/sorties temporisées sur des descripteurs obtenus indirectement ou autrement que par une ouverture de fichier. Leur utilisation n’a pas pour but de mélanger les entrées/sorties temporisés avec des entrées sorties non temporisées, ce qui est possible mais très délicat et fortement déconseillé, en particulier pour les lectures. Il est également possible mais très risqué de construire plusieurs in_channel (par exemple) sur le même descripteur de fichier.

On passe maintenant au processus filtre. Il utilise la fonction auxiliaire read_first_primes. L’appel read_first_primes input n lit n nombres sur input (un in_channel), en éliminant les multiples des nombres déjà lus. On affiche ces n nombres au fur et à mesure de leur construction, et on en renvoie la liste.

     
   let print_prime n = print_int nprint_newline()
   
   let read_first_primes input count =
     let rec read_primes first_primes count =
       if count <= 0 then
         first_primes
       else
         let n = input_int input in
         if List.exists (fun m -> n mod m = 0) first_primes then
           read_primes first_primes count
         else begin
           print_prime n;
           read_primes (n :: first_primes) (count - 1)
         end in
     read_primes [] count;;

Voici la fonction filtre proprement dite.

     
   let rec filter input =
     try
       let first_primes = read_first_primes input 1000 in
       let (fd_infd_out) = pipe() in
       match fork() with
         0 ->
           close fd_out;
           filter (in_channel_of_descr fd_in)
       | p ->
           close fd_in;
           let output = out_channel_of_descr fd_out in
           while true do
             let n = input_int input in
             if List.exists (fun m -> n mod m = 0) first_primes then ()
             else output_int output n
           done
     with End_of_file -> ();;

Le filtre commence par appeler read_first_primes pour lire les 1000 premiers nombres premiers sur son entrée (le paramètre input, de type in_channel). Ensuite, on crée un tuyau et on clone le processus par fork. Le processus fils se met à filtrer de même ce qui sort du tuyau. Le processus père lit des nombres sur son entrée, et les envoie dans le tuyau s’ils ne sont pas multiples d’un des 1000 nombres premiers lus initialement.

Le programme principal se réduit à connecter par un tuyau le générateur d’entiers avec un premier processus filtre (crible  k énumère les nombres premiers plus petits que k. Si k est omis (ou n’est pas un entier) il prend la valeur par défaut max_int).

     
   let crible () =
     let len = try int_of_string Sys.argv.(1) with _ -> max_int in
     let (fd_infd_out) = pipe () in
     match fork() with
       0 ->
         close fd_out;
         filter (in_channel_of_descr fd_in)
     | p ->
         close fd_in;
         generate len (out_channel_of_descr fd_out);;
   
   handle_unix_error crible ();;

Ici, nous n’avons pas attendu que les fils terminent pour terminer le programme. Les processus pères sont des générateurs pour leurs fils. Lorsque k est donné, le père va s’arrêter en premier, et fermer le descripteur sur le tuyau lui permettant de communiquer avec son fils. Lorsqu’il termine, OCaml vide les tampons sur les descripteurs ouverts en écriture, donc le processus fils peut lire les dernières données fournies par le père. Il s’arrête à son tour, etc. Les fils deviennent donc orphelins et sont temporairement rattachés au processus 1 avant de terminer à leur tour.

Lorsque k n’est pas fourni, tous les processus continuent indéfiniment (jusqu’à ce que l’un ou plusieurs soient tués). La mort d’un processus entraîne la terminaison d’un de ses fils comme dans le cas précédent et la fermeture en lecture du tuyau qui le relie à son père. Cela provoquera la terminaison de son père à la prochaine écriture sur le tuyau (le père recevra un signal SIGPIPE, dont l’action par défaut est la terminaison du processus).

Exercice 12   Comment faut-il modifier le programme pour que le père attende la terminaison de ses fils?
(Voir le corrigé)
Il faut attendre le fils bien sûr, mais avant cela il faut fermer le descripteur de fichier sur lequel lit le fils, sinon celui-ci attendrait indéfiniment que son père émette d’autres données, conduisant à un interblocage (la fermeture d’un canal se charge de vider le tampon juste avant sa fermeture, donc on ne perd rien). Concrètement, on remplace la ligne 52 par:
     
         let output = out_channel_of_descr fd_out in
         generate len output;
         close_out output;
         ignore(waitpid [] k);;
De même, on va entourer le bloc 37–41 (désigné par ...) par les lignes suivantes:
     
           try
             ...
           with End_of_file ->
             close_out output;
             ignore (waitpid [] p)
Exercice 13   À chaque nombre premier trouvé, la fonction print_prime exécute l’expression print_newline() qui effectue un appel système pour vider de tampon de la sortie standard, ce qui limite artificiellement la vitesse d’exécution. En fait print_newline() exécute print_char '\n' suivi de flush Pervasives.stdout. Que peut-il se passer si on exécute simplement print_char '\n'? Que faire alors?
(Voir le corrigé)
Les tampons d’entrées/sorties de la bibliothèque standard sont dupliqués au moment de la commande fork (puisque le processus fils est une copie du processus père). Si, les tampons ne sont plus vidés à chaque écriture, alors il faut les vider explicitement juste avant la commande fork. Pour cela, il suffit d’ajouter flush Pervasives.stdout entre les lignes 25 et 26.

5.3  Les tuyaux nommés

Certaines variantes d’Unix (System V, SunOS, Ultrix, Linux) fournissent la possibilité de créer des tuyaux associés à un nom dans le système de fichiers. Ces tuyaux nommés (aussi appelés fifo) permettent la communication entre des processus sans liens de famille particuliers (au contraire des tuyaux normaux, qui limitent la communication au créateur du tuyau et à ses descendants).

L’appel permettant de créer un tuyau nommé est:

     
   val mkfifo : string -> int -> unit

Le premier argument est le nom du tuyau; le deuxième, les droits d’accès voulus.

On ouvre un tuyau nommé par l’appel système openfile, comme pour un fichier normal. Lectures et écritures sur un tuyau nommé ont la même sémantique que sur un tuyau simple. Cependant l’ouverture d’un tuyau nommé en lecture seule ou en écriture seule est bloquante jusqu’à ce que ce tuyau soit ouvert par un autre processus en écriture ou en lecture, respectivement (ce qui peut déjà être le cas et il n’y a pas à attendre), à moins que l’ouverture soit faite avec le drapeau O_NONBLOCK. Dans ce denier cas les lectures ou écritures effectués sur le tuyau ne seront pas non plus bloquantes. On peut changer ce drapeau une fois le tuyau ouvert avec la fonction clear_nonblock et rendre les lectures ou les écritures suivantes sur le tuyau bloquantes (ou non bloquantes avec set_nonblock).

     
   val set_nonblock : file_descr -> unit
   val clear_nonblock : file_descr -> unit

5.4  Redirections de descripteurs

Avec ce qu’on a vu jusqu’ici, on ne sait toujours pas connecter par un tuyau des processus via leurs entrées et sorties standard, comme le fait le shell pour exécuter des commandes de la forme cmd1 | cmd2. En effet, les descripteurs sur les extrémités d’un tuyau qu’on obtient avec pipe (ou avec openfile sur un tuyau nommé) sont de “nouveaux” descripteurs, distincts des descripteurs stdin, stdout et stderr.

Pour ce genre de problèmes, Unix fournit un appel système, dup2 (lire: “DUPlicate a descriptor TO another descriptor”), qui permet de rediriger un descripteur vers un autre. Ceci est rendu possible par le fait qu’il y a un niveau d’indirection entre un descripteur de fichier (un objet de type file_descr) et l’objet du noyau, qu’on appelle une file table entry, qui contient effectivement le pointeur vers le fichier ou le tuyau associé, et la position courante de lecture/écriture.

     
   dup2 : file_descr -> file_descr -> unit

L’appel dup2 fd1 fd2 a pour effet de faire pointer le descripteur fd2 vers la même file table entry que celle pointée par fd1. Après exécution de cet appel, fd2 est donc un duplicata de fd1: les deux descripteurs font référence au même fichier ou tuyau, à la même position de lecture/écriture.

Exemple:

redirection de l’entrée standard.

     
   let fd = openfile "foo" [O_RDONLY] 0 in
   dup2 fd stdin;
   close fd;
   execvp "bar" [|"bar"|]

Après le dup2, le descripteur stdin fait référence au fichier foo. Toute lecture sur stdin va donc lire depuis le fichier foo. (Et aussi toute lecture depuis fd; mais on ne va plus utiliser fd par la suite, et c’est pourquoi on le ferme immédiatement.) De plus, cet état de choses est préservé par execvp. Et donc, le programme bar va s’exécuter avec son entrée standard reliée au fichier foo. C’est ainsi que le shell exécute des commandes de la forme bar < foo.

Exemple:

redirection de la sortie standard.

     
   let fd = openfile "foo" [O_WRONLYO_TRUNCO_CREAT] 0o666 in
   dup2 fd stdout;
   close fd;
   execvp "bar" [|"bar"|]

Après le dup2, le descripteur stdout fait référence au fichier foo. Toute écriture sur stdout va donc écrire sur le fichier foo. Le programme bar va donc s’exécuter avec sa sortie standard reliée au fichier foo. C’est ainsi que le shell exécute des commandes de la forme bar > foo.

Exemple:

relier l’entrée d’un programme à la sortie d’un autre.

     
   let (fd_infd_out) = pipe() in
   match fork() with
     0 -> dup2 fd_in stdin;
          close fd_out;
          close fd_in;
          execvp "cmd2" [|"cmd2"|]
   | _ -> dup2 fd_out stdout;
          close fd_out;
          close fd_in;
          execvp "cmd1" [|"cmd1"|]

Le programme cmd2 est exécuté avec son entrée standard reliée à la sortie du tuyau. En parallèle, le programme cmd1 est exécuté avec sa sortie standard reliée à l’entrée du tuyau. Et donc, tout ce que cmd1 écrit sur sa sortie standard est lu par cmd2 sur son entrée standard.

Que se passe-t-il si cmd1 termine avant cmd2? Au moment du exit dans cmd1, les descripteurs ouverts par cmd1 sont fermés. Il n’y a donc plus de descripteur ouvert sur l’entrée du tuyau. Lorsque cmd2 aura vidé les données en attente dans le tuyau, la prochaine lecture renverra une fin de fichier; cmd2 fait alors ce qu’il est censé faire quand il atteint la fin de son entrée standard — par exemple, terminer. Exemple de cette situation:

     
   cat foo bar gee | grep buz

D’un autre côté, si cmd2 termine avant cmd1, le dernier descripteur sur la sortie du tuyau est prématurément fermé, et donc cdm1 va recevoir un signal (qui, par défaut, termine le processus) la prochaine fois qu’il essaye d’écrire sur sa sortie standard. Exemple de cette situation:

     
   grep buz gee | more

et on quitte more avant la fin en appuyant sur q. À ce moment là, grep est prématurément arrêté, sans qu’il aille jusqu’à la fin du fichier gee.

Exercice 14   Comment implémenter quelques-unes des autres redirections du shell sh, à savoir:
     
   >>      2>      2>>     2>1     <<
(Voir le corrigé)
Pour >>, on procède comme pour >, sauf que le fichier est ouvert avec les options
     
           [O_WRONLYO_APPENDO_CREAT]
Pour 2>, on procède comme pour >, sauf que
     
           dup2 fd stderr
est exécuté en lieu et place de
     
           dup2 fd stdout
Pour 2>1, il suffit d’appeler
     
           dup2 stderr stdout
avant d’exécuter la commande. Enfin, pour <<, le shell sh crée un fichier temporaire dans /tmp, contenant les lignes qui suivent <<, puis exécute la commande en redirigeant son entrée standard sur ce fichier. Une autre méthode serait de connecter par un tuyau l’entrée standard de la commande à un processus fils qui envoie les lignes suivant << sur ce tuyau.

Échanger deux descripteurs est plus délicat: la séquence dup2 fd1 fd2; dup2 fd2 fd1 à laquelle on pourrait penser naïvement ne convient pas. En effet, la seconde opération de redirection n’a aucun effet, puisqu’après la premier les deux descripteurs fd1 et fd2 pointent déjà vers la même file table entry. L’ancienne valeur de fd2 a été perdue. Comme pour intervertir le contenu de deux références, il faut utiliser une variable auxiliaire pour sauvegarde l’une des deux valeurs. Ici, il faut sauvegarder l’un des deux descripteurs, en le recopiant avec l’appel système dup.

     
   val dup : file_descr -> file_descr

L’appel dup fd retourne un nouveau descripteur qui pointe vers la même file table entry que fd. Par exemple, on peut maintenant échanger stdout et stderr par le code suivant:

     
   let tmp = dup stdout in
   dup2 stderr stdout;
   dup2 tmp stderr;
   close tmp;;

Ne pas oublier de refermer le descripteur temporaire tmp, devenu inutile, afin d’éviter une fuite de mémoire.

5.5  Exemple complet: composer N commandes

On va construire une commande compose, telle que

compose cmd1 cmd2 … cmdn

se comporte comme la commande shell

cmd1 ∣ cmd2 ∣ ⋯ ∣ cmdn.
     
   open Sys;;
   open Unix;;
   
   let compose () =
     let n = Array.length Sys.argv - 1 in
     for i = 1 to n - 1 do
       let (fd_infd_out) = pipe() in
       match fork() with
         0 ->
           dup2 fd_out stdout;
           close fd_out;
           close fd_in;
           execv "/bin/sh" [| "/bin/sh""-c"Sys.argv.(i) |]
       | _ ->
           dup2 fd_in stdin;
           close fd_out;
           close fd_in
     done;
     match fork() with
       0 ->
         execv "/bin/sh" [|"/bin/sh""-c"Sys.argv.(n) |]
     | _ ->
         let rec wait_for_children retcode =
           try
             match wait() with
               (pidWEXITED n) -> wait_for_children (retcode lor n)
             | (pid_)         -> wait_for_children 127
           with
               Unix_error(ECHILD__) -> retcode in
         exit (wait_for_children 0)
   ;;
   handle_unix_error compose ();;

L’essentiel du travail est fait par la boucle for des lignes 6–18. Pour chaque commande sauf la dernière, on crée un nouveau tuyau, puis un nouveau processus. Le nouveau processus (le fils) relie l’entrée du tuyau à sa sortie standard, et exécute la commande. Il hérite l’entrée standard du processus père au moment du fork. Le processus principal (le père) relie la sortie du tuyau à son entrée standard, et continue la boucle. Supposons (hypothèse de récurrence) que, au début du ième tour de boucle, la situation soit la suivante:

(Les carrés représentent des processus, avec leur entrée standard à gauche et leur sortie standard à droite. Les deux ellipses représentent l’entrée standard et la sortie standard initiales du processus compose.) Après pipe et fork, la situation est la suivante:

Le père appelle dup2; on obtient:

Le fils appelle dup2 puis exec; on obtient:

Tout est prêt pour le prochain tour de boucle.

L’exécution de la dernière commande est faite en dehors de la boucle, parce qu’il n’y a pas besoin de créer un nouveau tuyau: le processus compose a déjà la bonne entrée standard (la sortie de l’avant-dernière commande) et la bonne sortie standard (celle fournie initialement à la commande compose); il suffit donc de faire fork et exec du côté du processus fils. Le processus père se met alors à attendre que les fils terminent: on fait wait de manière répétée jusqu’à ce que l’erreur ECHILD (plus de fils à attendre) se déclenche. On combine entre eux les codes de retour des processus fils par un “ou” bit-à-bit (opérateur lor) pour fabriquer un code de retour raisonnable pour compose: zéro si tous les fils ont renvoyé zéro, non nul sinon.

Remarque: si les exécutions des commandes cmdi passent par l’intermédiaire de /bin/sh, c’est pour le cas où l’on fait par exemple

     
   compose "grep foo" "wc -l"

L’appel à /bin/sh assure le découpage de chaque commande complexe en mots. (On aurait pu le faire soi-même comme dans l’exemple du mini shell, mais c’est pénible.)

5.6  Multiplexage d’entrées-sorties

Dans les exemples qu’on a vus jusqu’ici, les processus communiquent de façon “linéaire”. En particulier, un processus lit des données en provenance d’au plus un autre processus. Les situations où un processus doit lire des données en provenance de plusieurs processus posent des problèmes supplémentaires, comme on va le voir maintenant.

On considère l’exemple d’un émulateur de terminal multi-fenêtres sur un micro-ordinateur. C’est-à-dire, on a un micro-ordinateur relié à une machine Unix par une liaison série, et on cherche à faire émuler par le micro-ordinateur plusieurs terminaux, affichant chacun dans une fenêtre différente sur l’écran du micro, et reliés à des processus différents sur la machine Unix. Par exemple, une fenêtre peut être reliée à un shell, et une autre à un éditeur de textes. Les sorties du shell s’affichent sur la première fenêtre, les sorties de l’éditeur, sur la seconde; les caractères entrés au clavier du micro sont envoyés sur l’entrée standard du shell si la première fenêtre est active, ou sur l’entrée standard de l’éditeur si la seconde est active.

Comme il n’y a qu’une liaison physique entre le micro et la machine Unix, il va falloir multiplexer les liaisons virtuelles fenêtre/processus, c’est-à-dire entrelacer les transmissions de données. Voici le protocole qu’on va utiliser. Transitent sur la liaison série des paquets de la forme suivante:

Voici comment les choses se passent du côté de la machine Unix. Les processus utilisateur (shell, éditeur, etc.) vont être reliés par des tuyaux à un ou plusieurs processus auxiliaires, qui lisent et écrivent sur la liaison série, et effectuent le multiplexage et le démultiplexage. La liaison série se présente sous la forme d’un fichier spécial (/dev/ttya, par exemple), sur lequel on fait read et write pour recevoir ou envoyer des octets au micro-ordinateur.

Le démultiplexage (transmission dans le sens micro vers processus) ne pose pas de difficultés. Il suffit d’avoir un processus qui lit des paquets depuis la liaison série, puis écrit les données lues sur le tuyau relié à l’entrée standard du processus utilisateur destinataire.

Le multiplexage (transmission dans le sens processus vers micro) est plus délicat. Essayons l’approche symétrique du démultiplexage: un processus lit successivement sur les tuyaux reliés aux sorties standard des processus utilisateur, puis met les données lues sous forme de paquets (en ajoutant le numéro de la fenêtre destinataire et la taille du bloc de données lu) et les écrit sur la liaison série.

Cette approche ne marche pas, car les lectures sur tuyaux sont bloquantes. Par exemple, si on décide de lire sur la sortie du shell, mais que le shell n’affiche rien pendant ce temps, le processus multiplexeur reste bloqué; et si pendant ce temps l’éditeur affiche des caractères, ces caractères ne seront pas transmis au micro-ordinateur. Il n’y a aucun moyen de savoir à l’avance quels sont les tuyaux sur lesquels il y a effectivement des données en attente d’affichage. (En algorithmique parallèle, cette situation où un processus se voit refuser indéfiniment l’accès à une ressource partagée est connue sous le nom de “situation de famine”.)

Essayons une deuxième approche: à chaque processus utilisateur est associé un processus “répéteur”, qui lit la sortie standard du processus utilisateur par l’intermédiaire d’un tuyau, la met sous forme de paquet, et écrit directement le paquet sur la liaison série. (Chaque processus répéteur a ouvert /dev/ttya en écriture.)

Les situations de blocage ne sont plus à craindre, puisque chaque processus utilisateur a sa sortie retransmise indépendamment des autres processus utilisateur. En revanche, le protocole n’est pas forcément respecté. Deux répéteurs peuvent en effet écrire deux paquets au même instant (ou presque). Or, le noyau Unix ne garantit pas que les écritures sont atomiques, c’est-à-dire effectuées en une seule opération ininterruptible. Le noyau peut très bien transmettre à /dev/ttya une partie du paquet écrit par le premier répéteur, puis le paquet écrit par le second répéteur, puis le reste du premier paquet. Ceci va plonger le démultiplexeur qui est à l’autre bout de la liaison dans la plus extrême confusion: il va prendre le deuxième paquet pour une partie des données du premier paquet, puis il va essayer d’interpréter le reste des données du premier paquet comme un en-tête de paquet.

Pour éviter cette situation, il faut que les processus répéteurs se synchronisent pour assurer que, à tout instant, au plus un répéteur est en train d’écrire sur la liaison série. (En algorithmique parallèle, on dit qu’il faut assurer l’exclusion mutuelle entre les processus qui accèdent à la ressource partagée.) On peut le faire avec les moyens qu’on a vu jusqu’ici: les répéteurs créent un fichier donné (le “verrou”) avec l’option O_EXCL de openfile avant d’écrire un paquet, et le détruisent après avoir écrit le paquet. Cette méthode n’est pas très efficace, en raison du coût de création et de destruction du fichier verrou.

Une troisième approche consiste à reprendre la première approche (un seul processus multiplexeur), en mettant en mode “non bloquant” les descripteurs ouverts sur les tuyaux reliés aux sorties standard des processus utilisateur. (Le passage en mode “non bloquant” se fait par une des options de l’appel système fcntl, qu’on ne décrira pas dans ce cours.) En mode “non bloquant”, les opérations de lecture sur un tuyau vide ne bloquent pas, mais retournent immédiatement avec une erreur. Il suffit d’ignorer cette erreur et de passer à la lecture sur le prochain processus utilisateur. Cette approche empêche la famine et ne pose pas de problèmes d’exclusion mutuelle, mais se révèle très inefficace. En effet, le processus multiplexeur fait ce qu’on appelle de l’attente active: il consomme du temps de calcul même s’il n’y a rien à faire — par exemple, si les processus utilisateur n’envoient rien sur leur sortie pendant un certain temps. Bien entendu, on peut diminuer la fréquence des tentatives de lecture en introduisant par exemple des appels sleep dans la boucle de lecture; mais il est très difficile de trouver la bonne fréquence: celle qui charge suffisamment peu la machine lorsqu’il n’y a rien à retransmettre, mais qui n’introduit pas de délai perceptible dans la transmission lorsqu’il y a beaucoup à retransmettre.

On le voit, il y a là un problème sérieux. Pour le résoudre, les concepteurs de BSD Unix ont introduit un nouvel appel système, select, qui se trouve maintenant sur la plupart des variantes d’Unix. L’appel select permet à un processus de se mettre en attente (passive) d’un ou plusieurs événements d’entrée-sortie. Les événements possibles sont:

     
   val select :
       file_descr list -> file_descr list -> file_descr list ->
         float -> file_descr list * file_descr list * file_descr list

Les trois premiers arguments sont des ensembles de descripteurs (représentés par des listes): le premier argument est l’ensemble des descripteurs à surveiller en lecture; le deuxième argument est l’ensemble des descripteurs à surveiller en écriture; le troisième argument est l’ensemble des descripteurs à surveiller en exception. Le quatrième argument est un délai maximal d’attente, exprimé en secondes. S’il est positif ou nul, l’appel select va rendre la main au bout de ce temps, même si aucun événement ne s’est produit. S’il est strictement négatif, l’appel select attend indéfiniment qu’un des événements demandés se produise.

L’appel select renvoie un triplet de listes de descripteurs: les descripteurs effectivement prêts en lecture (première composante), en écriture (deuxième composante), ou sur lesquels une condition exceptionnelle s’est produite (troisième composante). Si le délai maximal s’est écoulé sans aucun événement, les trois listes sont vides.

Exemple:

Le fragment de code ci-dessous surveille en lecture les deux descripteurs fd1 et fd2, et reprendre la main au bout de 0,5 secondes.

     
   match select [fd1;fd2] [] [] 0.5 with
     [],[],[] -> (* le délai de 0,5s est écoulé *)
   | fdl,[],[] ->
       if List.mem fd1 fdl then
            (* lire depuis fd1 *);
       if List.mem fd2 fdl then
            (* lire depuis fd2 *)

Exemple:

La fonction multiplex ci-dessous est le coeur du multiplexeur/démultiplexeur pour l’émulateur de terminaux virtuels décrit plus haut. (Pour simplifier, le multiplexeur se contente d’étiqueter les messages en fonction de leur provenance et le démultiplexeur suppose que les messages sont étiquetés en fonction de leur destinataire: on suppose donc soit que chaque émetteur parle à une destinataire de même numéro, soit qu’au milieu de la ligne on établit la correspondance émetteur destinataire en ré-étiquetant les messages.)

La fonction multiplex prend un descripteur ouvert sur la liaison série, et deux tableaux de descripteurs de même taille, l’un contenant les tuyaux reliés aux entrées standard des processus utilisateur, l’autre les tuyaux reliés à leurs sorties standard.

     
   open Unix;;
   
   let rec really_read fd buff start length =
     if length <= 0 then () else
       match read fd buff start length with
         0 -> raise End_of_file
       | n -> really_read fd buff (start+n) (length-n);;
   
   let buffer = String.create 258;;
   
   let multiplex channel inputs outputs =
     let input_fds = channel :: Array.to_list inputs in
     try
       while true do
         let (ready_fds__) = select input_fds [] [] (-1.0) in
         for i = 0 to Array.length inputs - 1 do
           if List.mem inputs.(iready_fds then begin
             let n = read inputs.(ibuffer 2 255 in
             buffer.[0] <- char_of_int i;
             buffer.[1] <- char_of_int n;
             ignore (write channel buffer 0 (n+2));
             ()
           end
         done;
         if List.mem channel ready_fds then begin
           really_read channel buffer 0 2;
           let i = int_of_char(buffer.[0])
           and n = int_of_char(buffer.[1]) in
           if n = 0 then
             close outputs.(i)
           else begin
             really_read channel buffer 0 n;
             ignore (write outputs.(ibuffer 0 n);
             ()
           end
         end
       done
     with End_of_file ->
       ()
   ;;

La fonction multiplex commence par construire un ensemble de descripteurs (input_fds) contenant les descripteurs d’entrée (ceux qui sont connectés aux sorties standard des processus utilisateur), plus le descripteur sur la liaison série. À chaque tour de la boucle while true, on appelle select pour surveiller en lecture input_fds. On ne surveille aucun descripteur en écriture ni en exception, et on ne borne pas le temps d’attente. Lorsque select retourne, on teste s’il y a des données en attente sur un des descripteurs d’entrée, ou sur le canal de communication.

S’il y a des données sur une des entrées, on fait read sur cette entrée, on ajoute un en-tête aux données lues, et on écrit le paquet obtenu sur le canal. Si read renvoie zéro, ceci indique que le tuyau correspondant a été fermé. Le programme à l’autre bout de la liaison série va recevoir un paquet contenant zéro octets de données, ce qu’il va interpréter comme “le processus utilisateur numéro tant est mort”; il peut alors fermer la fenêtre correspondante.

S’il y a des données sur le canal, on lit d’abord l’en-tête, ce qui donne le numéro i de la sortie destinataire et le nombre n d’octets à lire; puis on lit les n octets sur le canal, et on les écrit sur la sortie numéro i. Cas particulier: si n vaut 0, on ferme la sortie correspondante. L’idée est que l’émulateur de terminal à l’autre bout de la liaison va envoyer un paquet avec n = 0 pour signifier une fin de fichier sur l’entrée standard du processus correspondant.

On sort de la boucle quand really_read déclenche une exception End_of_file, c’est-à-dire quand la fin de fichier est atteinte sur la liaison série.

5.7  Miscelleaneous: write

La commande Unix.write itère l’appel système write jusqu’à ce que la quantité demandée soit effectivement la quantité transférée. Lorsque le descripteur est un tuyau (ou une prise que l’on verra dans le chapitre suivant), l’écriture est par défaut bloquante. L’appel système write peut donc être interrompu en présence de signaux. L’erreur EINTR est alors remontée vers OCaml, alors qu’une partie des données a déjà pu être transférée par un appel précédent à write. La taille des données transférée est alors perdue, irrémédiablement, ce qui rend Unix.write inutilisable en présence de signaux.

Pour remédier à ce problème, OCaml relève également l’appel système write sous le nom single_write. Nous montrons sur cet exemple comment relever un appel système en OCaml. Il s’agit en fait simplement d’interfacer OCaml avec du code C et on pourra se reporter à la section correspondante du manuel OCaml. Le code suivant est écrit dans un fichier single_write.c.

     
   #include <errno.h>
   #include <string.h>
   #include <caml/mlvalues.h>
   #include <caml/memory.h>
   #include <caml/signals.h>
   #include "unixsupport.h"
   
   CAMLprim value caml_single_write
           (value fdvalue bufvalue vofsvalue vlen) {
     CAMLparam4(fdbufvofsvlen);
     long ofslen;
     int numbytesret;
     char iobuf[UNIX_BUFFER_SIZE];
     ofs = Long_val(vofs)
     len = Long_val(vlen)
     ret = 0;
     if (len > 0) {
       numbytes = len > UNIX_BUFFER_SIZE ? UNIX_BUFFER_SIZE : len;
       memmove (iobuf, &Byte(bufofs), numbytes);
       enter_blocking_section();
       ret = write(Int_val(fd), iobufnumbytes);
       leave_blocking_section();
       if (ret == -1) uerror("single_write"Nothing);
     }
     CAMLreturn (Val_int(ret));
   }

Les deux premières lignes incluent des bibliothèques C standards. Les trois suivantes incluent des bibliothèques C spécifiques à OCaml et installées avec. La bibliothèque "unixsupport.h" qui permet de réutiliser certaines fonctions C d’Unix comme le traitement d’erreur, etc. n’est pas installée par défaut et il faut la récupérer dans les sources de la distribution.

La ligne la plus significative est l’appel à write (ligne 21). Comme celui-ci est bloquant (si le descripteur est un tuyau ou une chaussette), il faut relâcher le verrou OCaml immédiatement avant l’appel et le reprendre après (lignes 20 et 22), afin d’être compatible avec la bibliothèque Thread et de permettre éventuellement à d’autres coprocessus de s’exécuter pendant l’attente (voir le Chapitre 7). OCaml peut donc effectuer un GC pendant l’exécution de l’appel système, ce qui oblige à se prémunir contre un déplacement de la chaîne OCaml buf en la recopiant dans une chaîne C iobuf. Cela a un coût supplémentaire, mais seulement de l’ordre de 10% et non de l’ordre de 50% comme on pourrait s’y attendre, car la gestion de l’appel système et des structures systèmes liés à la copie gardent un coût prépondérant.

La taille de cette chaîne est bornée par UNIX_BUFFER_SIZE, afin d’éviter des à coups inutiles, définie dans unix_support.h. Une erreur pendant l’appel système, signalée par une valeur de retour strictement négative, est propagée vers OCaml par la fonction uerror, définie dans la bibliothèque Unix.

Pour remonter ce code en OCaml, le fichier unix.mli déclare

     
   external unsafe_single_write :
     file_descr -> string -> int -> int -> int = "caml_single_write"

En pratique, on vérifie les arguments avant d’appeler cette fonction.

     
   let single_write fd buf ofs len =
     if ofs < 0 || len < 0 || ofs > String.length buf - len
     then invalid_arg "Unix.write"
     else unsafe_single_write fd buf ofs len

Cette fonction est disponible en OCaml depuis la version 3.08. Autrement, pour utiliser cette fonction dans un programme main.ml, en supposant que l’on ait écrit le code précédant dans des fichiers write.mli et write.ml, il aurait fallu compiler comme suit:

     
   ocamlc -c single_write.c write.ml
   ocamlc -custom -o prog unix.cma single_write.o write.cmo mod1.ml mod2.ml

Il est souvent plus pratique de fabriquer une bibliothèque write.cma réunissant le code C et OCaml:

     
   ocamlc -custom -a -o write.cma single_write.o write.cmo

On peut alors utiliser write.cma simplement comme on utilise unix.cma:

     
   ocamlc -o main.byte unix.cma write.cma main.ml

La fonction single_write reproduit l’appel write aussi fidèlement que possible. La seule différence reste que lorsque la chaîne d’origine est très longue, l’appel est autoritairement découpé en plusieurs appels. L’atomicité de l’appel à write (garantie dans le cas des fichiers réguliers) n’est donc pas préservée pour des écritures très longues. Cette différence est assez marginale, mais il faut en être conscient.

Au dessus de cet appel nous pouvons réimplanter une fonction de plus haut niveau really_write analogue à really_read qui écrit effectivement la quantité demandée.

     
   let rec really_write fd buffer offset len =
     let n = restart_on_EINTR (single_write fd buffer offsetlen in
     if n < len then really_write fd buffer (offset + n) (len - n);;

Chapter 6  Communications modernes: les prises

La communication par tuyaux présente certaines insuffisances. Tout d’abord, la communication est locale à une machine: les processus qui communiquent doivent tourner sur la même machine (cas des tuyaux nommés), voire même avoir le créateur du tuyau comme ancêtre commun (cas des tuyaux normaux). D’autre part, les tuyaux ne sont pas bien adaptés à un modèle particulièrement utile de communication, le modèle dit par connexions, ou modèle client-serveur. Dans ce modèle, un seul programme (le serveur) accède directement à une ressource partagée; les autres programmes (les clients) accèdent à la ressource par l’intermédiaire d’une connexion avec le serveur; le serveur sérialise et réglemente les accès à la ressource partagée. (Exemple: le système de fenêtrage X-window — les ressources partagées étant ici l’écran, le clavier, la souris et le haut-parleur.) Le modèle client-serveur est difficile à implémenter avec des tuyaux. La grande difficulté, ici, est l’établissement de la connexion entre un client et le serveur. Avec des tuyaux non nommés, c’est impossible: il faudrait que les clients et le serveur aient un ancêtre commun ayant alloué à l’avance un nombre arbitrairement grand de tuyaux. Avec des tuyaux nommés, on peut imaginer que le serveur lise des demandes de connexions sur un tuyau nommé particulier, ces demandes de connexions pouvant contenir le nom d’un autre tuyau nommé, créé par le client, à utiliser pour communiquer directement avec le client. Le problème est d’assurer l’exclusion mutuelle entre les demandes de connexion provenant de plusieurs clients en même temps.

Les prises (traduction libre de sockets) sont une généralisation des tuyaux qui pallie ces faiblesses. Le modèle du client serveur avec prises (en mode connecté) est décrit dans la figure 6.1


Figure 6.1: Modèle Client-Serveur

  1. Le serveur U créé une prise s et la branche sur un port p connu des clients puis attend les connexions sur sa prise (1).
  2. Un client A créé une prise et se connecte au serveur sur le port p (2). Le système alloue alors une nouvelle prise pour communiquer en privé avec le client A (3). Dans le schéma choisi ici, il se duplique en un serveur auxiliaire V (4), ferme sa prise avec le client A (en trait haché), et laisse son fils V traiter la connexion avec A (5).
  3. Le serveur peut alors accepter un nouveau client B, établir une autre connexion en parallèle servie par un autre clone W (6), et ainsi de suite.
  4. Le serveur peut fermer son service en fermant le descripteur associé à la prise s. Au bout d’un certain temps le système libère le port p qui peut alors être réutilisé, par exemple pour y installer un autre service.

Il est essentiel dans ce modèle que le serveur U et le client A aient établi une connexion privée (3) pour dialoguer jusqu’à la fin de la connexion, sans interférence avec d’autres requêtes provenant d’autres clients. Pour cette raison, on dit que l’on fonctionne en mode connecté. Si le service est court, le serveur pourrait lui-même servir la requête directement (sans se cloner) au travers de la connexion (3). Dans ce cas, le client suivant doit attendre que le serveur soit libre, soit parce qu’il a effectivement fini de traiter la connexion (3), soit parce qu’il gère explicitement plusieurs connexions par multiplexage.

Les prises permettent également un modèle client-serveur en mode déconnecté. Dans ce cas, moins fréquent, le serveur n’établit pas une connexion privée avec le client, mais répond directement à chaque requête du client. Nous verrons brièvement comment procéder selon ce modèle dans la section 6.10.

6.1  Les prises

Le mécanisme des prises, qui étend celui des tuyaux, a été introduit en BSD 4.2, et se retrouve maintenant sur toutes les machines Unix connectées à un réseau (Ethernet ou autre). Tout d’abord, des appels systèmes spéciaux sont fournis pour établir des connexions suivant le modèle client-serveur. Ensuite, les prises permettent la communication locale ou à travers le réseau entre processus tournant sur des machines différentes de façon (presque) transparente. Pour cela, plusieurs domaines de communication sont pris en compte. Le domaine de communication associé à une prise indique avec qui on peut communiquer via cette prise; il conditionne le format des adresses employées pour désigner le correspondant. Deux exemples de domaines:

Enfin, plusieurs sémantiques de communication sont prises en compte. La sémantique indique en particulier si la communication est fiable (pas de pertes ni de duplication de données), et sous quelle forme les données se présentent au récepteur (flot d’octets, ou flot de paquets — petits blocs d’octets délimités). La sémantique conditionne le protocole utilisé pour la transmission des données. Voici trois exemples de sémantiques possibles:

 FlotsDatagrammePaquet segmentés
Fiableouinonoui
Forme des donnéesflot d’octetspaquetspaquets

La sémantique par “flot” est très proche de celle de la communication par tuyaux. C’est la plus employée, en particulier lorsqu’il s’agit de retransmettre des suites d’octets sans structure particulière (exemple: rsh). La sémantique par “paquets segmentés” structure les données transmises en paquets: chaque écriture délimite un paquet, chaque lecture lit au plus un paquet. Elle est donc bien adaptée à la communication par messages. La sémantique par “datagrammes” correspond au plus près aux possibilités hardware d’un réseau de type Ethernet: les transmissions se font par paquets, et il n’est pas garanti qu’un paquet arrive à son destinataire. C’est la sémantique la plus économique en termes d’occupation du réseau. Certains programmes l’utilisent pour transmettre des données sans importance cruciale (exemple: biff); d’autres, pour tirer plus de performances du réseau, étant entendu qu’ils doivent gérer eux-mêmes les pertes.

6.2  Création d’une prise

L’appel système socket permet de créer une nouvelle prise:

     
   val socket : socket_domain -> socket_type -> int -> file_descr

Le résultat est un descripteur de fichier qui représente la nouvelle prise. Ce descripteur est initialement dans l’état dit “non connecté”; en particulier, on ne peut pas encore faire read ou write dessus.

Le premier argument indique le domaine de communication auquel la prise appartient:

PF_UNIXle domaine Unix
PF_INETle domaine Internet

Le deuxième argument indique la sémantique de communication désirée:

SOCK_STREAMflot d’octets, fiable
SOCK_DGRAMpaquets, non fiable
SOCK_RAWaccès direct aux couches basses du réseau
SOCK_SEQPACKETpaquets, fiable

Le troisième argument est le numéro du protocole de communication à utiliser. Il vaut généralement 0, ce qui désigne le protocole par défaut, généralement déterminé à partir du type de communication (typiquement, SOCK_DGRAM et SOCK_STREAM sont associés aux protocoles udp et tcp). D’autres valeurs donnent accès à des protocoles spéciaux. Exemple typique: le protocole ICMP (Internet Control Message Protocol), qui est le protocole utilisé par la commande ping pour envoyer des paquets avec retour automatique à l’envoyeur. Les numéros des protocoles spéciaux se trouvent dans le fichier /etc/protocols ou dans la table protocols du système d’informations réparti NIS (Network Information Service), le cas échéant. L’appel système getprotobyname permet de consulter cette table de manière portable:

     
   val getprotobyname : string -> protocol_entry

L’argument est le nom du protocole désiré. Le résultat est un type record comprenant, entre autres, un champ p_proto qui est le numéro du protocole.

6.3  Adresses

Un certain nombre d’opérations sur les prises utilisent des adresses de prises. Ce sont des valeurs du type concret sockaddr:

     
   type sockaddr =
       ADDR_UNIX of string
     | ADDR_INET of inet_addr * int

L’adresse ADDR_UNIX(f) est une adresse dans le domaine Unix. La chaîne f est le nom du fichier correspondant dans la hiérarchie de fichiers de la machine.

L’adresse ADDR_INET(a,p) est une adresse dans le domaine Internet. Le premier argument, a, est l’adresse Internet d’une machine; le deuxième argument, p, est un numéro de service (port number) à l’intérieur de cette machine.

Les adresses Internet sont représentées par le type abstrait inet_addr. Deux fonctions permettent de convertir des chaînes de caractères de la forme 128.93.8.2 en valeurs du type inet_addr, et réciproquement:

     
   val inet_addr_of_string : string -> inet_addr
   val string_of_inet_addr : inet_addr -> string

Une autre manière d’obtenir des adresses Internet est par consultation de la table /etc/hosts, qui associe des adresses Internet aux noms de machines. On peut consulter cette table ainsi que la base de donnée NIS par la fonction de bibliothèque gethostbyname. Sur les machines modernes, cette fonction interroge les “name servers” soit en cas d’échec, soit au contraire de façon prioritaire, n’utilisant alors le fichier /etc/hosts qu’en dernier recours.

     
   val gethostbyname : string -> host_entry

L’argument est le nom de la machine désirée. Le résultat est un type record comprenant, entre autres, un champ h_addr_list, qui est un vecteur d’adresses Internet: les adresses de la machine. (Une même machine peut être reliée à plusieurs réseaux, sous des adresses différentes.)

Pour ce qui est des numéros de services (port numbers), les services les plus courants sont répertoriés dans la table /etc/services. On peut la consulter de façon portable par la fonction

     
   val getservbyname : string -> string -> service_entry

Le premier argument est le nom du service (par exemple, ftp pour le serveur FTP, smtp pour le courrier, nntp pour le serveur de News, talk et ntalk pour les commandes du même nom, etc.). Le deuxième argument est le nom du protocole: généralement, tcp si le service utilise des connexions avec la sémantique “stream”, ou udp si le service utilise des connexions avec la sémantique “datagrams”. Le résultat de getservbyname est un type record dont le champ s_port contient le numéro désiré.

Exemple:

pour obtenir l’adresse du serveur FTP de pauillac.inria.fr:

     
   ADDR_INET((gethostbyname "pauillac.inria.fr").h_addr_list.(0),
             (getservbyname "ftp" "tcp").s_port)

6.4  Connexion à un serveur

L’appel système connect permet d’établir une connexion avec un serveur à une adresse donnée.

     
   val connect : file_descr -> sockaddr -> unit

Le premier argument est un descripteur de prise. Le deuxième argument est l’adresse du serveur auquel on veut se connecter.

Une fois connect effectué, on peut envoyer des données au serveur en faisant write sur le descripteur de la prise, et lire les données en provenance du serveur en faisant read sur le descripteur de la prise. Lectures et écritures sur une prise se comportent comme sur un tuyau: read bloque tant qu’aucun octet n’est disponible, et peut renvoyer moins d’octets que demandé; et si le serveur a fermé la connexion, read renvoie zéro et write déclenche un signal SIGPIPE.

Un effet de connect est de brancher la prise à une adresse locale qui est choisie par le système. Parfois, il est souhaitable de choisir soi-même cette adresse, auquel cas il est possible d’appeler l’opération bind (voir ci-dessous) avant connect.

Pour suivre les connexions en cours cours on peut utiliser la commande netstat depuis un shell.

6.5  Déconnexion

Il y a deux manières d’interrompre une connexion. La première est de faire close sur la prise. Ceci ferme la connexion en écriture et en lecture, et désalloue la prise. Ce comportement est parfois trop brutal. Par exemple, un client peut vouloir fermer la connexion dans le sens client vers serveur, pour transmettre une fin de fichier au serveur, mais laisser la connexion ouverte dans le sens serveur vers client, pour que le serveur puisse finir d’envoyer les données en attente. L’appel système shutdown permet ce genre de coupure progressive de connexions.

     
   val shutdown : file_descr -> shutdown_command -> unit

Le premier argument est le descripteur de la prise à fermer. Le deuxième argument peut être:

SHUTDOWN_RECEIVEferme la prise en lecture; write sur l’autre bout de la connexion va déclencher un signal SIGPIPE
SHUTDOWN_SENDferme la prise en écriture; read sur l’autre bout de la connexion va renvoyer une marque de fin de fichier
SHUTDOWN_ALLferme la prise en lecture et en écriture; à la différence de close, la prise elle-même n’est pas désallouée

En fait, la désallocation d’une prise peut prendre un certain temps que celle-ci soit faite avec politesse ou brutalité.

6.6  Exemple complet: Le client universel

On va définir une commande client telle que client host port établit une connexion avec le service de numéro port sur la machine de nom host, puis envoie sur la connexion tout ce qu’il lit sur son entrée standard, et écrit sur sa sortie standard tout ce qu’il reçoit sur la connexion.

Par exemple, la commande

     
   echo -e 'GET /~remyHTTP/1.0\r\n\r\n' | ./client pauillac.inria.fr 80

se connecte sur le port 80 et pauillac.inria.fr et envoie la commande HTTP qui demande la page web d’accueil /~remy/ sur ce serveur.

Cette commande constitue une application client “universel”, dans la mesure où elle regroupe le code d’établissement de connexions qui est commun à beaucoup de clients, tout en déléguant la partie implémentation du protocole de communication, propre à chaque application au programme qui appelle client.

Nous utilisons une fonction de bibliothèque retransmit qui recopie les données arrivant d’un descripteur sur un autre descripteur. Elle termine lorsque la fin de fichier est atteinte sur le descripteur d’entrée, sans refermer les descripteurs. Notez que retransmit peut-être interrompue par un signal.

     
   let retransmit fdin fdout =
     let buffer_size = 4096 in
     let buffer = String.create buffer_size in
     let rec copy() =
       match read fdin buffer 0 buffer_size with
         0 -> ()
       | n -> ignore (write fdout buffer 0 n); copy() in
     copy ();;

Les choses sérieuses commencent ici.

     
   open Sys;;
   open Unix;;
   
   let client () =
     if Array.length Sys.argv < 3 then begin
       prerr_endline "Usage: client <host> <port>";
       exit 2;
     end;
     let server_name = Sys.argv.(1)
     and port_number = int_of_string Sys.argv.(2) in
     let server_addr =
       try (gethostbyname server_name).h_addr_list.(0)
       with Not_found ->
         prerr_endline (server_name ^ ": Host not found");
         exit 2 in
     let sock = socket PF_INET SOCK_STREAM 0 in
     connect sock (ADDR_INET(server_addrport_number));
     match fork() with
       0 ->
         Misc.retransmit stdin sock;
         shutdown sock SHUTDOWN_SEND;
         exit 0
     | _ ->
         Misc.retransmit sock stdout;
         close stdout;
         wait();;
   
   handle_unix_error client ();;

On commence par déterminer l’adresse Internet du serveur auquel se connecter. Elle peut être donnée (dans le premier argument de la commande) ou bien sous forme numérique, ou bien sous forme d’un nom de machine. La commande gethostbyname traite correctement les deux cas de figure. Dans le cas d’une adresse symbolique, la base /etc/hosts est interrogée et on prend la première des adresses obtenues. Dans le cas d’une adresse numérique aucune vérification n’est effectuée: une structure est simplement créée pour l’adresse demandée.

Ensuite, on crée une prise dans le domaine Internet, avec la sémantique “stream” et le protocole par défaut, et on la connecte à l’adresse indiquée.

On clone alors le processus par fork. Le processus fils recopie les données de l’entrée standard vers la prise; lorsque la fin de fichier est atteinte sur l’entrée standard, il ferme la connexion en écriture, transmettant ainsi une fin de fichier au serveur, et termine. Le processus père recopie sur la sortie standard les données lues sur la prise. Lorsqu’une fin de fichier est détectée sur la prise, il se synchronise avec le processus fils par wait, et termine.

La fermeture de la connexion peut se faire à l’initiative du client ou du serveur.

Si le client, fils ou père, termine prématurément la prise sera fermée en écriture ou en lecture. Si le serveur détecte cette information, il ferme l’autre bout de la prise, ce que l’autre partie du client va détecter. Sinon, le serveur termine normalement en fermant la connexion. Dans les deux cas, on se retrouve également dans l’un des scénarios précédents.

6.7  Établissement d’un service

On vient de voir comment un client se connecte à un serveur; voici maintenant comment les choses se passent du côté du serveur. La première étape est d’associer une adresse à une prise, la rendant ainsi accessible depuis l’extérieur. C’est le rôle de l’appel bind:

     
   val bind : file_descr -> sockaddr -> unit

Le premier argument est le descripteur de la prise; le second, l’adresse à lui attribuer. La commande bind peut aussi utiliser une adresse spéciale inet_addr_any représentant toutes les adresses internet possibles sur la machine (qui peut comporter plusieurs sous-réseaux).

Dans un deuxième temps, on déclare que la prise peut accepter les connexions avec l’appel listen:

     
   val listen : file_descr -> int -> unit

Le premier argument est le descripteur de la prise. Le second indique combien de demandes de connexion incomplètes peuvent être mises en attente. Sa valeur, souvent de l’ordre de quelques dizaines peut aller jusqu’à quelques centaines pour des serveurs très sollicités. Lorsque ce nombre est dépassé, les demandes de connexion excédentaires échouent.

Enfin, on reçoit les demandes de connexion par l’appel accept:

     
   val accept : file_descr -> file_descr * sockaddr

L’argument est le descripteur de la prise. Le premier résultat est un descripteur sur une prise nouvellement créée, qui est reliée au client: tout ce qu’on écrit sur ce descripteur peut être lu sur la prise que le client a donné en argument à connect, et réciproquement. Du côté du serveur, la prise donnée en argument à accept reste libre et peut accepter d’autres demandes de connexion. Le second résultat est l’adresse du client qui se connecte. Il peut servir à vérifier que le client est bien autorisé à se connecter; c’est ce que fait le serveur X par exemple (xhost permettant d’ajouter de nouvelles autorisations) ou à établir une seconde connexion du serveur vers le client (comme le fait ftp pour chaque demande de transfert de fichier).

Le schéma général d’un serveur tcp est donc de la forme suivante (nous définissons ces fonctions dans la bibliothèque Misc).

     
   let install_tcp_server_socket addr =
     let s = socket PF_INET SOCK_STREAM 0 in
     try
       bind s addr;
       listen s 10;
       s
     with z -> close sraise z;;
     
   let tcp_server treat_connection addr =
     ignore (signal sigpipe Signal_ignore);
     let server_sock = install_tcp_server_socket addr in
     while true do
         let client = restart_on_EINTR accept server_sock in
         treat_connection server_sock client
     done;;

La fonction install_tcp_server_socket commence par créer une prise dans le domaine Internet, avec la sémantique «stream» et le protocole par défaut (ligne 2), puis il la prépare à recevoir des demandes de connexion sur le port indiqué sur la ligne de commande par les appels bind et listen des lignes 4 et 5. Comme il s’agit d’une fonction de bibliothèque, nous refermons proprement la prise en cas d’erreur lors de l’opération bind ou listen. La fonction tcp_server crée la prise avec la fonction précédente, puis entre dans une boucle infinie, où elle attend une demande de connexion (accept, ligne 12) et traite celle-ci (ligne 13). Comme il s’agit d’une fonction de bibliothèque, nous avons pris soin de relancer l’appel système accept (bloquant) en cas d’interruption. Notez qu’il appartient à la fonction treat_connection de fermer le descripteur client en fin de connexion y compris lorsque la connexion se termine de façon brutale. Nous ignorons le signal sigpipe afin qu’une déconnexion prématurée d’un client lève une exception EPIPE récupérable par treat_connection plutôt que de tuer le processus brutalement.

La fonction treat_connection reçoit également le descripteur du serveur car dans le cas d’un traitement par fork ou double_fork, celui-ci devra être fermé par le fils.

Le traitement d’une connexion peut se faire séquentiellement, i.e. par le serveur lui même. Dans ce cas, treat_connection se contente d’appeler une fonction service, entièrement dépendante de l’application, qui est le corps du serveur et qui exécute effectivement le service demandé et se termine par la fermeture de la connexion.

     
   let service (client_sockclient_addr) =
     (* Communiquer avec le client sur le descripteur descr *)
     (* Puis quand c'est fini: *)
     close client_descr;;

D’où la fonction auxiliaire (que nous ajoutons à la bibliothèque Misc):

     
   let sequential_treatment server service client = service client

Comme pendant le traitement de la connexion le serveur ne peut pas traiter d’autres demandes, ce schéma est en général réservé à des services rapides, où la fonction service s’exécute toujours en un temps cours et borné (par exemple, un serveur de date).

La plupart des serveurs sous-traitent le service à un processus fils: On appelle fork immédiatement après le retour de accept. Le processus fils traite la connexion. Le processus père recommence aussitôt à faire accept. Nous obtenons la fonction de bibliothèque suivante:

     
   let fork_treatment server service (client_descr_ as client) =
     let treat () =
       match fork() with
       | 0 -> close serverservice clientexit 0
       | k -> () in
     try_finalize treat () close client_descr;;

Notez qu’il est essentiel de fermer le descripteur client_descr du père, sinon sa fermeture par le fils ne suffira pas à terminer la connexion; de plus, le père va très vite se retrouver à court de descripteurs. Cette fermeture doit avoir lieu dans le cas normal, mais aussi si pour une raison quelconque le fork échoue—le programme peut éventuellement décider que ce n’est pas une erreur fatale et maintenir éventuellement le serveur en service.

De façon symétrique, le fils ferme le descripteur sock sur lequel le service à été établi avant de réaliser le service. D’une part, il n’en a plus besoin. D’autre part, le père peut terminer d’être serveur alors que le fils n’a pas fini de traiter la connexion en cours. La commande exit 0 est importante pour que le fils meurt après l’exécution du service et ne se mette pas à exécuter le code du serveur.

Nous avons ici ignoré pour l’instant la récupération des fils qui vont devenir zombis, ce qu’il faut bien entendu faire. Il y a deux approches. L’approche simple est de faire traiter la connexion par un petit-fils en utilisant la technique du double fork.

     
   let double_fork_treatment server service (client_descr_ as client) =
     let treat () =
       match fork() with
       | 0 ->
           if fork() <> 0 then exit 0;
           close serverservice clientexit 0
       | k ->
           ignore (restart_on_EINTR (waitpid []) kin
     try_finalize treat () close client_descr;;

Toutefois, cette approche fait perdre au serveur tout contrôle sur son petit-fils. En général, il est préférable que le processus qui traite un service appartienne au même groupe de processus que le serveur, ce qui permet de tuer tous les services en tuant les processus du même groupe que le serveur. Pour cela, les serveurs gardent en général le modèle précédent et ajoute une gestion des fils, par exemple en installant une procédure Misc.free_children sur le signal sigchld.

6.8  Réglage des prises

Les prises possèdent de nombreux paramètres internes qui peuvent être réglés: taille des tampons de transfert, taille minimale des transferts, comportement à la fermeture, etc.. Ces paramètres sont de type booléen, entier, entier optionnel ou flottant. Pour des raisons de typage, il existe donc autant de primitives getsockopt, getsockopt_int, getsockopt_optint, getsockopt_float, qui permettent de consulter ces paramètres et autant de variantes de la forme setsockopt, etc.. On pourra consulter l’appendice ?? pour avoir la liste détaillée des options et le manuel Unix (getsockopt) pour leur sens exact.

A titre d’exemple, voici deux types de réglages, qui ne s’appliquent qu’à des prises dans le domaine INET de type SOCK_STREAM. La déconnexion des prises au protocole TCP est négociée, ce qui peut prendre un certain temps. Normalement l’appel close retourne immédiatement, alors que le système négocie la fermeture.

     
   setsockopt_optint sock SO_LINGER (Some 5);;

Cette option rend l’opération close bloquante sur la socket sock jusqu’à ce que les données déjà émises soient effectivement transmises ou qu’un délai de 5 secondes se soit écoulé.

     
   setsockopt sock SO_REUSEADDR;;

L’effet principal de l’option SO_REUSEADDR est de permettre à l’appel système bind de réallouer une prise à une adresse locale sur laquelle toutes les communications sont en cours de déconnexion. Le risque est alors qu’une nouvelle connexion capture les paquets destinés à l’ancienne connexion. Cette option permet entre autre d’arrêter un serveur et de le redémarrer immédiatement, très utile pour faire des tests.

6.9  Exemple complet: le serveur universel

On va maintenant définir une commande server telle que server port cmd arg1 … argn reçoit les demandes de connexion au numéro port, et à chaque connexion lance la commande cmd avec arg1 … argn comme arguments, et la connexion comme entrée et sortie standard.

Par exemple, si on lance

     
   ./server 8500 grep foo

sur la machine pomerol, on peut ensuite faire depuis n’importe quelle machine

     
   ./client pomerol 8500 < /etc/passwd

en utilisant la commande client écrite précédemment, et il s’affiche la même chose que si on avait fait

     
   ./grep foo < /etc/passwd

sauf que grep est exécuté sur pomerol, et non pas sur la machine locale.

La commande server constitue une application serveur “universel”, dans la mesure où elle regroupe le code d’établissement de service qui est commun à beaucoup de serveurs, tout en déléguant la partie implémentation du service et du protocole de communication, propre à chaque application ou programme lancé par server.

     
   open Sys;;
   open Unix;;
   
   let server () =
     if Array.length Sys.argv < 2 then begin
       prerr_endline "Usage: client <port> <command> [arg1 ... argn]";
       exit 2;
     end;
     let port = int_of_string Sys.argv.(1) in
     let args = Array.sub Sys.argv 2 (Array.length Sys.argv - 2) in
     let host = (gethostbyname(gethostname())).h_addr_list.(0) in
     let addr = ADDR_INET (hostportin
     let treat sock (client_sockclient_addr as client) =
       (* log information *)
       begin match client_addr with
         ADDR_INET(caller_) ->
           prerr_endline ("Connection from " ^ string_of_inet_addr caller);
       | ADDR_UNIX _ ->
           prerr_endline "Connection from the Unix domain (???)";
       end;
       (* connection treatment *)
       let service (s_) =
         dup2 s stdindup2 s stdoutdup2 s stderrclose s;
         execvp args.(0) args in
       Misc.double_fork_treatment sock service client in
     Misc.tcp_server treat addr;;
   
   handle_unix_error server ();;

L’adresse fournie à tcp_server contient l’adresse Internet de la machine qui fait tourner le programme; la manière habituelle de l’obtenir (ligne 11) est de chercher le nom de la machine (renvoyé par l’appel gethostname) dans la table /etc/hosts. En fait, il existe en général plusieurs adresses pour accéder à une machine. Par exemple, l’adresse de la machine pauillac est 128.93.11.35, mais on peut également y accéder en local (si l’on est déjà sur la machine pauillac) par l’adresse 127.0.0.1. Pour offrir un service sur toutes les adresses désignant la machine, on peut utiliser l’adresse inet_addr_any.

Le traitement du service se fera ici par un «double fork» après avoir émis quelques informations sur la connexion. Le traitement du service consiste à rediriger l’entrée standard et les deux sorties standard vers la prise sur laquelle est effectuée la connexion puis d’exécuter la commande souhaitée. (Notez ici que le traitement du service ne peut pas se faire de façon séquentielle.)

Remarque: la fermeture de la connexion se fait sans intervention du programme serveur. Premier cas: le client ferme la connexion dans le sens client vers serveur. La commande lancée par le serveur reçoit une fin de fichier sur son entrée standard. Elle finit ce qu’elle a à faire, puis appelle exit. Ceci ferme ses sorties standard, qui sont les derniers descripteurs ouverts en écriture sur la connexion. (Le client recevra alors une fin de fichier sur la connexion.) Deuxième cas: le client termine prématurément et ferme la connexion dans le sens serveur vers client. Le serveur peut alors recevoir le signal sigpipe en essayant d’envoyer des données au client, ce qui peut provoquer la mort anticipée par signal SIGPIPE de la commande du côté serveur; ça convient parfaitement, vu que plus personne n’est là pour lire les sorties de cette commande.

Enfin, la commande côté serveur peut terminer (de son plein gré ou par un signal) avant d’avoir reçu une fin de fichier. Le client recevra alors un fin de fichier lorsqu’il essayera de lire et un signal SIGPIPE (dans ce cas, le client meurt immédiatement) ou une exception EPIPE (si le signal est ignoré) lorsqu’il essayera d’écrire sur la connexion.

Précautions

L’écriture d’un serveur est en général plus délicate que celle d’un client. Alors que le client connaît le serveur sur lequel il se connecte, le serveur ignore tout de son client. En particulier, pour des services publics, le client peut être «hostile». Le serveur devra donc se protéger contre tous les cas pathologiques.

Un attaque typique consiste à ouvrir des connexions puis les laisser ouvertes sans transmettre de requête: après avoir accepté la connexion, le serveur se retrouve donc bloqué en attente sur la prise et le restera tant que le client sera connecté. L’attaquant peut ainsi saturer le service en ouvrant un maximum de connexions. Il est important que le serveur réagisse bien à ce genre d’attaque. D’une part, il devra prévoir un nombre limite de connexions en parallèle et refuser les connexions au delà afin de ne pas épuiser les ressources du système. D’autre part, il devra interrompre les connexions restées longtemps inactives.

Un serveur séquentiel qui réalise le traitement lui même et sans le déléguer à un de ses fils est immédiatement exposé à cette situation de blocage: le serveur ne répond plus alors qu’il n’a rien à faire. Une solution sur un serveur séquentiel consiste à multiplexer les connexions, mais cela peut être complexe. La solution avec le serveur parallèle est plus élégante, mais il faudra tout de même prévoir des «timeout», par exemple en programmant une alarme.

6.10  Communication en mode déconnecté

Les lectures/écritures en mode déconnecté

Le protocole tcp utilisé par la plupart des connexions de type SOCK_STREAM ne fonctionne qu’en mode connecté. Inversement, le protocole udp utilisé par la plupart des connexions de type SOCK_DGRAM fonctionne toujours de façon interne en mode déconnecté. C’est-à-dire qu’il n’y a pas de connexion établie entre les deux machines.

Ce type de prise peut être utilisé sans établir de connexion au préalable. Pour cela on utilise les appels système recvfrom et sendto.

     
   val recvfrom :
     file_descr -> string -> int -> int -> msg_flag list -> int * sockaddr
   val sendto :
     file_descr -> string -> int -> int -> msg_flag list -> sockaddr -> int

Chacun des appels retourne la taille des données transférées. L’appel recvfrom retourne également l’adresse du correspondant.

Une prise de type SOCK_DGRAM peut également être branchée avec connect, mais il s’agit d’une illusion (on parle de pseudo-connexion). L’effet de la pseudo-connexion est purement local. L’adresse passée en argument est simplement mémorisée dans la prise et devient l’adresse utilisée pour l’émission et la réception (les messages venant d’une autre adresse sont ignorés).

Les prises de ce type peuvent être connectées plusieurs fois pour changer leur affectation et déconnectées en les reconnectant sur une adresse invalide, par exemple 0. (Par opposition, la reconnexion d’une prise de type SOCK_STREAM produit en général un erreur.)

Les lectures/écritures de bas niveau

Les appels systèmes recv et send généralisent les fonctions read et write respectivement (mais ne s’appliquent qu’aux descripteurs de type prise).

     
   val recv : file_descr -> string -> int -> int -> msg_flag list -> int
   val send : file_descr -> string -> int -> int -> msg_flag list -> int

Leur interface est similaire à read et write mais elles prennent chacune en plus une liste de drapeaux dont la signification est la suivante: MSG_OOB permet d’envoyer une valeur exceptionnelle; MSG_DONTROUTE indique de court-circuiter les tables de routage par défaut; MSG_PEEK consulte les données sans les lire.

Ces primitives peuvent être utilisées en mode connecté à la place de read et write ou en mode pseudo-connecté à la place de recvfrom et sendto.

6.11  Primitives de haut niveau

L’exemple du client-serveur universel est suffisamment fréquent pour que la bibliothèque Unix fournisse des fonctions de plus haut niveau permettant d’établir et d’utiliser un service de façon presque transparente.

     
   val open_connection : sockaddr -> in_channel * out_channel
   val shutdown_connection : Pervasives.in_channel -> unit

La fonction open_connection ouvre une prise à l’adresse reçue en argument et crée une paire de tampons (de la bibliothèque Pervasives) d’entrée-sortie sur cette prise. La communication avec le serveur se fait donc en écrivant les requêtes dans le tampon ouvert en écriture et en lisant les réponses dans celui ouvert en lecture. Comme les écritures sont temporisées, il faut vider le tampon pour garantir qu’une requête est émise dans sa totalité. Le client peut terminer la connexion brutalement en fermant l’un ou l’autre des deux canaux (ce qui fermera la prise) ou plus “poliment” par un appel à shutdown_connection. (Si le serveur ferme la connexion, le client s’en apercevra lorsqu’il recevra une fin de fichier dans le tampon ouvert en lecture.)

De façon symétrique, un service peut également être établi par la fonction establish_server.

     
   val establish_server :
     (in_channel -> out_channel -> unit) -> sockaddr -> unit

Cette primitive prend en argument une fonction f, responsable du traitement des requêtes, et l’adresse de la prise sur laquelle le service doit être établi. Chaque connexion au serveur crée une nouvelle prise (comme le fait la fonction accept); après avoir été cloné, le processus fils créé une paire de tampons d’entrée-sortie (de la bibliothèque Pervasives) qu’il passe à la fonction f pour traiter la connexion. La fonction f lit les requêtes dans dans le tampon ouvert en lecture et répond au client dans celui ouvert en écriture. Lorsque le service est rendu (c’est-à-dire lorsque f a terminé), le processus fils ferme la prise et termine. Si le client ferme la connexion gentiment, le fils recevra une fin de fichier sur le tampon ouvert en lecture. Si le client le fait brutalement, le fils peut recevoir un SIGPIPE lorsqu’il essayera de d’écrire sur la prise fermée. Quant au père, il a déjà sans doute servi une autre requête! La commande establish_server ne termine pas normalement, mais seulement en cas d’erreur (par exemple, du runtime OCaml ou du système pendant l’établissement du service).

6.12  Exemples de protocoles

Dans les cas simples (rsh, rlogin, …), les données à transmettre entre le client et le serveur se présentent naturellement comme deux flux d’octets, l’un du client vers le serveur, l’autre du serveur vers le client. Dans ces cas-là, le protocole de communication est évident. Dans d’autres cas, les données à transmettre sont plus complexes, et nécessitent un codage avant de pouvoir être transmises sous forme de suite d’octets sur une prise. Il faut alors que le client et le serveur se mettent d’accord sur un protocole de transmission précis, qui spécifie le format des requêtes et des réponses échangées sur la prise. La plupart des protocoles employés par les commandes Unix sont décrits dans des documents appelés “RFC” (request for comments): au début simple propositions ouvertes à la discussion, ces documents acquièrent valeur de norme au cours du temps, au fur et à mesure que les utilisateurs adoptent le protocole décrit.2

Protocoles “binaires”

La première famille de protocoles vise à transmettre les données sous une forme compacte la plus proche possible de leur représentation en mémoire, afin de minimiser le travail de conversion nécessaire, et de tirer parti au mieux de la bande passante du réseau. Exemples typiques de protocoles de ce type: le protocole X-window, qui régit les échanges entre le serveur X et les applications X, et le protocole NFS (RFC 1094).

Les nombres entiers ou flottants sont généralement transmis comme les 1, 2, 4 ou 8 octets qui constituent leur représentation binaire. Pour les chaînes de caractères, on envoie d’abord la longueur de la chaîne, sous forme d’un entier, puis les octets contenus dans la chaîne. Pour des objets structurés (n-uplets, records), on envoie leurs champs dans l’ordre, concaténant simplement leurs représentations. Pour des objets structurés de taille variable (tableaux), on envoie d’abord le nombre d’éléments qui suivent. Le récepteur peut alors facilement reconstituer en mémoire la structure transmise, à condition de connaître exactement son type. Lorsque plusieurs types de données sont susceptibles d’être échangés sur une prise, on convient souvent d’envoyer en premier lieu un entier indiquant le type des données qui va suivre.

Exemple:

L’appel XFillPolygon de la bibliothèque X, qui dessine et colorie un polygone, provoque l’envoi au serveur X d’un message de la forme suivante:

Dans ce type de protocole, il faut prendre garde aux différences d’architecture entre les machines qui communiquent. En particulier, dans le cas d’entiers sur plusieurs octets, certaines machines mettent l’octet de poids fort en premier (c’est-à-dire, en mémoire, à l’adresse basse) (architectures dites big-endian), et d’autres mettent l’octet de poids faible en premier (architectures dites little-endian). Par exemple, l’entier 16 bits 12345 = 48 × 256 + 57 est représenté par l’octet 48 à l’adresse n et l’octet 57 à l’adresse n+1 sur une architecture big-endian, et par l’octet 57 à l’adresse n et l’octet 48 à l’adresse n+1 sur une architecture little-endian. Le protocole doit donc spécifier que tous les entiers multi-octets sont transmis en mode big-endian, par exemple. Une autre possibilité est de laisser l’émetteur choisir librement entre big-endian et little-endian, mais de signaler dans l’en-tête du message quelle convention il utilise par la suite.

Le système OCaml facilite grandement ce travail de mise en forme des données (travail souvent appelé marshaling ou encore sérialisation dans la littérature) en fournissant deux primitives générales de transformation d’une valeur OCaml quelconque en suite d’octets, et réciproquement:

     
   val output_valueout_channel -> 'a -> unit
   val input_valuein_channel -> 'a

Le but premier de ces deux primitives est de pouvoir sauvegarder n’importe quel objet structuré dans un fichier disque, puis de le recharger ensuite; mais elles s’appliquent également bien à la transmission de n’importe quel objet le long d’un tuyau ou d’une prise. Ces primitives savent faire face à tous les types de données OCaml à l’exception des fonctions; elles préservent le partage et les circularités à l’intérieur des objets transmis; et elles savent communiquer entre des architectures d’endianness différentes.

Exemple:

Si X-window était écrit en OCaml, on aurait un type concret request des requêtes pouvant être envoyées au serveur, et un type concret reply des réponses éventuelles du serveur:

     
   type request =
       ...
     | FillPolyReq of (int * intarray * drawable * graphic_context
                                       * poly_shape * coord_mode
     | GetAtomNameReq of atom
     | ...
   and reply =
       ...
     | GetAtomNameReply of string
     | ...

Le coeur du serveur serait une boucle de lecture et décodage des requêtes de la forme suivante:

     
   (* Recueillir une demande de connexion sur le descripteur s *)
   let requests = in_channel_of_descr s
   and replies  = out_channel_of_descr s in
   try
     while true do
       match input_value requests with
           ...
         | FillPoly(verticesdrawablegcshapemode) ->
             fill_poly vertices drawable gc shape mode
         | GetAtomNameReq atom ->
             output_value replies (GetAtomNameReply(get_atom_name atom))
         | ...
     done
   with End_of_file ->
     (* fin de la connexion *)

La bibliothèque X, liée avec chaque application, serait de la forme:

     
   (* Établir une connexion avec le serveur sur le descripteur s *)
   let requests = out_channel_of_descr s
   and replies  = in_channel_of_descr s;;
   let fill_poly vertices drawable gc shape mode =
     output_value requests
                  (FillPolyReq(verticesdrawablegcshapemode));;
   let get_atom_name atom =
     output_value requests (GetAtomNameReq atom);
     match input_value replies with
       GetAtomNameReply name -> name
     | _ -> fatal_protocol_error "get_atom_name";;

Il faut remarquer que le type de input_value donné ci-dessus est sémantiquement incorrect, car beaucoup trop général: il n’est pas vrai que le résultat de input_value a le type 'a pour tout type 'a. La valeur renvoyée par input_value appartient à un type bien précis, et non pas à tous les types possibles; mais le type de cette valeur ne peut pas être déterminé au moment de la compilation, puisqu’il dépend du contenu du fichier qui va être lu à l’exécution. Le typage correct de input_value nécessite une extension du langage ML connue sous le nom d’objets dynamiques: ce sont des valeurs appariées avec une représentation de leur type, permettant ainsi des vérifications de types à l’exécution. On se reportera à [13] pour une présentation plus détaillée.

Appel de procédure distant (Remote Procedure Call)


Figure 6.2: Remote Procedure Call

Une autre application typique de ce type de protocole est l’appel de procédure distant, courremment appelé RPC (pour «Remote Procedure Call»). Un utilisateur sur une Machine A veut exécuter un programme f sur une machine B. Ce n’est évidemment pas possible directement. Ce pourrait être programmé au cas par cas, en passant par le système pour ouvrir une connexion vers la machine B exécuter l’appel, relayer la réponse vers la machine A puis l’utilisateur.

En fait, comme c’est une situation typique, il existe un service RPC qui fait cela. C’est un client-serveur (client sur la machine A, serveur sur la machine B, dans notre exemple) qui reçoit des requêtes d’exécution sur une machine distante (B) de la part d’un utilisateur, se connecte au serveur RPC sur la machine distante B qui exécute l’appel f et retourne la réponse au client RPC A qui a son tour la renvoie à l’utilisateur. L’intérêt est qu’un autre utilisateur peut appeler un autre programme sur la machine B (ou une autre) en passant par le même serveur RPC. Le travail a donc été partagé par le service RCP installé sur les machines A et B.

Du point de vue du programme utilisateur, tout se passe virtuellement comme s’il faisait un simple appel de fonction (flèches hachurées).

Protocoles “texte”

Les services réseaux où l’efficacité du protocole n’est pas cruciale utilisent souvent un autre type de protocoles: les protocoles “texte”, qui sont en fait un petit langage de commandes. Les requêtes sont exprimées sous forme de lignes de commandes, avec le premier mot identifiant le type de requête, et les mots suivants les arguments éventuels. Les réponses sont elles aussi sous forme d’une ou plusieurs lignes de texte, commençant souvent par un code numérique, pour faciliter le décodage de la réponse. Quelques protocoles de ce type:

SMTP (Simple Mail Transfert Protocol)RFC 821courrier électronique
FTP (File Transfert Protocol)RFC 959transferts de fichiers
NNTP (Network News Transfert Protocol)RFC 977lecture des News
HTTP-1.0 (HyperText Transfert Protocol)RFC 1945navigation sur la toile
HTTP-1.1 (HyperText Transfert Protocol)RFC 2068navigation sur la toile

Le grand avantage de ce type de protocoles est que les échanges entre le client et le serveur sont immédiatement lisibles par un être humain. En particulier, on peut utiliser telnet pour dialoguer “en direct” avec un serveur de ce type3: on tape les requêtes comme le ferait un client, et on voit s’afficher les réponses. Ceci facilite grandement la mise au point. Bien sûr, le travail de codage et de décodage des requêtes et des réponses est plus important que dans le cas des protocoles binaires; la taille des messages est également un peu plus grande; d’où une moins bonne efficacité.

Exemple:

Voici un exemple de dialogue interactif avec un serveur SMTP. Les lignes précédées par → vont du client vers le serveur, et sont donc tapées par l’utilisateur. Les lignes précédées par ← vont du serveur vers le client.

     
       pomtelnet margaux smtp
       Trying 128.93.8.2 ...
       Connected to margaux.inria.fr.
       Escape character is '^]'.
   <<  220 margaux.inria.fr Sendmail 5.64+/AFUU-3 ready at Wed, 15 Apr 92 17:40:59
   >>  HELO pomerol.inria.fr
   <<  250 Hello pomerol.inria.frpleased to meet you
   >>  MAIL From:<god@heavens.sky.com>
   <<  250 <god@heavens.sky.com>... Sender ok
   >>  RCPT To:<xleroy@margaux.inria.fr>
   <<  250 <xleroy@margaux.inria.fr>... Recipient ok
   >>  DATA
   <<  354 Enter mailend with "." on a line by itself
   >>  Fromgod@heavens.sky.com (Himself)
   >>  Toxleroy@margaux.inria.fr
   >>  Subjectsalut!
   >>
   >>  Ca se passe bienen bas?
   >>  .
   <<  250 Ok
   >>  QUIT
   <<  221 margaux.inria.fr closing connection
       Connection closed by foreign host.

Les commandes HELO, MAIL et RCPT transmettent le nom de la machine expéditrice, l’adresse de l’expéditeur, et l’adresse du destinataire. La commande DATA permet d’envoyer le texte du message proprement dit. Elle est suivie par un certain nombre de lignes (le texte du message), terminées par une ligne contenant le seul caractère “point”. Pour éviter l’ambiguïté, toutes les lignes du message qui commencent par un point sont transmises en doublant le point initial; le point supplémentaire est supprimé par le serveur.

Les réponses sont toutes de la forme « un code numérique en trois chiffres plus un commentaire ». Quand le client est un programme, il interprète uniquement le code numérique; le commentaire est à l’usage de la personne qui met au point le système de courrier. Les réponses en 5xx indiquent une erreur; celles en 2xx, que tout s’est bien passé.

6.13  Exemple complet: requêtes http

Le protocole HTTP (HyperText Transfert Protocol) est utilisé essentiellement pour lire des documents sur la fameuse “toile”. Ce domaine est une niche d’exemples client-serveur: entre la lecture des pages sur la toile ou l’écriture de serveurs, les relais se placent en intermédiaires, serveurs virtuels pour le vrai client et clients par délégation pour le vrai serveur, offrant souvent au passage un service additionnel tel que l’ajout de caches, de filtres, etc.

Il existe plusieurs versions du protocole HTTP. Pour aller plus rapidement à l’essentiel, à savoir l’architecture d’un client ou d’un relais, nous utilisons le protocole simplifié, hérité des toutes premières versions du protocole. Même s’il fait un peu poussiéreux, il reste compris par la plupart des serveurs. Nous décrivons à la fin une version plus moderne et plus expressive mais aussi plus complexe, qui est indispensable pour réaliser de vrais outils pour explorer la toile. Cependant, nous laisserons la traduction des exemples en exercices.

La version 1.0 du protocole http décrite dans la norme RFC 19454 permet les requêtes simplifiées de la forme:

GET sp Request-URI crlf

sp représente un espace et crlf la chaîne de caractères \r\n (return suivi de linefeed). La réponse à une requête simplifiée est également simplifiée: le contenu de l’url est envoyé directement, sans entête, et la fin de la réponse est signalée par la fin de fichier, qui termine donc la connexion. Cette forme de requête, héritée du protocole 0.9, limite de fait la connexion à la seule requête en cours.

Récupération d’une url

Nous proposons d’écrire une commande geturl qui prend un seul argument, une URL, recherche sur la toile le document qu’elle désigne et l’affiche.

La première tâche consiste à analyser l’URL pour en extraire le nom du protocole (ici nécessairement http) l’adresse du serveur, le port optionnel et le chemin absolu du document sur le serveur. Pour ce faire nous utilisons la bibliothèque d’expressions régulières Str. Nous passons rapidement sur cette partie du code peu intéressante, mais indispensable.

     
   open Unix;;
   
   exception Error of string
   let error err mes = raise (Error (err ^ ": " ^ mes));;
   let handle_error f x = try f x with Error err -> prerr_endline errexit 2
   
   let default_port = "80";;
   
   type regexp = { regexp : Str.regexpfields : (int * string optionlist; }
   let regexp_match r string =
     let get (posdefault) =
       try Str.matched_group pos string
       with Not_found ->
         match default with Some s -> s
         | _ -> raise Not_found in
     try
       if Str.string_match r.regexp string 0 then
         Some (List.map get r.fields)
       else None
     with Not_found -> None;;
   
   let host_regexp =
     { regexp = Str.regexp "\\([^/:]*\\)\\(:\\([0-9]+\\)\\)?";
       fields = [ 1, None; 3, Some default_port; ] };;
   let url_regexp =
     { regexp = Str.regexp "http://\\([^/:]*\\(:[0-9]+\\)?\\)\\(/.*\\)";
       fields = [ 1, None; 3, None ] };;
   
   let parse_host host =
     match regexp_match host_regexp host with
       Some (host :: port :: _) -> hostint_of_string port
     | _ -> error host "Ill formed host";;
   let parse_url url =
     match regexp_match url_regexp url with
       Some (host :: path :: _) -> parse_host hostpath
     | _ -> error url "Ill formed url";;

Nous pouvons maintenant nous attaquer à l’envoi de la requête qui, dans le protocole simplifié, est une trivialité.

     
   let send_get url sock =
     let s = Printf.sprintf "GET %s\r\n" url in
     ignore (write sock s 0 (String.length s));;

Remarquons que l’url peut ne contenir que le chemin sur le serveur, ou bien être complète, incluant également le port et l’adresse du serveur.

La lecture de la réponse est encore plus facile, puisque le document est simplement envoyé comme réponse, sans autre forme de politesse. Lorsque la requête est erronée, un message d’erreur est encodé dans un document HTML. Nous nous contentons ici de faire suivre la réponse sans distinguer si elle indique une erreur ou correspond au document recherché. La transmission utilise la fonction de bibliothèque Misc.retransmit. Le cœur du programme établit la connexion avec le serveur.

     
   let get_url proxy url fdout =
     let (hostnameport), path =
       match proxy with
         None -> parse_url url
       | Some host -> parse_host hosturl in
     let hostaddr =
       try inet_addr_of_string hostname
       with Failure _ ->
         try (gethostbyname hostname).h_addr_list.(0)
         with Not_found -> error hostname "Host not found" in
     let sock = socket PF_INET SOCK_STREAM 0 in
     Misc.try_finalize
       begin function () ->
         connect sock (ADDR_INET (hostaddrport));
         send_get path sock;
         retransmit sock fdout
       end ()
       close sock;;

Nous terminons, comme d’habitude, par l’analyse de la ligne de commande.

     
   let geturl () =
     let len =  Array.length Sys.argv in
     if len < 2 then
       error "Usage:" (Sys.argv.(0) ^ " [ proxy [:<port>] ] <url>")
     else
       let proxyurl =
         if len > 2 then Some Sys.argv.(1), Sys.argv.(2)
         else NoneSys.argv.(1) in
       get_url proxy url stdout;;
   
   handle_unix_error (handle_error geturl) ();;

Relais HTTP


Figure 6.3: Relais HTTP

Nous nous proposons maintenant d’écrire un relais HTTP (proxy en anglais), c’est-à-dire un serveur de requêtes HTTP qui permet de traiter toutes les requêtes HTTP en les redirigeant vers la machine destinataire (ou un autre relais...) et fait suivre les réponses vers la machine appelante. Nous avons schématisé le rôle d’un relais dans la figure 6.3. Lorsqu’un client HTTP utilise un relais, il adresse ses requêtes au relais plutôt que de les adresser directement aux différents serveurs HTTP localisés un peu partout dans le monde. L’avantage du relais est multiple. Un relais peut mémoriser les requêtes les plus récentes ou les plus fréquentes au passage pour les resservir ultérieurement sans interroger le serveur, soit pour ne pas le surcharger, soit en l’absence de connexion réseau. Un relais peut aussi filtrer certaines pages (retirer la publicité ou les images, etc.). L’utilisation d’un relais peut aussi simplifier l’écriture d’une application en lui permettant de ne plus voir qu’un seul serveur pour toutes les pages du monde.

La commande proxy lance le serveur sur le port passé en argument, ou s’il est omis, sur le port par défaut du service HTTP. Nous récupérons bien entendu le code réalisé par la fonction get_url (nous supposons que les fonctions ci-dessus, hormis le lancement de la commande, sont disponibles dans un module Url). Il ne reste qu’à écrire l’analyse des requêtes et mettre en place le serveur.

     
   open Unix
   open Url
   let get_regexp =
     { regexp = Str.regexp "^[Gg][Ee][Tt][ \t]+\\(.*[^ \t]\\)[ \t]*\r";
       fields = [ 1, None ] }
   let parse_request line =
     match regexp_match get_regexp line  with
     | Some (url :: _) -> url
     | _ -> error line "Ill formed request"

Nous allons établir le service avec la commande establish_server. Il suffit donc de définir le traitement d’une connexion.

     
   let proxy_service (client_sock_) =
     let service() =
       try
         let in_chan = in_channel_of_descr client_sock in
         let line = input_line in_chan in
         let url = parse_request line in
         get_url None url client_sock
       with End_of_file ->
         error "Ill formed request" "End_of_file encountered" in
     Misc.try_finalize
       (handle_error service) ()
       close client_sock

Le reste du programme n’a plus qu’à établir le service.

     
   let proxy () =
     let http_port =
       if Array.length Sys.argv > 1 then
         try int_of_string Sys.argv.(1)
         with Failure _ -> error Sys.argv.(1) "Incorrect port"
       else
         try (getservbyname "http" "tcp").s_port
         with Not_found -> error "http" "Unknown service" in
     let treat_connection s = Misc.double_fork_treatment s proxy_service in
     let addr = ADDR_INET(inet_addr_anyhttp_portin
     Misc.tcp_server treat_connection addr;;
   
   handle_unix_error (handle_error proxy) ();;

Le Protocole HTTP/1.1

Les requêtes simplifiées obligent à créer une connexion par requête, ce qui est inefficace, car il est fréquent que plusieurs requêtes se suivent sur le même serveur (par exemple, le chargement d’une page WEB qui contient des images va entraîner dans la foulée le chargement des images correspondantes). Le temps d’établissement de la connexion peut facilement dépasser le temps passé à traiter la requête proprement dite. Nous verrons dans le chapitre 7 comment réduire celui-ci en faisant traiter les connexions par des coprocessus plutôt que par des processus. Nous proposons dans les exercices ci-dessous l’utilisation du protocole HTTP/1.15 qui utilise des requêtes complexes permettant de servir plusieurs requêtes par connexion6.

Dans les requêtes complexes, le serveur précède chaque réponse par une entête indiquant le format de la réponse et le cas échéant la taille du document transmis. La fin du document n’est plus indiquée par une fin de fichier, puisqu’elle peut être déduite de la taille. La connexion peut ainsi rester ouverte pour servir d’autres requêtes.

Celles-ci sont de la forme suivante:

GET sp Uri sp HTTP/1.1 crlf Header crlf

L’entête Header définit une suite de paires champ-valeur avec la syntaxe suivante:

field : value crlf

Des espaces superflus sont également permis autour du séparateur “":"”. En fait, un espace sp peut toujours être remplacé par une tabulation ou une suite d’espaces. Les champs de l’entête peuvent également s’étendre sur plusieurs lignes: dans ce cas et dans ce cas uniquement le lexème de fin de ligne crlf est immédiatement suivi d’un espace sp. Enfin, majuscules et minuscules sont équivalentes dans les mots-clés des champs, ainsi que dans les valeurs de certains champs composés de listes de mots-clé.

Selon le type de requête, certains champs sont obligatoires, d’autres sont optionnels. Par exemple, une requête GET comporte forcément un champ qui indique la machine destinataire:

Host colon Hostname crlf

Pour ce type requête, on peut aussi demander, en utilisant le champ optionnel If-Modified que le document ne soit retourné que s’il a été modifié depuis une certaine date.

If-Modified colon Date crlf

Le nombre de champs du Header n’est donc pas fixé par avance mais indiqué par la fin de l’entête qui consiste en une ligne réduite aux seuls caractères crlf.

Voici une requête complète (toutes les lignes se terminant par le caractère \n laissé implicite et qui suit immédiatement le \r):

     
   GET /~remyHTTP/1.1\r
   Host:pauillac.inria.fr\r
   \r

Une réponse à une requête complexe est également une réponse complète. Elle comporte une ligne de statut, une entête, puis le corps de la réponse, le cas échéant.

HTTP/1.0 SP status SP message crlf Header crlf Body

Les champs de l’entête d’une réponse ont une syntaxe analogue à celle d’une requête mais les champs permis et obligatoires sont différents (ils dépendent du type de la requête ou du statut de la réponse—voir la documentation complète du protocole).

Le corps de la réponse Body peut-être vide, transmis en un seul bloc, ou par tranches. Dans le second cas, l’entête comporte un champ Content-Length indiquant le nombre d’octets en notation décimale ASCII. Dans le troisième cas, l’entête comporte une champ Transfer-Encoding avec la valeur chuncked. Le corps est alors un ensemble de tranches et se termine par une tranche vide. Une tranche est de la forme:

Size [ semicolon arg ] crlf Chunk crlf

Size est la taille de la tranche en notation hexadécimale (la partie entre “[” et “]” est optionnelle et peut être ignorée ici) et Chunk est une partie du corps de la réponse de la taille indiquée. La dernière tranche de taille nulle est toujours de la forme suivante:

0 crlf Header crlf crlf

Enfin, le corps de la réponse Body est vide lorsque la réponse n’est pas tranchée et ne contient pas de champ Content-Length (par exemple, une requête de type HEAD ne répond que par une entête).

Voici un exemple de réponse:

     
   HTTP/1.1 200 OK\r
   DateSun, 10 Nov 2002 09:14:09 GMT\r
   ServerApache/1.2.6\r
   Last-ModifiedMon, 21 Oct 2002 13:06:21 GMT\r
   ETag"359-e0d-3db3fbcd"\r
   Content-Length: 3597\r
   Accept-Rangesbytes\r
   Content-Typetext/html\r
   \r
   <html>
   ...
   </html>

Le statut 200 indique que la requête a réussi. Un statut 301 ou 302 signifie que l’URL a été redirigée vers une autre URL définie dans le champ Location de la réponse. Les statuts de la forme 400, 401, etc. indique des erreurs dans la forme ou l’aboutissement de la requête et ceux de la forme 500, 501, etc. des erreurs, plus grave, dans le traitement de la requête.

Exercice 15   Écrire un relais qui fonctionne avec le protocole HTTP/1.1.
Exercice 16   Ajouter un cache au relais: les pages sont sauvegardées sur le disque. Lorsqu’une page demandée est disponible dans le cache, la page du cache est servie, sauf si elle est trop ancienne, auquel cas le serveur est interrogé (et le cache est mis à jour).
Exercice 17   Écrire un programme wget telle que wget u1 … un effectue les requêtes ui et sauve les réponses dans des fichiers ./mi"/"pimi et pi sont respectivement le nom de la machine et le chemin absolu de la requête ui. On profitera du protocole complet pour n’effectuer qu’une seule connexion sur la machine m lorsque celle-ci est la même pour plusieurs requêtes consécutives. De plus, on suivra une URL lorsque celle-ci est une redirection temporaire ou définitive. On pourra ajouter les options suivantes:

1
Le réseau Internet se compose de réseaux locaux, généralement du type Ethernet, reliés par des liaisons spécialisées. Il relie des millions de machines dans le monde entier. À l’intérieur du domaine Internet, il n’y a pas de différence au niveau des programmes entre communiquer avec la machine voisine, branchée sur le même câble Ethernet, et communiquer avec une machine à l’autre bout du monde, à travers une dizaine de routeurs et une liaison satellite.
2
Les RFC sont disponibles par FTP anonyme sur de nombreux sites. En France: ftp.inria.fr, dans le répertoire rfc. Le site de référence étant http://www.faqs.org/rfcs/.
3
Il suffit de lancer telnet machine service, où machine est le nom de la machine sur laquelle tourne le serveur, et service est le nom du service (smtp, nntp, etc.).
4
La description complète peut-être trouvée à l’URL http://www.doclib.org/rfc/rfc1945.html.
5
La description complète peut-être trouvée à l’URL http://www.doclib.org/rfc/rfc2068.html.
6
Le protocole HTTP/1.0 permet déjà ce type de requêtes en plus des requêtes simplifiées, mais nous préférons décrire le protocole HTTP/1.1 qui traite exclusivement des requêtes complexes.

Chapter 7  Les coprocessus

Un coprocessus, aussi appelé processus léger et thread en anglais, est un fil d’exécution d’un programme pouvant s’exécuter en parallèle avec d’autres coprocessus du même programme.


Ce chapitre décrit les appels systèmes Unix permettant à un programme de créer des coprocessus et de les synchroniser à l’aide de verrous, de conditions ou d’événements. Il s’appuie sur la bibliothèque OCaml Thread et les bibliothèques Mutex, Condition. Il présente également la communication par événements synchrones fournie par la bibliothèque et Event.

7.1  Généralités

La création d’un coprocessus est très différente de l’opération fork qui crée une copie du processus courant (donc une copie du programme): les espaces mémoire du père et du fils sont totalement disjoints après l’exécution de fork et les processus ne peuvent communiquer que par un appel système (comme écrire ou lire dans un fichier ou dans un tuyau).

Au contraire, tous les coprocessus d’un même programme partagent le même espace d’adressage. Les seules données qui les différencient et qui ne sont pas partagées sont leur identité et leur pile d’exécution, ainsi que quelques informations pour le système (masque des signaux, état des verrous et conditions, etc.). De ce point de vue, les coprocessus ressemblent aux coroutines. Les coprocessus d’un même programme forment un groupe: ils sont tous traités de la même façon, sauf un, le coprocessus principal, qui a été créé au démarrage du programme et dont la terminaison entraîne l’arrêt de tous les autres coprocessus et l’arrêt du programme. Lorsque nous parlons de plusieurs coprocessus, nous considérons implicitement qu’ils s’agit des coprocessus d’un même programme.

À la différence des coroutines, qui ne peuvent pas s’exécuter en parallèle —l’une devant passer explicitement la main avant que l’autre ne puisse s’exécuter à son tour— les coprocessus s’exécutent en parallèle ou du moins tout se passe comme s’ils le faisaient, car ils peuvent être interrompus de façon préemptive par le système pour donner la main à d’autres coprocessus. De ce point de vue, les coprocessus ressemblent aux processus.

Le partage de l’espace mémoire permet aux coprocessus de communiquer directement entre eux par leur mémoire commune. Bien qu’en principe, deux processus n’aient pas besoin pour cela de passer par le système d’exploitation, le fait qu’ils s’exécutent en parallèle les oblige à se synchroniser au moment de la communication (l’un devant finir d’écrire avant que l’autre ne commence à lire), ce qui nécessite, en général, de passer par le système d’exploitation. La synchronisation entre coprocessus, quasiment indispensable pour toute communication, reste souvent une partie difficile de la programmation avec des coprocessus. Elle peut se faire à l’aide de verrous et de conditions, ou bien de façon plus évoluée, en communiquant par des événements.

Le gain à l’utilisation des coprocessus par rapport aux processus reste le coût moindre à leur création et la possibilité d’échanger de grosses structures de données sans recopie, simplement en s’échangeant un pointeur. Inversement, l’utilisation des coprocessus a un coût qui est de devoir bien gérer la synchronisation entre eux, y compris en cas d’erreur fatale dans l’un d’eux. En particulier un coprocesseur doit bien faire attention de libérer ses verrous et de préserver ses invariant avant de s’arrêter. Aussi, on pourra préférer les processus aux coprocessus lorsqu’on ne profite pas réellement des avantages de ces derniers.

Mise en œuvre en OCaml

Pour compiler une application utilisant des coprocessus natifs il faut faire:

ocamlc -thread unix.cma threads.cma -o prog mod1.ml mod2.ml mod3.ml
ocamlopt -thread unix.cmxa threads.cmxa -o prog mod1.ml mod2.ml mod3.ml

Si votre installation ne supporte pas les coprocessus natifs, vous pouvez voir à la section 7.8 ou dans le manuel comment utiliser les coprocessus simulés. Le discours et les exemples de ce chapitre supposent des coprocessus natifs et ne s’applique pas, en général, aux coprocessus simulés.

7.2  Création et terminaison des coprocessus

Les fonctions décrites dans cette section sont définies dans le module Thread.

L’appel système create f a crée un nouveau coprocessus dans lequel l’application de fonction f a est exécutée. La création d’un coprocessus retourne à l’appelant une poignée sur le coprocessus fraîchement créé qui peut être utilisée pour son contrôle.

     
   val Thread.create : ('a -> 'b) -> 'a -> t

Le calcul s’exécute de façon concurrente avec les calculs effectués par les autres coprocessus du programme. Le coprocessus termine lorsque l’application f a termine. Le résultat du calcul est simplement ignoré. Si le coprocessus termine par une exception non rattrapée, celle-ci n’est pas propagée dans le coprocessus appelant ou principal mais simplement ignorée après affichage d’un message dans la sortie d’erreur. (Les autres coprocessus ont a priori évolué indépendemment et ne seraient pas en mesure de recevoir l’exception.)

Un coprocessus peut aussi se terminer prématurément en appelant la fonction exit (de la bibliothèque Thread), à ne pas confondre avec la fonction de même nom de la bibliothèque Pervasives qui termine le programme tout entier, i.e. tous ses coprocessus. Le coprocessus principal d’un programme, qui est celui qui a lancé le premier d’autres coprocessus, exécute implicitement la fonction Pervasives.exit lorsqu’il termine.

Si un coprocessus termine avant le coprocessus principal, alors il est désalloué immédiatement, et ne devient pas un fantôme comme dans le cas d’un processus Unix créé par fork. La bibliothèque OCaml se charge de cette gestion.

Nous en savons déjà assez pour proposer une alternative au modèle du serveur concurrent par «fork» (ou «double fork») vu dans le cours précédent en faisant effectuer le traitement par un coprocessus plutôt que par un processus fils. Pour établir un tel serveur, il nous suffit de proposer une variante Misc.co_treatment de la fonction Misc.fork_treatment définie dans le chapitre 6.

     
   let co_treatment server_sock service (client_descr_ as client) =
     try ignore (Thread.create service client)
     with exn -> close client_descrraise exn;;

Si le coprocessus a pu être créé, le traitement est entièrement pris en compte par la fonction service y compris la fermeture de client_descr. Sinon, on ferme le descripteur client_descr (le client est abandonné), et on laisse le programme principal traiter l’erreur.

Attention, toute la difficulté du coserveur est cachée dans la fonction service qui doit traiter la connexion avec robustesse et jusqu’à la déconnexion. Dans le cas du serveur concurrent où le service est exécuté par un autre processus, une erreur fatale pendant le service conduisant à la mort prématurée du service produit par défaut le bon comportement qui est de fermer la connexion, car le système ferme les descripteurs à la mort d’un processus. Il n’en est plus de même dans le cas où le service est exécuté par un coprocessus, car les descripteurs entre les différents coprocessus sont par défaut partagés et ne sont pas fermés à la mort du coprocessus. C’est donc au coprocessus de fermer ses descripteurs avant de mourir. De plus, un coprocessus ne peut pas appeler Pervasives.exit dans le cas d’une erreur fatale dans le traitement d’un service, car celle-ci arrêterait non seulement le service mais aussi le serveur. Appelé Thread.exit n’est souvent pas non plus une solution, car on risque de ne pas avoir bien désalloué les ressources ouvertes, et en particulier la connexion. Une solution consiste à lever une exception signifiant un arrêt fatal (par exemple une exception Exit) provoquant l’exécution du code de finalisation à sa remontée. Pour des raisons analogues, il est essentiel que le signal sigpipe soit dans un traitement du service par coprocessus, remplaçant l’arrêt immédiat du coprocessus par la levée d’une exception EPIPE.

7.3  Mise en attente

Les fonctions décrites dans cette section sont définies dans le module Thread.

L’appel système join permet à un coprocessus d’en attendre un autre.

     
   val Thread.join : t -> unit

Le coprocessus appelant est alors suspendu jusqu’à ce que celui dont l’identité est passée en argument ait terminé son exécution. Cet appel peut également être utilisé par le coprocessus principal pour attendre que tous les autres aient retourné avant de terminer lui-même et de terminer le programme (le comportement par défaut étant de tuer les autres coprocessus sans attendre leur terminaison).

Bien que cet appel soit bloquant donc du type «long», il est relancé automatiquement à la réception d’un signal: il est effectivement interrompu par un signal, le handler est traité, puis l’appel est relancé. On ne revient donc de l’appel que quand le coprocessus à réellement terminé et l’appel ne doit jamais lever l’exception EINTR. Du point de vue du programmeur OCaml, cela se comporte comme si le signal était reçu au moment où l’appel retourne.

Un coprocessus ne retourne pas, car il s’exécute de façon asynchrone. Mais son action peut être observée — heureusement! — par ses effets de bords. Par exemple, un coprocessus peut placer le résultat d’un calcul dans une référence qu’un autre coprocessus ira consulter après s’être assuré de la terminaison du calcul. Nous illustrons cela dans l’exemple suivant.

     
   exception Exited
   type 'a result = Value of 'a | Exception of exn
   let eval f x = try Value (f xwith z -> Exception z
   let coexec (f : 'a -> 'b) (x : 'a) : unit -> 'b =
     let result = ref (Exception Exitedin
     let p = Thread.create (fun x -> result := eval f xx in
     function() ->
       match join p; !result with
       | Value v -> v
       | Exception exn -> raise exn;;
   
   let v1 = coexec succ 4 and v2 = coexec succ 5 in v1()+v2();;

Le système peut suspendre un coprocessus pour donner temporairement la main à un autre ou parce qu’il est en attente d’une ressource utilisée par un de ses coprocessus (verrous et conditions, par exemple) ou par un autre processus (descripteur de fichier, par exemple). Un coprocessus peut également se suspendre de sa propre initiative. La fonction yield permet à un coprocessus de redonner la main prématurément (sans attendre la préemption par le système).

     
   val Thread.yield : unit -> unit

C’est une indication pour le gestionnaire de coprocessus, mais son effet peut être nul, par exemple, si aucun coprocessus ne peut s’exécuter immédiatement. Le système peut donc décider de redonner la main au même coprocessus.

Inversement, il n’est pas nécessaire d’exécuter yield pour permettre à d’autres coprocessus de s’exécuter, car le système se réserve le droit d’exécuter lui-même la commande yield à tout moment. En fait, il exerce ce droit à intervalles de temps suffisamment rapprochés pour permettre à d’autres coprocessus de s’exécuter et donner l’illusion à l’utilisateur que les coprocessus s’exécutent en parallèle, même sur une machine mono-processeur.

Exemple:

On peut reprendre et modifier l’exemple 3.3 pour utiliser des coprocessus plutôt que des processus.

     
   let rec psearch k cond v =
     let n = Array.length v in
     let slice i = Array.sub v (i * k) (min k (n - i * k)) in
     let slices = Array.init (n/kslice in
     let found = ref false in
     let pcond v = if !found then Thread.exit(); cond v in
     let search v = if simple_search pcond v then found := true in
     let proc_list = Array.map (Thread.create searchslices in
     Array.iter Thread.join proc_list;
     !found;;

La fonction psearch k f v recherche avec k coprocessus en parallèle une occurrence dans le tableau satisfaisant la fonction f. La fonction pcond permet d’interrompre la recherche en cours dès qu’une réponse a été trouvée. Tous les coprocessus partagent la même référence found: ils peuvent donc y accéder de façon concurrente. Il n’y a pas de section critique entre les différents coprocessus car s’ils écrivent en parallèle dans cette ressource, ils écrivent la même valeur. Il est important que les coprocessus n’écrivent pas le résultat de la recherche quand celui-ci est faux! par exemple le remplacement de la ligne 7 par

     
   let search v = found := !found && simple_search pcond v

ou même:

     
   let search v = let r = simple_search pcond v in found := !found && r

serait incorrect.

la recherche en parallèle est intéressante même sur une machine mono-processeur si la comparaison des éléments peut être bloquée temporairement (par exemple par des accès disques ou, mieux, des connexions réseau). Dans ce cas, le coprocessus qui effectue la recherche passe la main à un autre et la machine peut donc continuer le calcul sur une autre partie du tableau et revenir au coprocessus bloqué lorsque sa ressource sera libérée.

L’accès à certains éléments peut avoir une latence importante, de l’ordre de la seconde s’il faut récupérer de l’information sur le réseau. Dans ce cas, la différence de comportement entre une recherche séquentielle et une recherche en parallèle devient flagrante.

Exercice 18   Paralléliser le tri rapide (quicksort) sur des tableaux.
(Voir le corrigé)
Le tri rapide se prête bien à une parallélisation, car le tri se fait récursivement sur des sous-tableaux indépendants et peut être délégué à des coprocessus avec pour seule synchronisation d’attendre que tous les coprocessus aient terminé leur tri pour affirmer que le sous-tableau est trié.
     
   let qsort cmp arr =
     let rec qsort lo hi =
     if hi - lo > 0 then
       begin
         let mid = (lo + hilsr 1 in
         if cmp arr.(midarr.(lothen swap arr mid lo;
         if cmp arr.(hiarr.(midthen
           begin
             swap arr mid hi;
             if cmp arr.(midarr.(lothen swap arr mid lo
           end;
         let pivot = arr.(midin
         let i = ref (lo + 1) and j = ref (hi - 1) in
         while !i < !j do
           while not (cmp pivot arr.(!i)) do incr i done;
           while not (cmp arr.(!jpivotdo decr j done;
           if !i < !j then swap arr !i !j;
         done;
         let u = Thread.create (qsort lo) (!i-1) in
         let v = Thread.create (qsort (!i+1)) hi in
         Thread.join u;
         Thread.join v
       end in
     qsort 0 (Array.length arr - 1);;
Il serait correct, mais inintéressant d’intervertir les lignes 20 et 21. En effet, cela aurait pour conséquence d’attendre que la partie base du tableau soit triée pour lancer le tri de la partie haute. On obtiendrait alors le comportement d’un programme séquentiel, plus le coût des coprocessus sans en retirer aucun bénéfice.

En pratique, on devrait limiter la parallélisation à un facteur raisonnable et continuer de façon séquentielles au delà.

Les autres formes de suspension sont liées à des ressources du système d’exploitation. Un coprocessus peut se suspendre pendant un certain temps en appelant delay s. Une fois s secondes écoulées, il pourra être relancé.

     
   val Thread.delay : float -> unit

Cette primitive est fournie pour des raisons de portabilité avec les coprocessus simulés, mais Thread.delay t est simplement une abréviation pour ignore (Unix.select [] [] [] t). Cet appel, contrairement à Thread.join, n’est pas relancé lorsqu’il est interrompu par un signal.

Pour synchroniser un coprocessus avec une opération externe, on peut utiliser la commande select d’Unix. Bien entendu, celle-ci ne bloquera que le coprocessus appelant et non le programme tout entier. (Le module Thread redéfinit cette fonction, car dans le cas des coprocessus simulés, elle ne doit pas appeler directement cette du module Unix, ce qui bloquerait le programme tout entier, donc tous les coprocessus. Il faut donc utiliser Thread.select et non Unix.select, même si les deux sont équivalents dans le cas des coprocessus natifs.)

Exemple:

Pour faire fonctionnner le crible d’Ératosthène avec des coprocessus plutôt que par duplication de processus Unix, il suffit de remplacer les lignes 30–41 par

     
       let p = Thread.create filter (in_channel_of_descr fd_inin
       let output = out_channel_of_descr fd_out in
       try
         while true do
           let n = input_int input in
           if List.exists (fun m -> n mod m = 0) first_primes then ()
           else output_int output n
         done;
       with End_of_file ->
         close_out output;
         Thread.join p

et les lignes 46–52 par

     
     let k = Thread.create filter (in_channel_of_descr fd_inin
     let output = out_channel_of_descr fd_out in
     generate len output;
     close_out output;
     Thread.join k;;

Toutefois, il ne faut espérer aucun gain significatif sur cet exemple qui utilise peu de processus par rapport au temps de calcul.

7.4  Synchronisation entre coprocessus: les verrous

Les fonctions de cette section sont définies dans le module Mutex (pour Mutual exclusion, en anglais).

Nous avons évoqué ci-dessus un problème d’accès concurrent aux ressources mutables. En particulier, le scénario suivant illustre le problème d’accès aux ressources partagées. Considérons un compteur c et deux processus p et q, chacun incrémentant en parallèle le même compteur c.


     Temps 
Coprocessus p      lit k        écrit k+1  
Coprocessus q         lit k  écrit k+1     
Valeur de c      k  k  k+1  k+1  
Figure 7.1: Compétition pour l’accès à une ressource partagée.

Supposons le scénario décrit dans la Figure 7.1. Le coprocessus p lit la valeur du compteur c, puis donne la main à q. À son tour, q lit la valeur de c puis écrit la valeur k+1 dans c. Le processus p reprend la main et écrit la valeur k+1 dans c. La valeur finale de c est donc k+1 au lieu de k+2.

Ce problème classique peut être résolu en utilisant des verrous qui empêchent l’entrelacement arbitraire de p et q.

Les verrous sont des objets partagés par l’ensemble des coprocessus d’un même programme qui ne peuvent être possédés que par un seul coprocessus à la fois. Un verrou est créé par la fonction create.

     
   val Mutex.create : unit -> Mutex.t

Cette opération ne fait que construire le verrou mais ne le bloque pas. Pour prendre un verrou déjà existant, il faut appeler la fonction lock en lui passant le verrou en argument. Si le verrou est possédé par un autre processus, alors l’exécution du processus appelant est gelée jusqu’à ce que le verrou soit libéré. Sinon le verrou est libre et l’exécution continue en empêchant tout autre processus d’acquérir le verrou jusqu’à ce que celui-ci soit libéré. La libération d’un verrou doit se faire explicitement par le processus qui le possède, en appelant unlock.

     
   val Mutex.lock : Mutex.t -> unit
   val Mutex.unlock : Mutex.t -> unit

L’appel système Mutex.lock se comporte comme Thread.join vis-à-vis des signaux: si le coprocessus reçoit un signal pendant qu’il exécute Mutex.lock, le signal sera pris en compte (i.e. le runtime OCaml informé du déclanchement du signal), mais le coprocessus se remet en attente de telle façon que Mutex.lock ne retourne effectivement que lorsque le lock a effectivement été acquis et bien sûr Mutex.lock ne levera pas l’exception EINTR. Le traitement réel du signal par OCaml se fera seulement au retour de Mutex.lock.

On peut également tenter de prendre un verrou sans bloquer en appelant try_lock

     
   val Mutex.try_lock : Mutex.t -> bool

Cette fonction retourne true si le verrou a pu être pris (il était donc libre) et false sinon. Dans ce dernier cas, l’exécution n’est pas suspendue, puisque le verrou n’est pas pris. Le coprocessus peut donc faire autre chose et éventuellement revenir tenter sa chance plus tard.

Exemple:

Incrémenter un compteur global utilisé par plusieurs coprocessus pose un problème de synchronisation: les instants entre la lecture de la valeur du compteur et l’écriture de la valeur incrémentée sont dans une région critique, i.e. deux coprocessus ne doivent pas être en même temps dans cette région. La synchronisation peut facilement être gérée par un verrou.

     
   type counter = { lock : Mutex.tmutable counter : int }
   let newcounter() = { lock = Mutex.create(); counter = 0 }
   let addtocounter c k =
     Mutex.lock c.lock;
     c.counter <- c.counter + k;
     Mutex.unlock c.lock;;

La seule consultation du compteur ne pose pas de problème. Elle peut être effectuée en parallèle avec une modification du compteur, le résultat sera simplement la valeur du compteur juste avant ou juste après sa modification, les deux réponses étant cohérentes.

Un motif fréquent est la prise de verrou temporaire pendant un appel de fonction. Il faut bien sûr prendre soin de le relâcher à la fin de l’appel que l’appel ait réussi ou échoué. On peut abstraire ce comportement dans une fonction de bibliothèque:

     
   let run_with_lock l f x =
     Mutex.lock ltry_finalize f x Mutex.unlock l

Dans l’exemple précédent, on aurait ainsi pu écrire:

     
   let addtocounter c =
     Misc.run_with_lock c.lock (fun k -> c.counter <- c.counter + k)

Exemple:

Une alternative au modèle du serveur avec coprocessus est de lancer un nombre de coprocessus à l’avance qui traitent les requêtes en parallèle.

     
   val tcp_farm_server :
     int -> (file_descr -> file_descr * sockaddr -> 'a) -> sockaddr -> unit

La fonction tcp_farm_server se comporte comme tcp_server mais prend un argument supplémentaire qui est le nombre de coprocessus à lancer qui vont chacun devenir serveurs sur la même adresse. L’intérêt d’une ferme de coprocessus est de réduire le temps de traitement de chaque connexion en éliminant le temps de création d’un coprocessus pour ce traitement, puisque ceux-ci sont créés une fois pour toute.

     
   let tcp_farm_server n treat_connection addr =
     let server_sock = Misc.install_tcp_server_socket addr in
     let mutex = Mutex.create() in
     let rec serve () =
       let client =
         Misc.run_with_lock mutex
           (Misc.restart_on_EINTR acceptserver_sock in
       treat_connection server_sock client;
       serve () in
     for i = 1 to n-1 do ignore (Thread.create serve ()) done;
     serve ();;

La seule précaution à prendre est d’assurer l’exclusion mutuelle autour de accept afin qu’un seul des coprocessus n’accepte une connexion au même moment. L’idée est que la fonction treat_connection fasse un traitement séquentiel, mais ce n’est pas une obligation et on peut effectivement combiner une ferme de processus avec la création de nouveaux coprocessus, ce qui peut s’ajuster dynamiquement selon la charge du service.

La prise et le relâchement d’un verrou est une opération peu coûteuse lorsqu’elle réussit sans bloquer. Elle est en général implémentée par une seule instruction «test-and-set» que possèdent tous les processeurs modernes (plus d’autres petits coûts induits éventuels tels que la mise à jour des caches). Par contre, lorsque le verrou n’est pas disponible, le processus doit être suspendu et reprogrammé plus tard, ce qui induit alors un coût supplémentaire significatif. Il faut donc retenir que c’est la suspension réelle d’un processus pour donner la main à un autre et non sa suspension potentielle lors de la prise d’un verrou qui est pénalisante. En conséquence, on aura presque toujours intérêt à relâcher un verrou dès que possible pour le reprendre plus tard si nécessaire plutôt que d’éviter ces deux opérations, ce qui aurait pour effet d’agrandir la région critique et donc la fréquence avec laquelle un autre coprocessus se trouvera effectivement en compétition pour le verrou et dans l’obligation de se suspendre.

Les verrous réduisent l’entrelacement. En contrepartie, ils risquent de provoquer des situations d’interblocage. Par exemple, il y a interblocage si le coprocessus p attend un verrou v possédé par le coprocessus q qui lui-même attend un verrou u possédé par p. (Dans le pire des cas, un processus attend un verrou qu’il possède lui-même...) La programmation concurrente est difficile, et se prémunir contre les situations d’interblocage n’est pas toujours facile. Une façon simple et souvent possible d’éviter cette situation consiste à définir une hiérarchie entre les verrous et de s’assurer que l’ordre dans lequel on prendra dynamiquement les verrous respecte la hiérarchie: on ne prend jamais un verrou qui n’est pas dominé par tous les verrous que l’on possède déjà.

7.5  Exemple complet: relais HTTP

On se propose de reprendre le relais HTTP développé dans le chapitre précédent pour servir les requêtes par des coprocessus.

Intuitivement, il suffit de remplacer la fonction establish_server qui crée un processus clone par une fonction qui crée un coprocessus. Il faut cependant prendre quelques précautions... En effet, la contrepartie des coprocessus est qu’ils partagent tous le même espace mémoire. Il faut donc s’assurer que les coprocessus ne vont pas se “marcher sur les pieds” l’un écrasant ce que vient juste de faire l’autre. Cela se produit typiquement lorsque deux coprocessus modifient en parallèle une même structure mutable.

Dans le cas du serveur HTTP, il y a quelques changements à effectuer. Commençons par régler les problèmes d’accès aux ressources. La fonction proxy_service qui effectue le traitement de la connexion et décrite à la section 6.13 appelle, par l’intermédiaire des fonctions parse_host, parse_url et parse_request, la fonction regexp_match qui utilise la bibliothèque Str. Or, cette bibliothèque n’est pas ré-entrante (le résultat de la dernière recherche est mémorisé dans une variable globale). Cet exemple montre qu’il faut également se méfier des appels de fonctions qui sous un air bien innocent cachent des collisions potentielles. Dans ce cas, nous n’allons pas réécrire la bibliothèque Str mais simplement séquentialiser son utilisation. Il suffit (et il n’y a pas vraiment d’autres choix) de protéger les appels à cette bibliothèque par des verrous. Il faut quand même prendre la précaution de bien libérer le verrou au retour de la fonction, pour un retour anormal par une levée d’exception.

Pour modifier au minimum le code existant, il suffit de renommer la définition de regexp_match du module Url en unsafe_regexp_match puis de définir regexp_match comme une version protégée de unsafe_regexp_match.

     
   let strlock = Mutex.create();;
   let regexp_match r string =
     Misc.run_with_lock strlock (unsafe_regexp_match rstring;;

La modification à l’air minime. Il faut toutefois remarquer que la fonction regexp_match regroupe à la fois le filtrage de l’expression et l’extraction de la chaîne filtrée. Il eût bien sûr été incorrect de protéger individuellement les fonctions Str.string_match et Str.matched_group.

Une autre solution serait de réécrire les fonctions d’analyse sans utiliser la bibliothèque Str. Mais il n’y a pas de raison pour un tel choix tant que la synchronisation des primitives de la bibliothèque reste simplement réalisable et ne s’avère pas être une source d’inefficacité. Évidemment, une meilleure solution serait que la bibliothèque Str soit ré-entrante en premier lieu.

Les autres fonctions appelées sont bien réentrantes, notamment la fonction Misc.retransmit qui alloue des tampons différents pour chaque appel.

Cependant, il reste encore quelques précautions à prendre vis à vis du traitement des erreurs. Le traitement d’une connexion par coprocessus doit être robuste comme expliqué ci-dessus. En particulier, en cas d’erreur, il ne faut pas que les autres processus soit affectés, c’est-à-dire que le coprocessus doit terminer «normalement» en fermant proprement la connexion en cause et se remettre en attente d’autres connexions. Nous devons d’abord remplacer l’appel à exit dans handle_error car il ne faut surtout pas tuer le processus en cours. Un appel à Thread.exit ne serait pas correct non plus, car la mort d’un coprocessus ne ferme pas ses descripteurs (partagés), comme le ferait le système à la mort d’un processus. Une erreur dans le traitement d’une connexion laisserait alors la connexion ouverte. La solution consiste à lever une exception Exit qui permet au code de finalisation de faire ce qu’il faut. Nous devons maintenant protéger treat_connection pour rattraper toutes les erreurs: notamment Exit mais aussi EPIPE qui peut être levée si le client ferme prématurément sa connexion. Nous ferons effectuer ce traitement par une fonction de protection.

     
   let allow_connection_errors f s =
     try f s with Exit | Unix_error(EPIPE,_,_) -> ()
     
     let treat_connection s =
       Misc.co_treatment s (allow_connection_errors proxy_servicein
Exercice 19   Réécrire la version du proxy pour le protocole HTTP/1.1 à l’aide de coprocessus.

7.6  Les conditions

Les fonctions décrites dans cette section sont définies dans le module Condition.

Le mécanisme de synchronisation par verrous est très simple, mais il n’est pas suffisant: les verrous permettent d’attendre qu’une donnée partagée soit libre, mais il ne permettent pas d’attendre la forme d’une donnée. Remplaçons l’exemple du compteur par une file d’attente (premier entré/premier sorti) partagée entre plusieurs coprocessus. L’ajout d’une valeur dans la queue peut être synchronisé en utilisant un verrou comme ci-dessus, car quelque soit la forme de la queue, on peut toujours y ajouter un élément. Mais qu’en est-il du retrait d’une valeur de la queue? Que faut-il faire lorsque la queue est vide? On ne peut pas garder le verrou en attendant que la queue se remplisse, car cela empêcherait justement un autre coprocessus de remplir la queue. Il faut donc le rendre. Mais comment savoir quand la queue ne sera plus vide, sinon qu’en testant à nouveau périodiquement? Cette solution appelée “attente active” n’est bien sûr pas satisfaisante. Ou bien elle consomme du temps de calcul inutilement (période trop courte) ou bien elle n’est pas assez réactive (période trop longue).

Les conditions permettent de résoudre ce problème. Les conditions sont des objets sur lequel un coprocessus qui possède un verrou peut se mettre en attente jusqu’à ce qu’un autre coprocessus envoie un signal sur cette condition.

Comme les verrous, les conditions sont des structures passives qui peuvent être manipulées par des fonctions de synchronisation. On peut les créer par la fonction create.

     
   val Condition.create : unit -> Condition.t

Un processus p qui possède déjà un verrou v peut se mettre en attente sur une condition c et le verrou v en appelant wait  c  v. Le processus p informe le système qu’il est en attente sur la condition c et le verrou v puis libère le verrou v et s’endort. Il ne sera réveillé par le système que lorsqu’un autre processus q aura signalé un changement sur la condition c et lorsque le verrou v sera disponible; le processus p tient alors à nouveau le verrou v.

     
   val Condition.wait : Condition.t -> Mutex.t -> unit

Attention! c’est une erreur d’appeler Condition.wait  c  v sans être en possession du verrou v. Le comportement de Condition.wait vis à vis des signaux est le même que pour Mutex.lock.

Lorsqu’un coprocessus signale un changement sur une condition, il peut demander ou bien que tous les coprocessus en attente sur cette condition soient réveillés (broadcast), ou bien qu’un seul parmi ceux-ci soit réveillés (signal).

     
   val Condition.signal : Condition.t -> unit
   val Condition.broadcast : Condition.t -> unit

L’envoi d’un signal ou d’un broadcast sur une condition ne nécessite pas d’être en possession d’un verrou (à la différence de l’attente), dans le sens où cela ne déclenchera pas une erreur «système». Cependant, cela peut parfois être une erreur de programmation.

Le choix entre le réveil d’un coprocessus ou de tous les coprocessus dépend du problème. Pour reprendre l’exemple de la file d’attente, si un coprocessus ajoute un élément dans une queue vide, il n’a pas besoin de réveiller tous les autres puisqu’un seul pourra effectivement retirer cet élément. Par contre, s’il ajoute un nombre d’éléments qui n’est pas connu statiquement ou bien qui est très grand, il doit demander le réveil de tous les coprocessus. Attention! si l’ajout d’un élément dans une queue non vide n’envoie pas de signal, alors l’ajout d’un élément dans une queue vide doit envoyer un broadcast, car il pourrait être immédiatement suivi d’un autre ajout (sans signal) donc se comporter comme un ajout multiple. En résumé, soit chaque ajout envoie un signal, soit seul l’ajout dans une queue vide envoie un broadcast. Le choix entre ces deux stratégies est un pari sur le fait que la queue est généralement vide (première solution) ou généralement non vide (deuxième solution).

Il est fréquent qu’un coprocessus n’ait qu’une approximation de la raison pour laquelle un autre peut être en attente sur une condition. Il va donc signaler sur cette condition par excès simplement lorsque la situation peut avoir évolué pour un coprocessus en attente. Un processus réveillé ne doit donc pas supposer que la situation a évolué pour lui et que la condition pour laquelle il est mis en attente est maintenant remplie. Il doit en général (presque impérativement) tester à nouveau la configuration, et si nécessaire se remettre en attente sur la condition. Il ne s’agit plus cette fois-ci d’une attente active, car cela ne peut se passer que si un coprocessus à émis un signal sur la condition.

Voici une autre raison qui justifie cette discipline: lorsqu’un coprocessus vient de fournir une ressource en abondance et réveille tous les autres par un broadcast, rien n’empêche le premier réveillé d’être très gourmand et d’épuiser la ressource en totalité. Le second réveillé devra se rendormir en espérant être plus chanceux la prochaine fois.

Nous pouvons maintenant donner une solution concrète pour les queues partagées. La structure de queue définie dans le module Queue est enrichie avec un verrou et une condition non_empty.

     
   type 'a t =
     { queue : 'a Queue.tlock : Mutex.tnon_empty : Condition.t }
   let create () =
     { queue = Queue.create();
       lock = Mutex.create(); non_empty = Condition.create() }

L’ajout n’est jamais bloquant, mais il ne faut pas oublier de signaler la condition non_empty lorsque la liste était vide avant ajout, car il est possible que quelqu’un soit en attente sur la condition.

     
   let add e q =
     Mutex.lock q.lock;
     if Queue.length q.queue = 0 then Condition.broadcast q.non_empty;
     Queue.add e q.queue;
     Mutex.unlock q.lock;;

Le retrait est un tout petit peu plus compliqué:

     
   let take q =
     Mutex.lock q.lock;
     while Queue.length q.queue = 0
     do Condition.wait q.non_empty q.lock done;
     let x = Queue.take q.queue in
     Mutex.unlock q.lockx;;

Après avoir acquis le verrou, on peut essayer de retirer l’élément de la queue. Si la queue est vide, il faut se mettre en attente sur la condition non_empty. Au réveil, on essayera à nouveau, sachant qu’on a déjà le verrou.

Comme expliqué ci-dessus, le signal Condition.broadcast q.non_empty (8) est exécuté par un coprocessus p en étant en possession du verrou q.lock. Cela implique qu’un coprocessus lecteur q exécutant la fonction take ne peut pas se trouver entre la ligne 13 et 14 où il serait prêt à s’endormir après avoir vérifié que la queue est vide mais ne l’aurait pas encore fait: dans ce cas, le signal émis par p serait inopérant et ignoré, le coprocessus q n’étant pas encore endormi et q s’endormirait et ne serait pas réveillé par p ayant déjà émis son signal. Le verrou assure donc que soit q est déjà endormi, soit q n’a pas encore testé l’état de la queue.

Exercice 20   Implémenter une variante dans laquelle la queue est bornée: l’ajout dans la queue devient bloquant lorsque la taille de la queue a atteint une valeur fixée à l’avance. (Dans un monde concurrent, on peut avoir besoin de ce schéma pour éviter qu’un producteur produise sans fin pendant que le consommateur est bloqué.
(Voir le corrigé)
Il faut introduire une condition supplémentaire non_full. On ajoute également un champ size pour permettre des queues de différentes tailles.
     
   type 'a t =
       { queue : 'a Queue.tsize : intlock : Mutex.t;
         non_empty : Condition.tnon_full : Condition.t; }
   
   let create k =
     if  k > 0 then
       { queue = Queuecreate(); size = klock = Mutexcreate();
         non_empty = Conditioncreate(); non_full = Condition.create() }
     else failwith "Tqueue.create: empty size";;
L’ajout est une combinaison des versions précédentes des fonctions add et take ci-dessus.
     
   let add x q =
     Mutex.lock q.lock;
     while Queue.length q.queue = q.size
     do Condition.wait q.non_full q.lock done;
     if Queue.is_empty q.queue then Condition.broadcast q.non_empty;
     Queue.add q x;
     Mutex.unlock q.lock;;
Le retrait est symétrique à l’ajout (et doit maintenant signaler non_full lorsque la queue est pleine avant le retrait) et est laissé au lecteur. On retrouve le comportement des queues non bornées en choisissant max_int pour size.

7.7  Communication synchrone entre coprocessus par événements

Les fonctions décrites dans cette section sont définies dans le module Event.

Verrous et conditions permettent ensemble d’exprimer toutes les formes de synchronisation. Toutefois, leur mise en œuvre n’est pas toujours aisée comme le montre l’exemple a priori simple des files d’attente dont le code de synchronisation s’avère a posteriori délicat.

La communication synchrone entre coprocessus par événements est un ensemble de primitives de communication de plus haut niveau qui tend à faciliter la programmation concurrente. L’ensemble des primitives de la bibliothèque Event a été initialement développé par John Reppy pour le langage Standard ML, appelé Concurrent ML [14]. En OCaml, ces primitives sont implantées au dessus de la synchronisation plus élémentaire par verrous et conditions. Les primitives sont regroupées dans le module Event.

La communication se fait en envoyant des événements au travers de canaux. Les canaux sont en quelque sorte des “tuyaux légers”: ils permettent de communiquer entre les coprocessus d’un même programme en gérant eux-mêmes la synchronisation entre producteurs et consommateurs. Un canal transportant des valeurs de type 'a est de type 'a Event.channel. Les canaux sont homogènes et transportent donc toujours des valeurs du même type. Un canal est créé avec la primitive new_channel.

     
   val Event.new_channel : unit -> 'a channel

L’envoi ou la réception d’un message ne se fait pas directement, mais par l’intermédiaire d’un événement. Un événement élémentaire est “envoyer un message” ou “recevoir un message”. On les construit à l’aide des primitives suivantes:

     
   val Event.send : 'a channel -> 'a -> unit event
   val Event.receive : 'a channel -> 'a event

Le construction d’un message n’a pas d’effet immédiat et se limite à la création d’une structure de donnée décrivant l’action à effectuer. Pour réaliser un événement, le coprocessus doit se synchroniser avec un autre coprocessus souhaitant réaliser l’événement complémentaire. La primitive sync permet la synchronisation du coprocessus pour réaliser l’événement passé en argument.

     
   val Event.sync : 'a event -> 'a

Ainsi pour envoyer une valeur v sur le canal c, on pourra exécuter sync (send c v). Le coprocessus est suspendu jusqu’à ce que l’événement se réalise, c’est-à-dire jusqu’à ce qu’un autre coprocessus soit prêt à recevoir de une valeur sur le canal c. De façon symétrique, un processus peut se mettre en attente d’un message sur le canal c en effectuant sync (receive c).

Il y a compétitions entre tous les producteurs d’une part et tous les consommateurs d’autre part. Par exemple, si plusieurs coprocessus essayent d’émettre un message sur un canal alors qu’un seul est prêt à le lire, il est un clair qu’un seul producteur réalisera l’événement. Les autres resteront suspendus, sans même s’apercevoir qu’un autre a été “servi” avant eux.

La compétition peut également se produire au sein d’un même coprocessus. Deux événements peuvent être combinés par la primitive choose:

     
   val Event.choose : 'a event list -> 'a event

L’événement résultant est une offre en parallèle des événements passés en arguments qui se réalisera lorsque qu’un exactement de ceux-ci se réalisera. Il bien faut distinguer là l’offre d’un événement de sa réalisation. L’appel sync  (choose  e1  e2) se synchronise en offrant au choix deux événements e1 et e2, mais un seul des deux événements sera effectivement réalisé (l’offre de l’autre événement sera simultanément annulée). La primitive wrap_abort permet à un événement d’agir lorsqu’il est ainsi annulé.

     
   Event.wrap_abort : 'a event -> (unit -> unit) -> 'a event

L’appel wrap_abort  e  f construit un événement e′ équivalent à e mais tel que si e n’est pas réalisé après sa synchronisation alors la fonction f est exécutée (cela n’a d’intérêt que si e′ fait parti d’un événement complexe).

Un coprocessus peut proposer de réaliser un événement sans se bloquer (un peu à la manière de Mutex.try_lock).

     
   val Event.poll : 'a event -> 'a option

L’appel Event.pool  e va offrir l’événement e mais si celui-ci ne peut pas être immédiatement réalisé, il retire l’offre au lieu de se bloquer et tout se passera comme s’il n’avait rien fait (ou plus exactement comme si l’expression Event.pool  e avait été remplacée par la valeur None). Par contre, si l’événement peut se réaliser immédiatement, alors tout se passera comme si le coprocessus avec effectué Event.sync  e, sauf que c’est la valeur Some  v plutôt que v qui est retournée.

Exemple:

Dans l’exemple 7.3 du crible d’Ératosthène la communication entre les différents coprocessus s’effectue par des tuyaux comme dans le programme d’origine, donc en utilisant la mémoire du système (le tuyau) comme intermédiaire. On peut penser qu’il serait plus efficace de communiquer directement en utilisant la mémoire du processus. Une solution simple consisterait à remplacer le tuyau par un canal sur lequel sont envoyés les entiers.

Passer des entiers dans le canal n’est pas suffisant car il faut aussi pouvoir détecter la fin du flux. Le plus simple est donc de passer des éléments de la forme Some  n et de terminer en envoyant la valeur None. Pour simplifier et souligner les changements en les minimisant, nous allons récupérer le code de l’exemple 5.2. Pour cela, nous simulons les tuyaux et les fonctions de lecture/écriture sur un tuyau par des canaux et de fonctions de lecture/écriture sur les canaux.

Il suffit de reprendre la version précédente du programme et de modifier les fonctions d’entrée-sortie pour qu’elles lisent et écrivent dans un canal plutôt que dans un tampon d’entrée-sortie de la bibliothèque Pervasives, par exemple en insérant à la ligne 2 le code suivant:

     
   let pipe () = let c = Event.new_channel() in cc
   let out_channel_of_descr x = x
   let in_channel_of_descr x = x
   
   let input_int chan =
     match Event.sync (Event.receive chanwith
       Some v -> v
     | None -> raise End_of_file
   let output_int chan x = Event.sync (Event.send chan (Some x))
   let close_out chan = Event.sync (Event.send chan None);;

Toutefois, si l’on compare l’efficacité de cette version avec la précédente, on trouve qu’elle est deux fois plus lente. La communication de chaque entier requiert une synchronisation entre deux coprocessus donc plusieurs appels systèmes pour prendre et relâcher le verrou. Au contraire, la communication par tuyau utilise des entrées/sorties temporisées qui permettent d’échanger plusieurs milliers d’entiers à chaque appel système.

Pour être juste, il faudrait donc également fournir une communication temporisée sur les canaux en utilisant le canal seulement pour échanger un paquet d’entiers. Le fils peut accumuler les résultats dans une queue qu’il est seul à posséder, donc dans laquelle il peut écrire sans se synchroniser. Quand la queue est pleine ou sur demande explicite, il la vide en se synchronisant sur le canal. Le père à lui même sa propre queue qu’il reçoit en se synchronisant et qu’il vide petit à petit.

Voici une solution (qui remplace des lignes 2–0 ci-dessus):

     
   type 'a buffered =
       { c : 'a Queue.t Event.channelmutable q : 'a Queue.tsize : int }
   let pipe () = let c = Event.new_channel() in cc;;
   
   let size = 1024;;
   let out_channel_of_descr chan =
     { c = chanq = Queue.create(); size = size };;
   let in_channel_of_descr = out_channel_of_descr;;
   
   let input_int chan =
     if Queue.length chan.q = 0 then begin
       let q = Event.sync (Event.receive chan.cin
       if Queue.length q > 0 then chan.q <- q
       else raise End_of_file
     end;
     Queue.take chan.q;;
   
   let flush_out chan =
     if Queue.length chan.q > 0 then Event.sync (Event.send chan.c chan.q);
     chan.q <- Queue.create();;
   
   let output_int chan x =
     if Queue.length chan.q = size then flush_out chan;
     Queue.add x chan.q
   
   let close_out chan =
     flush_out chan;
     Event.sync (Event.send chan.c chan.q);;

Cette version permet de retrouver une efficacité comparable à la version avec tuyau (mais pas meilleure).

Si l’on compare avec la version d’origine avec processus et tuyaux, il y a deux sources potentielles de gain: d’une part les coprocessus sont plus sobres et coûtent moins cher à leur lancement. D’autre part, la communication par le canal se contente de passer un pointeur sans recopie. Mais ces gains ne sont pas perceptibles ici, car le nombre de coprocessus créés et les structures échangées ne sont pas suffisamment grands devant le coût de l’appel système et devant les temps de calcul.

En conclusion, on peut retenir que la communication entre coprocessus a un coût qui peut aller jusqu’à celui d’un appel système (si le processus doit être suspendu) et que ce coût peut être significativement réduit en temporisant les communications pour communiquer moins souvent de plus grosses structures.

Exercice 21   Un serveur HTTP peut être soumis à une charge élevée et par à-coups. Pour améliorer le temps de réponse, on peut raffiner l’architecture d’un serveur HTTP en gardant toujours une dizaine de coprocessus prêts à traiter de nouvelles requêtes. Cela veut dire qu’un coprocessus ne traite plus une seule requête, mais une suite potentiellement infinie de requêtes qu’il lit dans une file d’attente.

Pour éviter l’écroulement de la machine, on peut limiter le nombre de coprocessus à une valeur raisonnable au delà laquelle le coût de la gestion des tâches dépasse la latence du service des requêtes (temps passés à attendre des données sur le disque, etc.). Au delà, on pourra garder quelques connexions en attente d’être traitées, puis finalement refuser les connexions. Lorsque la charge diminue et que le nombre de coprocessus est au-delà de la valeur “idéale”, certains se laissent mourir, les autres restent prêts pour les prochaines requêtes.

Transformer l’exemple 7.5 pour arriver à cette architecture.

7.8  Quelques détails d’implémentation

Implémentation des coprocessus en Unix

Le système Unix n’a pas été conçu au départ pour fournir du support pour les coprocessus. Toutefois, la plupart des implémentations modernes d’Unix offrent maintenant un tel support. Malgré tout, les coprocessus restent une pièce rapportée qui est parfois apparente. Par exemple, dès l’utilisation de coprocessus il est fortement déconseillé d’utiliser fork autrement que pour faire exec immédiatement après. En effet, fork copie le coprocessus courant qui devient un processus estropié car s’exécutant en croyant avoir des coprocessus qui en fait n’existent pas. Le père lui continue à s’exécuter a priori normalement. Le cas particulier d’un appel à fork où le fils lance immédiatement un autre programme ne pose quant à lui pas de problème. Heureusement! car c’est le seul moyen de lancer d’autres programmes.

Inversement, on peut faire fork (non suivi de exec) puis lancer ensuite plusieurs coprocessus dans le fils et le père, sans aucun problème.

Implémentation native et implémentation simulée en OCaml

Lorsque le système d’exploitation sous-jacent possède des coprocessus, OCaml peut fournir une implémentation native des coprocessus, en laissant le plus possible leur gestion au système d’exploitation. Chaque coprocessus réside alors dans un processus Unix différent mais partageant le même espace mémoire.

Lorsque le système ne fournit pas de support pour les coprocessus, OCaml peut les émuler. Tous les coprocessus s’exécutent alors dans le même processus Unix et leur gestion y compris leur ordonnancement est effectuée par l’environnement d’exécution d’OCaml. Toutefois, cette implémentation n’est disponible qu’avec la compilation vers du bytecode.

Le système OCaml offre une même interface programmatique pour les versions native et simulée des coprocessus. L’implémentation des coprocessus est donc dédoublée. Une implémentation pour la version émulée qui comporte son propre contrôleur de tâches et une autre implémentation qui s’appuie sur les coprocessus POSIX (1003.1c) et relève les fonctions des bibliothèques correspondantes au niveau du langage OCaml. Au passage, le langage OCaml effectue quelques tâches administratives simples et assure une interface identique avec la version émulée. Cela garantit qu’un programme compilable sur une architecture Unix reste compilable sur une autre architecture Unix. Toutefois, le fait que les processus soient émulés ou natifs peut changer la synchronisation des appels à des bibliothèques C, et ainsi changer, malgré tout, la sémantique du programme. Il faut donc prendre quelques précautions avant de considérer qu’un programme se comportera de la même façon dans les deux versions. Dans ce chapitre, le discours vaut principalement pour les deux implantations, mais rapelons qu’à défaut, nous avons pris le point de vue d’une implantation native.

Pour utiliser les coprocessus émulés, il faut passer l’option -vmthreads à la place de -threads au compilateur ocamlc. Cette option n’est pas acceptée par le compilateur ocamlopt.

Séquentialisation du code OCaml

L’implémentation des coprocessus en OCaml doit faire face à une des particularités du langage OCaml qui est la gestion automatique de la mémoire et sa consommation importante de données allouées. La solution retenue, qui est la plus simple et aussi généralement la plus efficace, est de séqentialiser l’exécution du code OCaml dans tous les coprocessus: un verrou de l’environnement d’exécution empêche deux coprocessus d’exécuter du code OCaml simultanément. Cela semble contraire à l’idée même des coprocessus, mais il n’en est rien. Car le verrou est évidemment relâché avant les appels systèmes bloquants et repris au retour. D’autres coprocessus peuvent donc prendre la main à ce moment là. Un cas particulier d’appel système étant l’appel à sched_yield effectué à intervalles de temps réguliers pour permettre de suspendre le coprocessus en cours d’exécution et donner la main à d’autres.

Sur une machine multiprocesseur, la seule source de vrai parallélisme provient de l’exécution du code C et des appels systèmes. Sur une machine mono-processeur, le fait que le code OCaml soit séquentialisé n’est pas vraiment perceptible.

Le programmeur ne peut pas pour autant s’appuyer sur cette séquentialisation, car un coprocessus peut donner la main à un autre à peu près à n’importe quel moment. À une exception près, la séquentialisation garantit la cohérence de la mémoire: deux coprocessus ont toujours la même vision de la mémoire, sauf, peut-être, lorsqu’ils exécutent du code C. En effet, le passage du verrou implique une synchronisation de la mémoire: une lecture à une adresse effectuée par un coprocessus qui se situe dans le temps après une opération d’écriture à cette même adresse par un autre coprocessus retournera toujours la valeur fraîchement écrite, sans besoin de synchronisation supplémentaire.

Coprocessus et signaux

D’une façon générale, l’utilisation des signaux est déjà délicate avec un seul coprocessus en raison de leur caractère asynchrone. Elle l’est encore plus en présence de plusieurs coprocessus car s’ajoute alors de nouvelles difficultés: à quel coprocessus doit être envoyé le signal? À tous, au principal, ou à celui en cours d’exécution? Que se passe-t-il si un coprocessus envoie un signal à un autre? En fait, les coprocessus ont été implantés avant de bien répondre à ces questions, et différentes implantations peuvent se comporter différemment par rapport aux signaux.

Les opérations Thread.join, Mutex.lock, and Condition.wait, bien qu’étant des appels systèmes longs, ne sont pas interruptibles par un signal. (Elle ne peuvent donc pas échouer avec l’erreur EINTR). Si un signal est envoyé pendant l’attente, il sera reçu et traité lorsque l’appel retournera.

La norme POSIX spécifie que le handler des signaux est partagé entre tous les coprocessus et au contraire le masque des signaux est propre à chaque coprocessus et hérité à la création d’un coprocessus. Mais le comportement des coprocessus vis-à-vis des signaux reste largement sous-spécifié et donc non portable.

Il est donc préférable d’éviter autant que possible l’utilisation de signaux asynchrones (du type sigalrm, sigvtalrm, sigchld, etc.) avec des coprocessus. Ceux-ci peuvent être bloqués et consultés avec Thread.wait_signal. On peut dédier un coprocessus au traitement des signaux: ne faisant que cela, il peut se mettre en attente sur la réception de signaux et entreprendre les actions nécessaires et mettre à jour certaines informations consultées par d’autres coprocessus.

De plus, les coprocessus OCaml (depuis la version 3.08) utilisent le signal sigvtalarm de façon interne pour effectuer la préemption des coprocessus. Ce signal est donc réservé et ne doit pas être utilisé par le programme lui-même, car il y a un risque d’interférence.

Pour aller plus loin

Nous avons montré comment utiliser les bibliothèques Sys, Unix, et Threads d’OCaml pour programmer des applications qui interagissent avec le système d’exploitation.

Ces bibliothèques relèvent les appels système Unix les plus importants au niveau du langage OCaml. Au passage, certains de ces appels système ont été remplacés par des fonctions de plus haut niveau, soit pour faciliter la programmation, soit pour maintenir des invariants de l’environnement d’exécution des programmes OCaml. En général, cela conduit à une économie dans l’écriture des applications.

Certaines fonctionalités du système Unix ne sont pas accessibles au travers des bibliothèques précédentes, mais il est toujours possible d’y accéder directement via du code C.

Il existe aussi une bibliothèque Cash dédiée à l’écriture de scripts en Ocaml. Cette bibliothèque complète la bibliothèque Unix dans deux directions différentes. D’une part, elle peut se voir comme une couche au dessus du module Unix qui offre, en plus de fonctions dédiées à l’écriture de scripts, de nombreuses variations autour des appels systèmes d’"Unix", en particulier en ce qui concerne la gestion des processus et des tuyaux. D’autre part, elle fournit quelques accès supplémentaires au système Unix.

Références


[Objective Caml]

[1]
Xavier Leroy, Michel Mauny. The Caml Light system, release 0.5. Documentation and user’s manual. Logiciel L-5, distribué par l’INRIA.
[2]
Xavier Leroy, Didier Rémy, Jacques Garrigue, Jérôme Vouillon et Damien Doligez. The Objective Caml system, documentation and user’s manual – release 3.06. Documentation distribuée avec le système Objective Caml par l’INRIA, août 2002. http://caml.inria.fr/pub/docs/manual-ocaml/.
[3]
Bruno Verlyck. Cash, the Caml Shell – release 0.20. Documentation distribuée avec le système Cash par l’INRIA, 2002. http://pauillac.inria.fr/cash/.


[Programmation du système Unix]

[4]
Le manuel Unix, sections 2 et 3.
[5]
Brian Kernighan, Rob Pike. The Unix programming environment, Addison-Wesley. Traduction française: L’environnement de la programmation Unix, Interéditions.
[6]
Jean-Marie Rifflet. La programmation sous Unix. McGraw-Hill.
[7]
Jean-Marie Rifflet. La communication sous Unix. McGraw-Hill.


[Architecture du noyau Unix]

[8]
Samuel Leffler, Marshall McKusick, Michael Karels, John Quarterman. The design and implementation of the 4.3 BSD Unix operating system, Addison-Wesley.
[9]
Maurice Bach. The design and implementation of the Unix operating system, Prentice-Hall.
[Ste92]
Richard W. Stevens. Advanced Programming in the Unix Environment. Addisson-Wesley, 1992.
[Ste92]
Kay A. Robbins and Steven Robbins. Practical Unix Programming. A Guide to Concurrency, Communication, and Multithreading. Prentice Hall, 1996.


[Culture générale sur les systèmes]

[10]
Andrew Tanenbaum. Modern Operating Systems, Second Edition ©2001. Prentice-Hall.
[11]
Andrew Tanenbaum. Operating systems, design and implementation, Prentice-Hall. Traduction française: Les systèmes d’exploitation: conception et mise en œuvre, Interéditions.
[12]
Andrew Tanenbaum. Computer Networks, Prentice-Hall. Traduction française: Réseaux: architectures, protocoles, applications, Interéditions.


[Typage des communications d'objets structurés]

[13]
Xavier Leroy, Michel Mauny. Dynamics in ML. Actes de FPCA 91, LNCS 523, Springer-Verlag.
[14]
John H. Reppy. Concurrent Programming in ML. Cambridge University Press, 1999.


[Programmation avec coprocessus]

[15]
David R. Butenhof. Programming with POSIX Threads. Addison-Wesley, 1997.


[Applications]

[16]
Pierce et al. Unison File Synchronizer. Version 2.9.1. User Manual and Reference. Logiciel libre, disponible, à l’URL http://www.cis.upenn.edu/~bcpierce/unison/.

Index

Les pages en italiques comme 6.4 renvoient à des explications dans le corps du document; les pages en gras comme 8.3 renvoient à des occurrences essentielles correspondants à une fonction Posix; les pages en fonte romanes comme 12.7 à la documentation.


1
INRIA Rocquencourt
2
Droits réservés. Distributé sous licence Creative Commons Paternité–Pas d’Utilisation Commerciale–Partage des Conditions Initiales à l’Identique 2.0 France. Voir le code juridique.

Ce document a été traduit de LATEX par HEVEA