Communications inter-processus par pipes

Le but de ce TP est la conception d'un utilitaire transcript permettant d'exécuter un programme interactif arbitraire en gardant une trace, dans un fichier log.txt, de son entrée et sa sortie standard.

Ainsi, si on tape:

./transcript prog arg1 arg2 ... argN

cela lancera le programme interactif prog avec les arguments arg1 à argN. Chaque ligne entrée au clavier sera consignée dans le fichier log.txt et envoyée sur l'entrée standard de prog, et chaque ligne écrit par prog sur sa sortie standard sera consignée dans log.txt avant d'être envoyée sur le terminal. On distinguera dans log.txt les lignes d'entrée des lignes de sortie en ajoutant au début de chaque ligne soit un caractère >, soit un caractère <.

Voici un exemple de fichier log.txt obtenu avec ./transcript bc -l:

> 2+2
< 4
> 1/3
< .33333333333333333333

Exercices

Utilisation de pipes anonymes

1) Dans un premier temps, on ne s'intéresse qu'à communiquer l'entrée clavier à prog. Écrivez un programme transcript qui lance le programme prog passé en argument (fork et execvp) et communique avec lui grâce à un pipe (pipe). L'entrée standard de prog sera branchée sur la sortie du pipe (dup2). transcript lit ensuite en boucle les lignes entrées au clavier. Chaque ligne lue est simplement envoyée à prog à travers le pipe. La sortie standard de prog n'est pas (encore) redirigée.

Voici le schéma de fonctionnement:

transcript quitte quand l'utilisateur tape Contrôle+D ou quand le programme prog quitte. Attention aux signaux SIGCHLD et SIGPIPE!

Testez transcript sur:

Correction) transcript1.c.

2) Modifiez transcript pour qu'il consigne chaque ligne entrée au clavier dans le fichier log.txt avant de l'envoyer à prog.

Le schéma de fonctionnement devient alors:

Correction) transcript2.c.

3) On souhaite maintenant consigner la sortie de prog en plus de son entrée. On propose le schéma de fonctionnement suivant:

Dans ce schéma, un appel à transcript génère trois processus qui communiquent à travers deux pipes: le processus de gauche (père) consigne l'entrée clavier; le processus du milieu (fils 1) lance prog par un execvp; enfin, le processus de droite consigne la sortie écran.

Deux processus écrivent maintenant de manière concurrente dans le fichier log.txt. Il y a donc un danger de race-condition: un processus peut écraser ce qui vient d'être écrit par l'autre, ou bien des morceaux de lignes des deux processus peuvent se mélanger dans le fichier. Plusieurs solutions sont envisageables:

Remarque: si un fichier est ouvert avant un fork, les deux processus partagent le même curseur indiquant l'offset (position) courant. Tout déplacement dans le fichier effectué par un processus (e.g., par lseek, mais également implicitement après toute lecture ou écriture) modifie la position courante pour l'autre processus. Si deux processus ouvrent un même fichier indépendemment, chacun possède son propre curseur.

Assurez-vous de la cohérence temporelle du fichier log.txt: si une ligne est affichée par prog en réponse à une ligne entrée par l'utilisateur, la sortie apparaît bien après l'entrée dans log.txt.

Correction) transcript3.c.

Utilisation de select

4) On souhaite maintenant obtenir le même résultat que pour la question précédente mais en n'utilisant qu'un seul processus pour consigner à la fois l'entrée et la sortie. On a donc le schéma de fonctionnement suivant:

Ceci a l'avantage qu'un unique processus gère log.txt, donc on n'a donc pas de problème de race-condition.
Par contre, une difficulté apparaît car transcript doit maintenant lire deux entrées à la fois: l'entrée clavier et la sortie de prog. La solution naïve:

while (1) {
  lire le clavier
  ...
  lire la sortie de prog
  ...
}

ne fonctionnera pas. En effet, les opérations de lecture sont bloquantes. On peut donc se retrouver bloqué à lire la sortie de prog tandis que celui-ci est lui-même bloqué en attente d'une ligne en entrée (e.g., considérez le programme sort). On a une situation de deadlock!

La solution à ce problème est la fonction select permettant d'attendre simultanément sur plusieurs descripteurs de fichier qu'un au moins soit prêt à être lu.

Correction) transcript4.c.

Note sur les tampons

5) Comparez le comportement de transcript sur les deux versions suivantes de cat:

La différence observée provient des tampons (buffers) utilisés dans la gestion des flux de la bibliothèque C. En particulier, la bibliothèque C détecte automatiquement si les descripteurs de fichier 0 et 1 correspondent à des entrées/sorties interactives (TTY, cf. isatty) ou non (fichier disque, pipe, socket réseau, etc.). Dans le premier cas, le tampon est limité à une ligne pour garder un semblant d'interactivité. Dans le deuxième cas, le tampon est un bloc de taille fixée par le système (par exemple 8192), ce qui explique pourquoi ./transcript cat2 ne répond pas immédiatement après un retour à la ligne.

Il existe plusieurs solutions pour contourner les problèmes de tampon:

Malheureusement, il n'est pas possible de modifier ainsi tous les programmes susceptibles d'être lancés par transcript! La véritable solution à ce problème serait de remplacer les pipes par des pseudo-terminaux qui sont similaires mais donnent en plus une sémantique de terminal interactif aux descripteurs de fichiers (et forcent le mode de tampon ligne par ligne par défaut sur les flux de la bibliothèque C). C'est la solution adoptée par les programmes comme script ou expect.

Notez également qu'il existe d'autre tampons, au niveau du noyau. En particulier, toute entrée interactive depuis un (pseudo-)terminal passe par un tampon qui assemble, dans le noyau, une ligne complète avant de l'envoyer au processus lecteur. Ainsi même un read d'un seul caractère bloquera jusqu'à ce que l'utilisateur tape un retour à la ligne. Ceci ne nous dérangera pas puisqu'on souhaite justement lire ligne par ligne. Pour information, ce comportement peut être modifié par l'utilitaire stty ou bien la fonction C tcsetattr (drapeau ICANON).

Utilisation de pipes nommés

On souhaite obtenir le même résultat qu'aux questions 4) et 5) mais en combinant plusieurs petits programmes génériques grâce au shell (ce qui correspond assez bien à la philosophie UNIX) plutôt qu'en utilisant un seul gros programme dédié. On utilisera pour cela le programme tee qui recopie son entrée standard à la fois sur sa sortie standard et sur le fichier passé en paramètre. On aura besoin de deux instances de tee: une pour dupliquer l'entrée de prog, et l'autre pour dupliquer sa sortie. Il ne nous reste plus qu'à réaliser un utilitaire consigne qui recopie son entrée standard dans log.txt après avoir préfixé chaque ligne du caractère (< ou >) passé en argument.

On obtient le schéma de fonctionnement suivant:

6) Programmez consigne en C. Attention à gérer l'accès concurrent des deux processus dans le fichier log.txt (grâce à lockf par exemple). Programmez également un script shell qui lance tous les processus et réalise les interconnexions indiquées sur le schéma. Pour cela, les redirections <, > et | du shell seront utiles mais pas suffisantes. Il faudra utiliser judicieusement des pipes nommés (mkfifo)...

Correction) consigne.c et transcript6.sh.


Antoine Miné