Pipe : une méthode de communication inter-processus

Table des matières

Par défaut, il est difficile de faire communiquer deux processus entre eux. Comme on l’a vu dans un précédent article, même les processus pères et fils ne partagent pas le même espace mémoire. Il nous faut donc des moyens d’établir une communication inter-processus. Et l’un de ces mécanismes de communication, c’est le pipe.

Qu’est-ce qu’un pipe ?

Un pipe, ou un “tube” en français, est une section de mémoire partagée qui facilite la communication entre processus. Ce canal est unidirectionnel : un pipe a une extrémité de lecture et une extrémité d’écriture. Un processus peut alors écrire sur l’extrémité d’écriture ; ces données seront mises en mémoire tampon jusqu’à ce qu’elles soient lues par un autre processus depuis l’extrémité de lecture du tube.

Diagramme du fonctionnement d'un pipe ou tube. Illustre le fait qu'un processus père peut écrire dans le bout d'écriture du pipe et le processus fils peut y lire depuis son bout de lecture, etc inversement pour créer une communication inter-processus.

Un pipe prend la forme d’une sorte de fichier qui réside en dehors du système de fichiers et n’a pas de nom ou d’attributs particuliers. Pourtant, on le manipule comme un fichier grâce à ses deux descripteurs de fichier. Dans l’article sur les descripteurs de fichier, nous avons eu l’occasion de découvrir ce concept. En résumé, un descripteur de fichier (" file descriptor" ou simplement fd) est un nombre entier positif, l’index d’un fichier dans une structure de données contenant les informations de tous les fichiers ouverts dans le système. Quand on crée un pipe, donc, on reçoit deux descripteurs de fichier vers le même tube, l’un ouvert en mode lecture seule, et l’autre ouvert en mode écriture seule.

Il faut garder à l’esprit qu’il existe une limite de taille pour un pipe, qui varie selon les systèmes d’exploitation. Lorsque cette limite est atteinte, un processus ne pourra plus y écrire jusqu’à ce qu’on y lise assez de données.

Créer un pipe

On peut créer un pipe avec l’appel système pertinemment nommé pipe. Voici son prototype dans la bibliothèque <unistd.h> :

int pipe(int pipefd[2]);

En paramètres, pipe prend un tableau de deux entiers où stocker les deux descripteurs de fichiers qui représenteront les deux bouts du tube :

  • pipefd[0] : le bout de lecture
  • pipefd[1] : le bout d’ écriture

L’appel système pipe ouvrira les descripteurs de fichiers du pipe et les renseignera dans ce tableau qu’on lui fournit en paramètres.

En cas de succès, pipe renvoie 0. Par contre en cas d’échec, il renvoie -1, décrit l’erreur rencontrée dans errno, et ne remplit pas le tableau fourni.

Pour établir une communication inter-processus entre un processus père et son processus fils, il nous faudra donc d’abord créer le pipe. Quand on crée ensuite le fils, il aura un duplicata des descripteurs de ce pipe, puisqu’un processus fils est un clone de son père. Ainsi, le fils sera en mesure de lire depuis pipefd[0] les informations fournies par son père dans pipefd[1] et inversement. Évidemment, on peut aussi faire communiquer deux processus fils entre eux de cette manière.

Lire et écrire dans un pipe

Les descripteurs de fichiers d’un pipe s’utilisent de la même manière que tout autre descripteur de fichier. Afin d’y mettre des données ou de les récupérer, on pourra se servir respectivement des appels systèmes read et write de la bibliothèque <unistd.h>.

Il y a toutefois deux points à garder à l’esprit :

  • Si un processus tente de lire depuis un pipe vide, read bloquera le processus jusqu’à ce que des données soient écrites dans le pipe.
  • À l’inverse, si un processus tente d’écrire dans un pipe plein (c’est à dire à la limite de sa capacité), write bloquera le processus jusqu’à ce qu’assez de données soient lues pour permettre d’y écrire.

Fermer un pipe

Les bouts de lecture et d’écriture d’un pipe se ferment avec l’appel système close de la bibliothèque <unistd.h> comme tout autre descripteur de fichier. Cependant, il y a quelques particularités à prendre en compte concernant la fermeture des bouts d’un pipe.

Lorsque tous les descripteurs de fichier qui font référence au bout d’écriture d’un pipe sont fermés, un processus qui tente de lire depuis son bout de lecture verra le caractère de fin de fichier ( EOF ou “end of file”) et la fonction read renverra 0.

À l’inverse, si tous les descripteurs de fichier qui font référence au bout de lecture d’un pipe sont fermés et qu’un processus tente d’y écrire, write lancera le signal SIGPIPE ou, si le signal est ignoré, échouera avec l’erreur EPIPE dans errno.

Pour s’assurer que les processus reçoivent correctement les indications de terminaison ( EOF, SIGPIPE/ EPIPE), il est primordial de fermer tous les descripteurs dupliqués inutilisés. Sinon, on risque d’avoir des soucis de processus en suspens infini.

Exemple de pipe

Essayons donc de faire communiquer un secret du processus père à son processus fils. Dans ce programme en C, on créera un pipe avant d’engendrer un fils. Le processus fils héritera donc d’une paire de descripteurs de fichiers qui font référence au même pipe. Ensuite, les processus père et fils fermeront tous deux les descripteurs qu’ils n’utilisent pas. Puis le père écrira un secret dans le tube tandis que le fils tentera de le lire un octet à la fois tout en copiant sa lecture sur la sortie standard.

#include <fcntl.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

// Fonction utilitaire pour écrire
void writestr(int fd, const char *str)
{
 write(fd, str, strlen(str));
}

// Main
int main(void)
{
 int pipefd[2];  // Stocke les fd du pipe :
       //  - pipefd[0] : lecture seule
       //  - pipefd[1] : écriture seule
 pid_t pid; // Stocke le retour de fork
 char buf; // Stocke la lecture de read

// Crée un pipe. En cas d'échec on arrête tout
 if (pipe(pipefd) == -1)
 {
  perror("pipe");
  exit(EXIT_FAILURE);
 }
// Crée un processus fils
 pid = fork();
 if (pid == -1) // Echec, on arrête tout
 {
  perror("fork");
  exit(EXIT_FAILURE);
 }
 else if (pid == 0) // Processus fils
 {
 // Ferme le bout d'écriture inutilisé
  close(pipefd[1]);
  writestr(STDOUT_FILENO, "Fils : Quel est le secret dans ce pipe ?\n");
  writestr(STDOUT_FILENO, "Fils : \"");
 // Lit les caractères dans le pipe un à un
  while (read(pipefd[0], &buf, 1) > 0)
  {
   // Écrit le caractère lu dans la sortie standard
   write(STDOUT_FILENO, &buf, 1);
  }
  writestr(STDOUT_FILENO, "\"\n");
  writestr(STDOUT_FILENO, "Fils : Ohlala ! Je vais voir mon pere.\n");
 // Ferme le bout de lecture
  close(pipefd[0]);
  exit(EXIT_SUCCESS);
 }
 else // Processus père
 {
 // Ferme le bout de lecture inutilisé
  close(pipefd[0]);
  writestr(STDOUT_FILENO, "Pere : J'ecris un secret dans le pipe...\n");
 // Écrit dans le bout d'écriture du pipe
  writestr(pipefd[1], "\e[33mJe suis ton pere mwahahaha!\e[0m");
 // Ferme le bout d'ecriture (lecteur verra EOF)
  close(pipefd[1]);
 // Attend la terminaison du fils
  wait(NULL);
  writestr(STDOUT_FILENO, "Pere : Salut mon fils !\n");
  exit(EXIT_SUCCESS);
 }
}

Résultat d’un programme de test en C qui démontre comment faire en sorte que deux processus communiquent à travers un tube ou pipe.

Et voilà ! On a réussi à établir une communication inter-processus !

Par contre, si l’on oublie de fermer les bouts inutilisés du pipe dans chacun des processus, c’est à dire si l’on supprime les lignes surlignées dans le code ci-dessus, on aura le résultat suivant :

Résultat d’un programme de test qui démontre l’important de fermer les bouts inutilisés d’un tube ou pipe. Read attend encore l’écriture sur le tube car tous les processus n’ont pas fermé leurs descripteurs de fichiers.

Le processus fils reste indéfiniment en suspens, puisque read n’a pas reçu le caractère de fin de fichier ( EOF). Pourtant, le processus père a bien fermé son descripteur pour le bout d’écriture… C’est simplement que le processus fils n’a pas fermé le sien avant sa lecture. D’où l’importance de s’assurer d’avoir bien fermé les bouts inutilisés de nos pipes dans chaque processus.

Reproduire l’opérateur pipe “|” du shell

C’est aussi grâce à ces tubes de communication que fonctionne l’opérateur " |" du même nom dans un shell comme Bash.

Par exemple, disons qu’on a un fichier test.txt, et qu’on veut savoir combien de lignes il contient. La commande cat test.txt va afficher le contenu du fichier. Si on ajoute la commande wc -l (qui compte le nombre de lignes) à l’aide de l’opérateur pipe, on va naturellement afficher le nombre de lignes dans le fichier :

Exemple d’une commande shell qui contient un tube, un pipe. La sortie standard de la première commande est redirigée vers l’entrée standard de la deuxième avec un pipe.

La première chose qu’on remarque ici, c’est que quand on lance la commande cat test.txt | wc -l, le contenu du fichier ne s’affiche plus. Alors que fait exactement l’opérateur “|” ?

Il crée un pipe et deux processus fils, un pour la commande cat et un pour wc. Ensuite, il fait une redirection de la sortie standard de cat vers l’entrée standard de wc. La commande cat va donc écrire son résultat non pas sur la sortie standard (notre terminal), mais dans le pipe. Ensuite, la commande wc va aller chercher les données à traiter dans ce tube, plutôt que dans l’entrée standard. Voici un petit schéma pour visualiser le concept du pipe dans le shell :

Diagramme de l'utilisation de pipe pour rediriger l'entrée et la sortie standard de processus dans le cadre de commandes shell avec l'opérateur pipe |.

On pourrait reproduire ce comportement en dupliquant le bout d’écriture du pipe sur la sortie standard du premier fils et le bout de lecture sur l’entrée standard du second fils. Nous avons eu l’occasion de découvrir la fonction dup2 qui nous permettrait de faire ceci dans le précédent article sur les descripteurs de fichier.

Créer une pipeline comme un shell

Évidemment, le shell peut enchaîner plus de deux commandes avec l’opérateur pipe “|”, comme par exemple man bash | head -n 50 | grep shell | grep bash | wc -l. C’est ce qu’on appelle une pipeline, c’est à dire une série de pipes.

Si, pour répliquer un tel pipeline, on se contente d’utiliser un seul pipe pour l’entrée et la sortie de tous les processus fils, on rencontrera de gros problèmes. Vu que les processus fils sont exécutés simultanément, ils se bagarreront pour lire et écrire dans un seul tube. Et inévitablement, l’un d’entre eux finira par attendre une entrée qui ne viendra jamais.

Ce qu’il faut faire si l’on souhaite construire une pipeline, c’est créer un tube (une paire de descripteurs de fichiers) pour chaque processus fils, moins 1. Comme ça, le premier peut écrire dans la sortie de son propre tube, le deuxième lire depuis le tube du premier et écrire dans son propre tube et ainsi de suite.

Diagramme d'une pipeline similaire à ce que fait un shell pour gérer de multiples commandes exécutées avec l'opérateur pipe |.

Et surtout, il ne faut pas oublier de fermer tous les descripteurs de fichiers inutilisés de tous les pipes dans chacun des processus fils !


Une autre astuce à partager, une petite question à poser, ou une découverte intéressante à propos des pipes ou des pipelines ? Je serai ravie de lire et de répondre à tout ça dans les commentaires. Bon code !

Sources et lectures supplémentaires

  • Manuel du programmeur Linux :

  • Shahriar Shovon, Pipe System Call in C [LinuxHint]

  • CodeVault, Simulating the pipe “|” operator in C [YouTube]

Commentaires

Articles connexes

Envoyer et intercepter un signal en C

À force d’être confrontés à des segfaults ou a des erreurs de bus, on se sera déjà familiarisé avec l’idée d’un signal informatique.

Lire la suite

Binaire 001 : compter et calculer comme un ordinateur

Comme on le sait tous, un ordinateur ne connaît que deux choses: les 1 et les 0.

Lire la suite

Variables locales, globales et statiques en C

Une variable, c’est un nom qu’on donne à un lieu de stockage en mémoire que notre programme peut ensuite manipuler.

Lire la suite