Programmation système UNIX - Cours - PDF

Summary

Ce document est un cours sur la programmation système UNIX. Il couvre les concepts de base des processus, des fichiers, la communication inter-processus et la synchronisation. L'auteur est Xavier Hilaire d'ESIEE.

Full Transcript

Programmation système UNIX Notes de cours Xavier Hilaire [email protected] Table des matières 1 Les processus 3 1.1 Qu’est-ce qu’un processu...

Programmation système UNIX Notes de cours Xavier Hilaire [email protected] Table des matières 1 Les processus 3 1.1 Qu’est-ce qu’un processus.................................. 3 1.2 Création de processus.................................... 4 1.2.1 La fonction fork().................................. 4 1.2.2 Fork, traduction d’adresses, et mémoire virtuelle................. 6 1.3 Les fonctions getpid() et getppid()............................. 9 1.4 La fonction exit()...................................... 10 1.5 Les fonctions wait() et waitpid().............................. 10 1.6 Les fonctions exec()..................................... 11 2 Les fichiers 14 2.1 Qu’est-ce qu’un fichier ?................................... 14 2.2 Accès aux fichiers....................................... 14 2.2.1 Table des inodes (TI)................................ 14 2.2.2 Table des fichiers ouverts (TFO).......................... 15 2.2.3 Table des descripteurs de fichiers (TDF)...................... 15 2.3 Vue d’ensemble........................................ 15 2.4 Fonctions d’ouverture, écriture, lecture, et fermeture................... 16 2.4.1 La fonction open................................... 16 2.4.2 La fonction write.................................. 17 2.4.3 La fonction read................................... 18 2.4.4 La fonction close.................................. 19 2.4.5 La fonction lseek.................................. 19 2.5 Les redirections........................................ 20 2.5.1 Introduction..................................... 20 2.5.2 La fonction dup().................................. 20 2.5.3 La fonction dup2().................................. 21 2.6 La fonction fcntl()..................................... 22 2.7 Effet de fork sur les fichiers................................. 22 3 Communication inter processus 24 3.1 Motivations.......................................... 24 3.2 Les verrous de fichiers.................................... 26 3.2.1 Verrouiller un fichier en intégralité avec flock()................. 26 3.2.2 Mise en œuvre.................................... 26 3.3 La mémoire partagée..................................... 28 3.3.1 Création de mémoire partagée : la fonction shmget().............. 28 1 3.3.2 Attacher et détacher un segment : les fonctions shmat() et shmdt()...... 29 3.3.3 Consulter, modifier, ou détruire un segment : la fonction shmctl()....... 30 3.3.4 Mise en œuvre.................................... 30 3.4 Les tubes........................................... 33 3.4.1 Les tubes anonymes................................. 33 3.4.2 Les tubes nommés.................................. 37 4 Synchronisation de processus 40 4.1 Objectifs............................................ 40 4.2 Les signaux.......................................... 40 4.2.1 Principe........................................ 40 4.2.2 Liste des signaux standard............................. 43 4.2.3 Spécifier un masque de signaux........................... 44 4.2.4 Associer un gestionnaire de signal à un signal donné : la fonction sigaction() 44 4.2.5 Notifier un signal : la fonction kill()....................... 45 4.2.6 Premier exemple................................... 45 4.2.7 Spécifier les signaux à bloquer : la fonction sigprocmask()........... 46 4.2.8 Attendre un signal particulier : la fonction sigsuspend()............ 47 4.2.9 Deuxième exemple : le tic-tac............................ 47 4.3 Les sémaphores........................................ 50 4.3.1 Définition intuitive.................................. 50 4.3.2 Sémaphores et synchronisation........................... 52 4.3.3 L’interface System V................................. 52 4.3.4 Application des sémaphores............................. 54 2 Chapitre 1 Les processus 1.1 Qu’est-ce qu’un processus Un processus est une unité d’exécution, c’est-à-dire un ensemble unifié en mémoire centrale formé : — de code exécutable : c’est le code machine exécuté par le processeur. Ce code n’est accessible qu’en lecture seule, car stocké dans un segment appelé TEXT, qui en interdit matériellement la modification 1 — de données utilisateur : il s’agit de l’ensemble des données initialisées, accessibles et modi- fiables par l’utilisateur. Cette zone comprend les variables initialisées du programme, mais pas seulement : elle en effet à “géométrie variable”, puisqu’elle contient aussi : — le tas d’allocation dynamique : il s’agit des segments de mémoire alloués par malloc() — la pile utilisateur : c’est elle qui contient les variables de la classe C auto, et les pointeurs de code sauvegardés lors des appels de fonction utilisateur. — la pile système : si le système supporte la séparation des piles, elle contiendra la même chose, mais pour les appels réalisés par le système seulement L’ensemble constitue le segment DATA. — D’une zone mémoire appelée Process Control Block, qui contient les informations vitales au processus, et qui n’est jamais swappée (= échangée sur disque par le gestionnaire de mé- moire virtuelle). Sans prétendre être exhaustive, la Table 1.1 en montre les éléments les plus importants. La figure 1.1 montre une vue simplifiée et générique de l’organisation d’un processus en mémoire (parfois appelé plan d’adressage), qui peut varier d’une architecture à l’autre. Une notion fondamentale liée aux processus est celle de l’indépendance : un processus donné ne peut ni exécuter, ni accéder aux données d’un autre. Deux processus ne peuvent s’échanger des données que par l’intermédiaire du système (par des fonctions, ou des objets qui n’appartiennent ni à l’un, ni à l’autre, mais bien au système), ou par de la mémoire partagée. 1. Tenter d’écrire sur un segment TEXT provoque l’envoi du signal SIGSEGV, nous verrons cela au cours du chapitre sur les signaux. Gestion des processus Gestion de mémoire Gestion des fichiers Registres Pointeur seg. TEXT Répertoire courant (CWD) Priorité d’exéc. Pointeur seg. DATA Table des descr. de fichiers Pointeur de pile uid Etat (prêt, élu, bloqué) gid Signaux pid ppid Table 1.1 – Informations les plus importantes stockées dans le PCB 3 0xffffffff Pile adresses hautes DATA Tas d’allocation Données non initialisées (variables globales) Données initialisées (variables globales) Constantes adresses basses TEXT Code exécutable 0x00000000 Figure 1.1 – Plan d’adressage type d’un processus en mémoire centrale. Sur UNIX, les processus possèdent un identifiant unique, appelé Process ID, ou PID. Cet iden- tifiant est de type C pid_t, qui est assimilable à un type entier signé sur 16 bits. 1.2 Création de processus La création de processus sur UNIX se fait par clônage. Cela signifie que pour créer un nouveau processus, il va falloir procéder en un ou deux temps : 1. Dupliquer un processus existant en mémoire pour en créer un double (clône) parfait. C’est le rôle de la fonction fork() présentée en paragraphe 1.2.1 2. Si le code que doit exécuter le nouveau processus est identique à celui du processus dupliqué, il n’y a rien de plus à faire. Dans la négative, il convient de remplacer le code du processus dupliqué par un autre : c’est le rôle de la famille de fonctions exec, présentée en paragraphe 1.6. Le processus qui a demandé à se clôner en appelant fork() est appelé processus père, ou parent. A l’inverse, le nouveau processus créé est appelé processus fils, ou en enfant. De ce mécanisme de clônage, il résulte que tout processus possède un parent et un seul. La seule exception est le PID 0, qui contrairement à une idée largement répandue, n’est pas le noyau, mais le gestionnaire de mémoire virtuelle (swapper). Lorsqu’un processus devient orphelin, parce que son père a terminé son exécution, il est automatiquement adopté par le PID racine, qui est le géniteur de toute l’arbre généalogique. Sur UNIX, il s’agit de init, de PID 1. Sur Linux, ce fut le cas pendant longtemps, jusqu’à l’introduction du démon système utilisateur systemd : il y a un service qui gère les processus par utilisateur (donc étanchéité absolue entre utilisateurs). Le PID de ce dernier n’est pas fixe. 1.2.1 La fonction fork() Voici le synopsis de cette fonction : #include pid_t fork(void); La fonction fork() créé un nouveau processus en recopiant intégralement en mémoire le segment DATA du processus appelant vers un nouvel espace libre, et en mettant en commun son segment TEXT (ce dernier étant en lecture seule, et le code exécuté par les processus père et fils étant le même, sa recopie n’est pas nécessaire). 4 Il en résulte un nouveau processus (dit processus fils, ou enfant), qui est un clône parfait du processus appelant (dit processus père, ou parent), à trois différences près 2 : 1. Au processus père, fork() renvoit le PID du processus fils qui vient d’être créé 2. Au processus fils, fork() renvoie 0 3. Le PID du fils diffère du PID du père Il convient d’insister dès le départ qu’au retour de fork() : — Il n’y a pas un, mais deux processus qui reprennent, avec exactement le même contenu mémoire, mais qui vont évoluer de manière séparée. — La comparaison de la valeur de retour de fork() à zéro est le seul moyen pour chaque processus de savoir s’il est le processus créateur (père) ou au contraire le processus créé (fils). — Après retour de fork(), les deux processus reprennent chacun leur exécution de manière totalement indépendante — Il est impossible de prédire lequel des deux va reprendre son exécution en premier, le système n’apportant aucune garantie en la matière. En particulier, il n’est pas à exclure : — pour le processus parent, que le fils soit à l’état « élu » au retour de fork — que les deux reprennent exactement en même temps. En cas d’erreur, fork() renvoie une valeur négative, et la variable errno fournit un code d’erreur exploitable, qui correspond le plus souvent soit à une limite de processus par utilisateur atteinte (voir la commande ulimit pour la limitation des ressources utilsateurs), soit à une mémoire disponible insuffisante. Le listing 1.1 montre un exemple élémentaire de mise en œuvre de fork, dans lequel le processus appelant (père) affiche des numéros impairs, alors que son fils affiche des numéros pairs. Voici une copie d’écran du résultat obtenu sur une machine quadri-processeur : $./a.out Père: 1 Père: 3 Fils: 0 Père: 5 Père: 7 Père: 9 Fils: 2 Fils: 4 Fils: 6 Fils: 8 Il y a bien deux processus qui s’exécutent au retour de fork(), l’un et l’autre n’ont pas reçu la même valeur, et l’enchevêtrement des lignes écrites sur la sortie standard montre bien que les deux sont en exécution concurrentielle. Listing 1.1 – Mise en œuvre élémentaire de fork 1 #include 2 #include 3 4 int main ( ) { 5 int i ; 6 7 i f ( f o r k ( ) == 0 ) { 8 f o r ( i= 0 ; i < 1 0 ; i += 2 ) 9 p r i n t f ( " F i l s : ␣%d\n" , i ) ; 10 } 11 else { 12 f o r ( i =1; i < 1 0 ; i += 2 ) 13 p r i n t f ( " Père : ␣%d\n" , i ) ; 2. Il existe également d’aures différences et subtilités au niveau du système, mais elles sont transparentes pour le programmeur 5 14 } 15 16 return 0 ; 17 } Le nom de fork provient du fait qu’après appel de cette fonction, on créé une « fourchette » dans le chronogramme de filiation des processus. Ainsi, les deux représentations de la Fig. 1.2 sont presque équivalentes, car elles décrivent la même chose : un processus père (29) créé deux processus fils (30, 31). La seule différence est que la représentation temporelle est plus précise : elle permet de savoir ici que le processus 29 a repris son exécution (pendant un certain temps), après avoir créé le processus 30, et avant d’engrendrer le processus 31. Donc 30 a été créé avant 31. C’est d’elle que provient la notion de « fourchette ». Dans la représentation généalogique, on peut seulement dire que 30 et 31 sont des enfants de 30, sans plus (car l’ordre de création est perdu). L’avantage de la représentation temporelle est sa précision, son inconvénient est sa rapide illisibilité (envisager ce qui se passe au bout de 2, voire 3 générations) ; ceux de la représentation généalogique sont juste inverses. PID 29 29 temps PID 29 PID 30 30 31 PID 31 Figure 1.2 – Les deux diagrammes de filiation de processus les plus usités : à gauche, temporel ; à droite, généalogique 1.2.2 Fork, traduction d’adresses, et mémoire virtuelle Il a été dit plus haut que fork() procédait par recopie des données en mémoire centrale. Considérons alors le programme 1.2. Listing 1.2 – Fork et mémoire virtuelle 1 #include 2 #include 3 4 int tab [ 1 0 0 ] ; 5 6 void e c r i r e ( int f i l s ) { 7 int somme= 0 , i ; 8 9 p r i n t f ( " P r o c e s s (%d ) : ␣ tab=%p\n" , f i l s , tab ) ; 10 f o r ( i= 0 ; i < 1 0 0 ; i ++) 11 tab [ i ]= f i l s ; 12 f o r ( i= 0 ; i < 1 0 0 ; i ++) 13 somme += tab [ i ] ; 14 p r i n t f ( " P r o c e s s (%d ) : ␣somme␣=␣%d\n" , f i l s , somme ) ; 15 } 16 17 int main ( ) { 6 18 int r ; 19 20 r= f o r k ( ) ; 21 e c r i r e ( ( r == 0 ) ? 1 : 2 ) ; 22 23 return 0 ; 24 } Une exécution de ce programme donne la sortie suivante : $ cc -Wall -o fork2 fork2.c $./fork2 Process(2): tab=0x5578e5922060 Process(1): tab=0x5578e5922060 Process(1): somme = 100 Process(2): somme = 200 La sortie confirme bien que les deux processus ont écrit, puis relu tab avec des valeurs différentes, puisque la somme obtenue n’est pas la même. Mais comment expliquer que la valeur du pointeur de base du tableau soit exactement la même dans les deux cas, si les espaces mémoire qui ont été adressés ne sont pas les mêmes ? De deux choses l’une : 1. Soit le pointeur sur tab est le même et désigne la même adresse mémoire physique, ce qui serait possible dans cet exemple, et suggérerait que le code entre lignes 10 à 14 incluses a été exécuté non pas parrallèlement, mais séquentiellement. Mais cela contredirait ce qui a été annoncé plus haut, à savoir qu’un processus ne peut pas avoir accès aux données d’un autre. 2. Soit la valeur de ce pointeur est la même pour les deux processus, mais elle fait référence à des emplacements de mémoire physique différents. C’est la deuxième explication qui est la bonne. Et c’est là qu’interviennent deux éléments complé- mentaires et indipensables à UNIX : la traduction d’adresse, et la mémoire virtuelle. En réalité, une adresse mémoire n’est jamais absolue pour le processeur : elle est toujours traduite en une adresse physique, par un composant électronique appelé MMU (memory management unit), dont le rôle consiste à réécrire les bits les plus significatifs du pointeur à l’aide d’une ou plusieurs tables, dont le contenu dépend précisément du processus en cours d’exécution. À titre d’exemple, reprenons notre pointeur tab=0x5578e5922060. Pour fixer les idées, imaginons que la MMU ne soit qu’à une table. Nous pourrions décomposer le pointeur ainsi tab=0x 5578 |{z } e5922060 | {z } ent depl dans lequel ent = 5578 désigne l’entrée numéro 0x5578 dans une table, dont il faudra consulter la valeur afin de la substituer dans le pointeur pour obtenir l’adresse physique véritable. Supposons que la valeur en question soit 0x13a8e pour le processus en cours d’exécution, nous savons alors que l’adresse de base visée est en fait 0x13a8e0000000. depl = e5922060 désigne un déplacement à ajouter à l’adresse de base, ce qui donne 0x13a8e0000000 + 5922060 = 0x13a8e5922060 La figure 1.3 donne une idée de ce que cela pourrait donner en mémoire centrale. Insistons sur pourrait, car en effet : — La valeur du pointeur d’origine est particulièrement élevée : 0x5578e5922060 = 93977735995488 ≈ 94 × 1012 ! ! — Notre découpage impose de rattacher, à chaque processus, 0xffff = 65535 entrées de table en MMU, ce qui n’est pas négligeable sachant que la mémoire consommée ne doit pas être swappée. — Et il suppose aussi que la mémoire vive est découpable en segments de taille 0xffffffff = 4294967295 octets, soit 4 GO, ce qui est énorme. En réalité, UNIX va plus loin en utilisant non pas une, mais deux tables : une table des régions par processus, et une table des zones mémoire par région, comme le montre la figure 1.4. 7 0xffffffff Pile adresses hautes DATA Tas d’allocation Données non initialisées (variables globales) Données initialisées (variables globales) Constantes adresses basses TEXT Code exécutable 0x00000000 Figure 1.3 – Plan d’adressage type d’un processus en mémoire centrale. Mémoire centrale Table des régions Table des par processus régions partagée. Table des code x x. x x processus x. data x x 0 tas x x. x. 1 (autre). 2 x x x 3... x x x... code x x x data x. x. x x x. tas x x x struct U struct U (autre) x x x Figure 1.4 – Traduction des adresses virtuelles en adresses réelles par la MMU. 8 Cette solution présente trois avantages : 1. elle réagrège, sous une forme contiguë en apparence, des segments de mémoire vive totalement disparates 2. par rapport à la solution à table unique, elle permet un découpage plus fin de la mémoire centrale en blocs, et évite de gaspiller de l’espace pour stocker des entrées dont une faible partie seulement a de chances d’être utilisée 3. elle permet la recopie des données telles qu’elles par fork(), mais aussi le partage du code exécutable, comme le montre la figure 1.4, dans laquelle le processus 3 (en rouge) est un enfant du processus 2, et est montré juste après l’appel à fork(). Ceci se fait toutefois au coût d’un léger ralentissement des accès mémoire, puisque la MMU devra réaliser autant de lectures intermédiaires de ses tables qu’il existe de niveaux d’indirection à traverser pour résoudre l’adresse physique. Sur les architectures modernes, comme x86-64 (Intel), il est possible d’utiliser 3, et même 4 tables d’indirection. Ce procédé constitue la traduction d’adresses. La virtualisation de la mémoire centrale, ou mémoire virtuelle, consiste à envoyer des blocs de mémoire peu utilisés sur disque (on dit que le bloc est swappé), ou inversement, recharger un bloc swappé en mémoire centrale après en avoir envoyé un autre sur disque si nécessaire. Sur UNIX, le processus chargé de réaliser cela est le swapper, de PID 0. La virtualisation est un procédé coûteux, car les transferts vers et depuis le disque sont toujours beaucoup plus lents qu’un accès en une mémoire vive. Mais couplé à la traduction d’adresses, il présente deux avantages considérables : i) présenter à l’utilisateur une quantité de mémoire bien plus importante que celle disponible en mémoire vive, ii) moyennant un "bon" algorithme de régulation des échanges, libérer de l’espace mémoire peu utilisé en faveur des processus qui en ont le plus besoin. 1.3 Les fonctions getpid() et getppid() Elles retournent respectivement le PID, et le PID du père du processus appelant : #include #include pid_t getpid(void); pid_t getppid(void); Listing 1.3 – Mise en œuvre de getpid() et getppid() 1 #include 2 #include 3 #include 4 5 int main ( ) { 6 char ∗ q u i ; 7 8 i f ( f o r k ( ) == 0 ) q u i = " f i l s " ; 9 else qui = " pere " ; 10 11 p r i n t f ( "%s ␣ : ␣mon␣PID␣=␣%d , ␣PID␣ de ␣mon␣ p e r e ␣=␣%d\n" , 12 qui , g e t p i d ( ) , g e t p p i d ( ) ) ; 13 14 return 0 ; 15 } Le listing 1.3 montre un exemple de mise en œuvre. En voici la sortie d’écran : 9 $ cc getppid.c $./a.out pere : mon PID = 6300, PID de mon pere = 1615 fils : mon PID = 6301, PID de mon pere = 6300 $ Il est intéressant de remarquer ici que la valeur du PID du processus créé (6301) suit celle du processus parent (6300), mais que le PID du père de ce dernier (1615) est beaucoup plus faible. Rien d’anormal à cela, en fait, ce PID est tout simplement celui du shell à partir duquel on a invoqué./a.out : $ ps -aux | grep 1615 xavier 1615 0.0 0.1 21944 5740 pts/0 Ss 12:51 0:00 bash xavier 6320 0.0 0.0 12784 968 pts/0 S+ 22:34 0:00 grep 1615 1.4 La fonction exit() #include void exit(int status); Elle termine le processus appelant avec le code spécifié en paramètre, dont seuls les 8 bits les moins significatifs seront pris en compte (autrement dit, les codes de sortie sont toujours dans la plage 0– 255). C’est ce code que l’on récupère via la variable dollar ($) du shell, une valeur > 0 indiquant par convention une erreur. Lorsque le processus appelant meurt, toutes les zones mémoires qui lui avaient été allouées sont libérées (cf. figure 1.4), mais il survivra dans la table des processus où ce code est stocké, et ce jusqu’à ce que sont parent soit venu récupérer ce code via un appel à wait() ou waitpid(). Un processus qui a fait son exit(), mais dont le parent n’a pas encore fait d’appel à wait() ou waitpid(), est dit zombie. A noter egalement qu’un return 0 dans une fonction main() rend la main à une fonction système particulière (crt0), qui elle, fait l’appel à exit() immédiatement derrière. 1.5 Les fonctions wait() et waitpid() #include #include pid_t wait(int *wstatus); pid_t waitpid(pid_t pid, int *wstatus, int options); La fonction wait() : i) soit récupère le code de sortie de l’un des enfants zombie du processus appelant, sous réserve qu’il en existe au moins un, place ce code dans les bits 8 à 15 de l’entier pointé par wstatus, puis renvoie le PID du zombie définitivement mort à l’appelant. Si wstatus vaut NULL, le code n’est pas recopié. ii) soit fait basculer le processus appelant à l’état bloqué, si l’appelant possède bien au moins un enfant, mais aucun zombie ; il restera dans cet état jusqu’au décès effectif de l’un de ses enfants, et le traitement se poursuivra en i) iii) soit renvoie 0 si le processus appelant ne possède plus aucun enfant, zombie ou non. Cette fonction offre un moyen fondamental et simple de synchroniser un processus avec la termi- naison de l’un de ses fils, voire de tous si elle est appelée en boucle à la manière d’un while (wait(NULL) > 0) ; 10 Un exemple simple est celui où deux fils doivent trier parallèlement un tableau d’entiers, et écrire leur résultat sur fichiers, avant que le père relise les fichiers produits pour les fusionner. La fonction waitpid() fait la même chose, mais attend le fils de pid spécifié, qui doit exister à défaut de provoquer une erreur et une valeur de retour < 0. Le troisième paramètre, options, peut être une combinaison de WNOHANG et WUNTRACED, qui modifient totalement le comportement attendu, en permettant de tester (sans bloquer) s’il n’y pas d’enfant décédé, et de faire la même chose parmi les processus non tracés par un appel à ptrace(). On se contentera de la valeur de 0 dans le cadre de ce cours. Signalons ici que le stockage dans les bits 8–15 n’a en fait rien de fantaisiste : historiquement, les mots machine sont restés pendant très longtemps de 16 bits au mieux, et les bits 0–7 étaient utilisés pour représenter les 15 signaux les plus importants, et responsables de la mort du processus. Les signaux seront traités au chapitre 3. Le listing 1.4 montre un exemple élémentaire de mise en œuvre de wait(), dans lequel le fils attend volontairement 3 secondes avant de terminer et renvoyer son code (de 3 également). Ce qui valide l’attente forcée du père, qui reçoit alors bien 3 5 #include 6 7 int main ( ) { 8 9 i f ( f o r k ( ) > 0 ) { /∗ p è r e ∗/ 10 int s t a t , p i d= w a i t (& s t a t ) ; 11 12 p r i n t f ( " Père : ␣ code ␣ r e ç u ␣non␣ s h i f t é ␣du␣PID␣%d␣=␣%d , ␣ s h i f t é ␣=␣%d\n" , 13 pid , s t a t , s t a t >> 8 ) ; 14 } 15 e l s e { /∗ f i l s ∗/ 16 p r i n t f ( " F i l s : ␣mon␣PID␣=␣%d\n" , g e t p i d ( ) ) ; 17 sleep (3); 18 exit (3); 19 } 20 21 return 0 ; 22 } 1.6 Les fonctions exec() #include int execl(const char *path, const char *arg,...); int execlp(const char *path, const char *arg,...); int execle(const char *path, const char *arg,..., char * const envp[]); 11 int execv(const char *path, char *const argv[]); int execvp(const char *path, char *const argv[]); int execvpe(const char *path, char *const argv[], char *const envp[]); Les familles de fonctions execl et execv poursuivent le même but : abandonner l’exécution du processus appelant pour en exécuter un autre, dont le fichier binaire est spécifié en premier argument (path). On parle souvent de recourement, ou encore commutation d’image. Concrètement, le fichier spéficié par path, sous réserve qu’il existe, est chargé en mémoire centrale, puis le système commute : les données et le code exécutable de l’appelant seront définitivement perdus, et le processus substitué démarrera son exécution depuis son main(int argc, char *argv[], char *envp[]). Reste à voir comment les arguments argv et envp seront transmis à ce main(). En ce qui concerne argv, cela est résolu par la 5ème lettre du nom de la fonction (l ou v) : — Si la 5ème lettre est un ’l’, les arguments qui suivent path seront rassemblés par execl pour former le tableau unique argv[] qui sera reçu par main(). Le dernier de ces arguments doit toujours valoir NULL. — Au contraire, si la 5ème lettre est un ’v’, cela signifie que l’argument qui suit path représente déjà ce tableau de pointeurs terminé par NULL. Vous aurez donc nécessairement à charge de le créer. Il sera transmis tel quel. On profitera de l’occasion pour rappeler que, par convention, le premier élément de argv est toujours le nom du programme lui-même. Il n’y a donc rien d’étonnant à ce que les 2 premeirs arguments d’un execl soient identiques. Les lettres suivantes, optionnelles et qui peuvent être ’p’ ou ’e’, déterminent deux choses : — S’il y a un ’p’, cela signifie que les chemins stockés dans la variable d’environnement PATH, doivent être examinés un par un, et dans l’ordre, afin de déterminer si fichier binaire exécutable correspondant à path se trouve dans le répertoire en cours d’examen. Si tel est le cas, exec utilisera ce fichier. En principe, l’utilisation d’un execp n’a donc de sens que si que path n’est pas un chemin absolu, mais relatif. — S’il y a un ’e’, cela signifie que le dernier argument est un tableau de pointeurs terminé par NULL vers les variables d’envionnement à recevoir. Ce tableau sera lui aussi transmis tel quel, et vous avez à charge de le construire vous-même. — S’il n’y a pas de ’e’, le main reçoit les variables d’environnement du processus appelant. A noter que la valeur de retour d’un exec est toujours négative, et dénote nécessairement une erreur pour le processus appelant, puisque cela implique qu’il a survécu, donc qu’exec a échoué. Le programme présenté listing 1.5 met en œuvre execlp sur la ligne de commande ls -l -a /tmp, en utilisant fork et wait par la même occasion. Remarquer qu’en cas d’erreur, le processus fils peut renvoyer deux codes bien différents : 255 s’il s’agit d’un échec d’exec, et le code d’erreur renvoyé par ls elle-même si elle échoue. Listing 1.5 – Exec et fork 1 #include 2 #include 3 #include 4 #include 5 6 7 int main ( ) { 8 int r ; 9 10 i f ( f o r k ( ) == 0 ) { /∗ f i l s ∗/ 11 e x e c l p ( " l s " , " l s " , "− l " , "−a " , " /tmpxxx" , NULL ) ; 12 return 2 5 5 ; /∗ e r r e u r d ’ e x e c ∗/ 13 } 14 15 /∗ p e r e ∗/ 16 w a i t (& r ) ; 17 r >>= 8 ; 12 18 switch ( r ) { 19 case 0 : p u t s ( " Père : ␣ l s ␣ e x é c u t é ␣ avec ␣ s u c c è s. " ) ; 20 break ; 21 case 2 5 5 : p u t s ( " Père : ␣ é c h e c ␣ de ␣ e x e c l p " ) ; 22 break ; 23 default : p r i n t f ( " Père : ␣ l s ␣ s o r t ␣ avec ␣ l e ␣ code ␣%d\n" , r ) ; 24 } 25 26 return r ; 27 } 13 Chapitre 2 Les fichiers 2.1 Qu’est-ce qu’un fichier ? Historiquement, les fichiers ont servi à sauvegarder des informations, sous forme de séries d’octets, de manière permanente, sur un support ou un autre (disque, streamer,...). C’est ce que l’on appelle conventionnellement les fichiers ordinaires, et ces fichiers sont toujours d’actualité. Les opérations standard les plus importantes offertes par Unix pour les manipuler sont : — l’ouverture (open), la fermeture (close), et la duplication de descripteurs de fichiers (dup, dup2) — le positionnement à un emplacement donné du fichier (lseek) — la lecture (read), et l’écriture (write) Toutefois, et parce que ces opérations étaient applicables à des objets ou à des périphériques systèmes qui allaient bien au-delà des fichiers ordinaires, Unix a progressivement étendu leur spectre d’application, tout en conservant la même notion de fichier et d’interface. De nos jours, un fichier peut ainsi consister en : — Un moyen de communiquer directement avec un périphérique : — soit caractère par caractère (exemple : /dev/tty0 pour un terminal) — soit par bloc par bloc (exemple : /dev/sda pour accéder directement à un disque dur linéarisé, /dev/vtap* pour accéder directement à une interface réseau) — Un tube de communication. Les tubes seront traités au chapitre 3. — Une socket de communication — Un moyen d’obtenir des informations en provenance du système (exemples : /dev/random, /dev/cpu,...) — Et même... rien ! (/dev/null) L’ensemble des fichiers forme un arbre unique, rooté dans /. 2.2 Accès aux fichiers Unix vous permet d’accéder aux fichiers par l’intermédiaire de 3 tables, qui ne doivent surtout pas être confondues : la table des inodes en mémoire centrale, la table des fichiers ouverts, et la table des descripteurs de fichiers. 2.2.1 Table des inodes (TI) Un inode, pour index node, est une structure de données contenant les informations système sur un fichier, ce qui inclut (liste non exhaustive) : — Numéro d’inode (unique) — Référence et type du périphérique (fichier caractère, bloc, répertoire, lien symbolique, tube, fifo (tube), socket) — UID et GID du propriétaire 14 — Taille en octets — Droits d’accès — Horodatages (création, dernière consulation, dernière modification) — Le compteur de liens = entier qui compte combien de fois l’inode est référencé dans l’arbo- rescence à l’inode par l’intermédiaire de la commande ln (link) — La liste des blocs disque du fichier, ou du moins un pointeur vers le premier d’entre eux (dépend du système de fichiers utilisé) Les inodes sont présents sur disque, mais pour des raisons de performances, le système les charge en mémoire centrale dans la mesure du possible, formant ainsi la table des inodes. Cette table est unique. 2.2.2 Table des fichiers ouverts (TFO) Chaque fois qu’un processus fait appel à la fonction open pour ouvrir un fichier, il créé une nouvelle entrée dans la table des fichiers ouverts. Cette table système est unique, et chacune de ses entrées contient (entre autres) les informations suivantes : — Le chemin d’accès transmis à open pour l’ouverture — Le mode d’accès au fichier (lecture seule, écriture seule, lecture/écriture) — Un pointeur vers l’inode correspondant dans la table des inodes en mémoire — Le pointeur de fichier : il s’agit d’un entier qui indique à partir d’où la prochaine lecture ou écriture devra avoir lieu. Le pointeur de fichier existe toujours pour les fichiers ordinaires, mais ce n’est pas toujours le cas pour d’autres fichiers (tubes, sockets, FIFO, et la plupart des périphériques caractère) — Un compteur de références = entier qui compte combien de descripteurs de fichiers (voir paragraphe suivant) font référence à cette entrée. Initialisé à 1 lors du tout premier appel à open(). Le dernier champ (compteur de références) joue un rôle très important dans le maniement des tubes. Son fonctionnement est assez subtil, et est influencé par open, dup, close, mais aussi fork. Nous reviendrons sur ce point lorsque ces fonctions seront passées en revue, l’effet de fork étant expliqué en paragraphe 2.7. 2.2.3 Table des descripteurs de fichiers (TDF) Lorsque la fonction open a ouvert un fichier avec succès, elle renvoie un entier appelé descripteur de fichier, et elle créé une entrée dans une table qui les rassemble tous, qui est propre à chaque processus. Cette table est appelée table des descripteurs de fichiers, et ne doit surtout pas être confondue avec la table des fichiers ouverts. Comme nous l’avons mentionné au chapitre 1, la table des descripteurs de fichiers est stockée dans la struct U du processus, son nombre d’entrées est de fait généralement limité (typiquement, 32). Les entrées de cette table ne possèdent que très peu d’informations : — Un drapeau, qui indique si oui ou non l’entrée est disponible — Un pointeur vers l’entrée correpondante dans la table des fichiers ouverts Par défaut, les trois premières entrées (0 à 2 inclus) de cette table sont toujours réservées par le système, et associées respectivement à l’entrée standard (0), la sortie standard (1), et la sortie d’erreur standard (2). 2.3 Vue d’ensemble Il est primordial de bien comprendre comment le système gère l’accès aux fichiers en mémoire avant d’examiner en détail les fonctions de manipulation de fichiers. La figure 2.1 illustre tous les cas possibles à partir 3 fichiers (/tmp/toto, /tmp/titi, et /tmp/tata), qui vont être ouverts par 4 processus différents. Voici ce qui est supposé se passer, dans l’ordre : — Un premier processus A appelle open("/tmp/toto") pour ouvrir /tmp/toto. Il ne fait rien d’autre. On se retrouve avec une nouvelle entrée dans la TFO (la 12), le compteur de référence 15 Figure 2.1 – Organisation mémoire du système pour l’accès aux fichiers. (cref) est à 1, le pointeur de fichier (pfic) est au début de fichier, donc à 0, et l’inode corres- pondant à /tmp/toto est le 234. Côté TDF, une nouvelle entrée (la 3) est consommée, et le champ tfo pointe vers la nouvelle entrée de TFO, donc 12. — Un deuxième processus B fait de même pour /tmp/titi. On a le pointeur de fichier initialement à 0. Puis, il écrit 4 octets, disons XXXX, en utilisant le descripteur 3. Donc le pointeur de fichier passe à 4. Il fait alors un deuxième appel à open, pour ouvrir à nouveau /tmp/titi, et cette fois-ci le descripteur choisi par le système est 4. Comme précédemment, le pointeur de fichier associé à ce deuxième open est initialement à 0. Il écrit alors 7 octets, disons YYYYYYY en utilisant le descripteur 4. Résultat : — Les XXXX écrits en premier lieu sont écrasés par les YYYYYYY — Si le processus utilise 4 comme descripteur de fichier lors de sa prochaine écriture, il écrira à partir de l’octet 7 du fichier, donc à la suite des YYYYYYY — Au contraire, s’il utilise 3 comme descripteur de fichier lors de sa prochaine écriture, il écrira à partir de l’octet 4 du fichier, donc écrasera les 3 derniers YYY du fichier — Un troisième processus ouvre /tmp/titi, puis écrit 3 octets dans ce fichier à partir du descripteur qu’il a reçu. Puis, il forke. Résultat : — Le compteur de référence de l’entrée 31 de la TFO passe à 2 — Que ce soit le père ou le fils qui écrive, les écritures se feront à la suite, il n’y a aucun écrasement du fait que le pointeur de fichier est partagé — La règle vaut toujours si l’un et l’autre écrivent à la suite, sans déplacer le pointeur. 2.4 Fonctions d’ouverture, écriture, lecture, et fermeture Nous détaillons à présent ces quatres fonctions, qui sont respectivement open, write, read, et close. 2.4.1 La fonction open #include #include #include 16 int open(const char *pathname, int flags); int open(const char *pathname, int flags, mode_t mode); Elle ouvre le fichier de chemin pathname, avec les drapeaux spécifiés par flags. Ces derniers modifient la manière dont l’ouverture doit être faite. Les constantes suivantes sont des puissances de 2, et définissent donc chacune un bit de l’entier flags : Valeur Signification O_CREAT Si le fichier n’existe pas, il doit être créé en utilisant les droits spécifiés dans mode O_TRUNC Si le fichier existe, il doit est vidé O_EXCL Erreur si fichier existe déjà O_RDONLY Ouverture en lecture seule O_WRONLY Ouverture en écriture seule O_RDWR Ouverture en lecture et écriture O_APPEND Ouverture en ajout, ç-à-d écriture avec pointeur déplacé en fin de fichier. Remarquons que mode n’a de sens que si le bit O_CREAT est positionné, raison pour laquelle la fonction est 2 ou 3 paramètres. Si tel est le cas, alors la valeur du 3ème paramètre spécifie les droits UGO à utiliser en base octale. Par exemple, int fd= open("/tmp/monfichier", O_CREAT | O_RDWR, 0644) crééra /tmp/monfichier s’il n’existe pas déjà, et lui attribuera les droits 0645 = rw-r–r-x, donc accès en lecture/écriture pour l’utiisateur (U), lecture seule pour le groupe (G), lecture seule et exécution pour les autres (O). La fonction open retourne : — Soit un nouveau numéro de descripteur alloué dans la TDF du processus appelant, et qui est associé à l’ouverture. Ce descripteur de fichier sera le seul moyen de savoir de quelle ouverture et de quel fichier on parle pour les fonctions read et write. — Soit une valeur < 0 en cas d’erreur (causes les plus fréquentes : fichier inexistant, droits d’accès insuffisants, trop de fichiers ouverts) et la variable errno contient un code ad hoc. Remarque importante : même si Unix n’offre pas de garantie formelle sur ce point, le des- cripteur de fichier retourné par open() correspond en principe à la première entrée libre dans la TDF. 2.4.2 La fonction write #include ssize_t write(int fd, const void *buf, size_t count); Elle tente d’écrire les count octets qu’est supposée contenir la zone mémoire pointée par buf vers le fichier de descripteur fd préalablement ouvert par open(), et renvoie soit le nombre d’octets qui ont effectivement été écrits sur disque, soit une valeur négative en cas d’erreur (et la variable errno contient un code ad hoc le cas échéant). Il convient d’insister lourdement sur « tente » : même si la zone mémoire que vous avez désignée est valide, et même si vous ne pouvez toujours écrire sur disque parce que votre quota disque n’a pas encre été atteint (c’est une cause d’erreur possible), rien ne vous garantit que le système parviendra à tout écrire en un seul appel si count est suffisamment élevé, pour la simple raison que les appels systèmes de l’utilisateur peuvent être interrompus pour traiter un évènement de plus haute priorité. Ceci sera expliqué en détail dans le chapitre 3 sur les signaux. Toutefois, des valeurs de count inférieures ou égales à 512 garantissent des écritures sans interruption. La même règle vaut pour la lecture. Cette propriété est connue sous le nom de principe d’atomicité sous UNIX. A titre d’exemple, le listing 2.1 montre la bonne manière d’écrire une zone mémoire sur un fichier, après l’avoir ouvert. La fonction write peut être bloquante, ç-à-d qu’écrire sur un descripteur de fichier qui conduit à un périphérique ou à un objet système saturé peut conduire le système à faire basculer le processus 17 appelant à l’état bloqué, et ce jusqu’à ce que de la place soit libérée sur le périphérique ou l’objet en question. Ceci vaut surtout pour les tubes anonymes (présentés au chapitre 3). Le fait qu’un fichier soit bloquant en écriture ou non dépend complètement de sa nature, et est contrôlé par fcntl(). Les fichiers ordinaires ne sont jamais bloquants en écriture. Listing 2.1 – Écriture sur disque avec write 1 #include 2 #include 3 #include 4 #include < f c n t l. h> 5 #include 6 7 #define GRANDE_TAILLE 2000000 8 9 char tampon [GRANDE_TAILLE ] ; 10 11 int main ( ) { 12 int i , f ; 13 14 /∗ i n i t i a l i s e r tampon ∗/ 15 f o r ( i= 0 ; i < GRANDE_TAILLE; i ++) 16 tampon [ i ] = i %256; 17 18 /∗ c r é e r l e f i c h i e r ∗/ 19 i f ( ( f= open ( " /tmp/ t o t o " , O_CREAT | O_WRONLY, 0 6 0 0 ) ) < 0 ) 20 p e r r o r ( " E r r e u r ␣ de ␣ open ( ) \ n" ) ; 21 else 22 { /∗ é c r i r e l e tampon ∗/ 23 int somme= 0 ; 24 25 while ( somme < GRANDE_TAILLE) 26 { 27 i= w r i t e ( f , tampon+somme , GRANDE_TAILLE−somme ) ; 28 somme= somme+i ; 29 } 30 31 /∗ f e r m e r l e f i c h i e r ∗/ 32 close ( f ); 33 } 34 35 return 0 ; 36 } 2.4.3 La fonction read #include ssize_t read(int fd, void *buf, size_t count); Par opposition à write, mais tout en suivant une logique similaire, elle tente de lire count octets à partir du descripteur de fichier fd préalablement ouvert par open, et de les transférer vers la zone mémoire pointée par buf. La fonction retourne : — soit le nombre d’octets qu’elle a effectivement lus et transférés vers la zone mémoire pointée par buf, — soit 0, ce qui indique qu’il n’y a plus de données à lire à partir du fichier — soit une valeur < 0, et la variable errno contient un code ad hoc le cas échéant. 18 Tout comme write, elle peut être interrompue par un signal, sauf si count ≤ 512 en vertu du principe d’atomicité. Elle peut également être bloquante, ce qui se produit naturellement sur certains types de fichiers ou leur périphériques associés (les tubes, les sockets de communication, l’entrée standard, les terminaux, les lignes séries). 2.4.4 La fonction close #include int close(int fd); Elle libère le descripteur de fichier fd dans la TDF du processus appelant. Le compteur de réfé- rences dans la TFO est décrémenté, et s’il passe à zéro, le fichier est effectivement fermé (avec écriture sur périphérique en cas de mise en antémémoire) et disparaît aussi de la TFO, puisqu’il n’est plus utilisé par personne. La fonction renvoit soit 0 en cas de succès, soit une valeur négative, et la variable errno contient un code d’erreur le cas échéant. A défaut d’avoir été faits explicitement, les appels à close sont toujours faits par le système lorsqu’un processus termine (d’une manière ou d’une autre). 2.4.5 La fonction lseek #include #include off_t lseek(int fd, off_t offset, int whence); Sous réserve qu’il exsite, elle déplace le pointeur de fichier de descripteur fd de offset octets : — Soit à partir du début du fichier si whence vaut SEEK_SET — Soit à partir de la fin du fichier si whence vaut SEEK_END — Soit à partir de la position courante du pointeur si whence vaut SEEK_CUR Du fait qu’il soit possible de faire référence à la position courante du pointeur avec SEEK_SET, il résulte que offset est un entier signé : > 0 pour un déplacement en avant du pointeur de fichier, < 0 dans le cas contraire. La fonction renvoie toujours la position du pointeur de fichier après son déplacement par rapport au début du fichier. Les effets de lseek dépendent non seulement de l’existence ou non d’un pointeur associé au fichier ouvert, mais aussi de la possibilité de déplacer ce pointeur, et des conséquences que cela sous-entend. Ainso, appeler lseek résultera en une erreur : — si le fichier ne possède pas de pointeur de fichier, ou s’il en possède un mais qu’il pointe toujours sur sa fin et qu’il est non déplaçable (ex : stdin, stdout, fichier de port série /dev/ttyS*) — s’il en possède un, mais qu’il est ouvert en lecture seule, et que le déplacement demandé va au-delà de ses limites A l’inverse, appeler lseek sur un fichier ordinaire ouvert en écriture, avec une valeur de déplace- ment amenant le pointeur au-delà de sa taille, aura pour effet de l’agrandir. Par exemple, les lignes suivantes : int f=open("toto", O_TRUNC | O_CREAT | O_WRONLY, 0644); lseek(f, 2000, SEEK_SET); close(f); auront pour effet de créer un fichier toto de 2000 octets sur disque, au contenu parfaitement indeterminé. Mais ce fichier existera bien, et sera long de 2000 octets une fois le close(f) fait. 19 2.5 Les redirections 2.5.1 Introduction Qui n’a jamais lancé une ligne de la forme ls -laR / > /tmp/liste à partir d’un shell ? Nous allons utiliser cet exemple pour illuster ce qui se passe en détail au niveau système, avec un soin particulier pour le > /tmp/liste, c’est à dire comment Unix traite les redirections. Un examen des sources de la commande ls vous apprendra que, comme beaucoup de commandes, elle fait des appels à printf(). Rien de bien extraordinaire jusqu’ici, mais sauf si vous connaissiez déjà le contenu de ce chapitre, l’information nouvelle pour vous devrait être que printf() fait elle- même des appels à write(1,...,...), autrement dit écrit sur le fichier de descripteur 1, qui est toujours ouvert par défaut par le système, et désigne la sortie standard. Nous ne pouvons donc pas changer le comportement de ls, qui fera invariablement ses appels à write(1,...,...), peu importe ce qu’elle écrit. Comment faire en sorte que 1 ne soit plus la sortie standard, mais le fichier /tmp/liste ? Tout simplement en jouant sur le fait que la TDF peut associer au fichier 1 un autre fichier que celui ouvert par défaut par le système pour la sortie standard. Au paragraphe 2.4.1, nous avons dit que open() renvoyait en principe le premier descripteur libre dans la TDF. Si nous avons fermé 1 avant d’appeler open("/tmp/liste"), et que 0 (l’entrée standard) est toujours ouverte, open() devrait normalement nous renvoyer 1, donc le descripteur 1 de la TDF mènerait à /tmp/toto, et le tour serait joué. Le shell pourrait donc procéder ainsi : 1. Forker, ce qui permettra à son fils d’hériter des descripteurs de fichiers 0 à 3 2. Le fils ferme 1 3. Le fils ouvre /tmp/liste en écriture seule 4. Le fils fait un execlp sur ls 5. Le père (shell) ne fait rien de particulier pendant ce temps, il se contente d’attendre la mort de son fils. Ce qui pourrait donner le code suivant : Listing 2.2 – Redirection avec close et dup 1 2 i f ( f o r k ( ) == 0 ) { 3 int f ; 4 5 close (1); 6 f= open ( " /tmp/ l i s t e " , O_CREAT | O_TRUNC | O_WRONLY, 0 6 4 4 ) ; 7 e x e c l p ( " l s " , " l s " , "−laR " , " / " , NULL ) ; 8 e x i t ( 2 5 5 ) ; /∗ e r r e u r ∗/ 9 } 10 w a i t (NULL ) ; C’est une solution possible, mais elle présente un danger : si 0 a été fermée, elle ne fonctionnera pas. En réalité, le shell va faire presque la même chose, mais désignera le descripteur 1 à travers la fonction dup2() présentée ci-après, de façon à ne laisser aucun doute possible sur le descripteur à utiliser. Néanmoins, le principe de redirection par fermeture de fichiers et substitution d’en- trées dans la TDF que ne venons d’illustrer, reste exactement le même avec dup2. Il vaut aussi bien en écriture (> /tmp/liste) sur 1, qu’en lecture (< /tmp/liste) à partir de 0, que pour n’importe quel autre canal d’entrée ou sortie. 2.5.2 La fonction dup() #include 20 int dup(int oldfd); Cette fonction recopie l’entrée numéro oldfd de la TDF du processus appelant dans la première entrée libre qu’elle trouve dans la TDF, et renvoie le numéro de cette nouvelle entrée en valeur de retour. S’il n’y a plus de place dans la TDF, elle renvoie une valeur négative. Une fois la recopie faite, le compteur de référence du fichier auquel oldfd conduit dans la TFO est incrémenté. Les deux lignes suivantes int f= open("/tmp/toto", O_RDWR); int g= dup(f); produisent une configuration semblable à celle de la figure 2.1, sous réserve que /tmp/toto existe bien. Que l’on utilise alors f ou g pour lire, écrire, ou déplacer le pointeur de fichier, cela reviendra exactement au même, puisque le fichier ouvert auxquels ces deux descripteurs conduisent est en fait le même. 2.5.3 La fonction dup2() #include int dup2(int oldfd, newfd); Cette fonction fait la même chose que dup, sauf qu’elle réalise la copie vers le descripteur newfd (et pas un autre) spécifié au lieu d’utiliser la première entrée libre qu’elle trouve dans la TDF. Si l’entrée newfd n’est pas libre, dup2() la ferme d’abord par un close(newfd) avant de réaliser la copie. La valeur retournée est soit newfd en cas de succès, soit une valeur négative en cas d’erreur. A titre d’exemple, voici le code que l’on pourrait adopter pour la redirection ls -laR / > /tmp/liste du paragraphe 2.5.1 : Listing 2.3 – Redirection avec dup2 1 i f ( f o r k ( ) == 0 ) { 2 int f= open ( " /tmp/ l i s t e " , O_CREAT | O_TRUNC | O_WRONLY, 0 6 4 4 ) ; 3 4 dup2 ( f , 1 ) ; 5 close ( f ); 6 e x e c l p ( " l s " , " l s " , "−laR " , "/ " , NULL ) ; 7 e x i t ( 2 5 5 ) ; /∗ e r r e u r ∗/ 8 } 9 w a i t (NULL ) ; A comparer avec le code du listing 2.2. Remarquer que le close(f) en ligne 5 a simplement vocation a laisser une entrée de plus dans la TDF de ls, mais ne participe pas au mécanisme de redirection. La Figure 2.2 illustre le code du listing 2.3 étape par étape : 1. Initialement, l’entrée 0 de la TDF conduit au clavier, les entrées 1 et 2 vers l’écran 2. Etape a = ligne 2 du code : open créé /tmp/toto, et l’associe à une nouvelle entrée dans la TFO, et l’associe à l’entrée 3 de la TDF du processus. Noter que f=3 au retour de la fonction. 3. Etapes b et c = ligne 4 du code : dup2(3,1) a deux effets : fermer l’entrée 1 de la TDF (étape b), puis recopier l’entrée 3 de la TDF vers l’entrée 1 (étape c). En résultat, les descripteurs 1 et 3 conduisent toutes les deux à /tmp/toto 4. Etape d = ligne 5 du close : close(3) libère l’entrée 3 la TDF (on n’en a plus besoin). 21 TDF ent tfo ent cref pfic inode................ Processus Fils 0 b...... 1 c............ 2...... 3 a d TFO c......... TDF......... TI /tmp/liste Figure 2.2 – Effets du code en listing 2.3. 2.6 La fonction fcntl() #include #include int fcntl(int fd, int cmd,... ); Il s’agit d’une fonction « couteau Suisse », qui permet de réaliser l’opérations (cmd, d’argument(s) éventuel(s) arg..., sur le descripteur de fichier fd. Elle est très dépendante du système. Les opérations que l’on peut réaliser sont très diverses. En voici quelques unes sur BSD 4.4 : — F_GETFL et F_SETFL : lit ou positionne les drapeaux d’état du fichier, qui peuvent consister en une combinaison par OU arithmétique de — O_NONBLOCK : les lectures ou écritures deviennent non bloquantes — O_APPEND : les écritures se font en fin de fichier — O_ASYNC : envoie un signal SIGIO au processus appelant lorsque les données deviennent à nouveau disponible (voir chapitre 3 sur les signaux) — F_GETOWN et F_SETOWN : lit ou positionne le PID du processus qui doit recevoir les signaux SIGIO — F_GETLK et F_SETLK : lit l’état, ou positionne un verrou sur le descripteur de fichier fd pour un accès exclusif. Cette commande est similaire à flock(), détaillée dans le chapitre 3. 2.7 Effet de fork sur les fichiers Nous terminons ce chapitre par un paragraphe court, mais d’importance. Dans l’exemple de la figure 2.1, nous avons supposé que le dernier processus (C) forkait après avoir ouvert un fichier. Nous avons alors dit que le compteur de références cref passsait à 2 dans la TFO pour l’entrée 31. Quelle est la règle générale ? Le compteur cref doit toujours refléter le nombre de des- cripteurs qui désignent l’entrée de la TFO dans lequel il se trouve. Par conséquent, fork() ne peut pas se contenter d’une recopie en mémoire du processus forké. Elle doit également faire, après recopie et seulement dans le code du fils, une boucle pour mettre ce compteur à jour, et qui ressemble à ceci : pour toutes les entrées i de la TDF faire si TDF[i].tfo est associé à un fichier en TFO alors TFO[TDF[i].tfo]].cref++; fin si 22 fin pour Figure 2.3 – Effet de fork() sur la TFO. La figure 2.3 illustre le résultat sur un processus ayant exécuté le code suivant : int f= open("/tmp/toto", O_RDONLY); int g= open("/tmp/toto", O_RDONLY); int h= open("/tmp/titi", O_RDONLY); int i= dup(h); fork(); Avant fork(), les compteurs cref sont à 1 et 2 pour les entrées 12 et 21. Ils passent à 2 et 4 après fork(). Cette mise à jour est vitale pour les tubes anonymes, car, comme nous le verrons au chapitre 3, un compteur de lien à 0 à l’entrée d’un tube anonyme est le seul moyen de savoir qu’il est inutilisable (n’étant plus associé à aucun processus, plus personne ne peut écrire dedans). 23 Chapitre 3 Communication inter processus 3.1 Motivations Dans les chapitres précédents, nous avons vu : — que les processus s’exécutaient de manière indépendante : un processus n’a aucun moyen ni d’accéder à la mémoire d’un autre 1 , ni de savoir ce que cet autre fait. — que la seule donnée que peuvent échanger deux processus en mémoire est le code de sortie, récupérable par wait() ou waitpid(), d’un processus lorsqu’il meurt. Ce qui suppose un lien de parenté en plus de n’offrir que 8 bits d’information pertinente. Le but de la communication inter-processus, ou IPC pour Inter Process Communication en anglais, est de fournir un ensemble de fonctions systèmes à au moins deux processus sans lien de parenté particulier de s’échanger des données sans qu’aucun ne doive terminer. Nous avons déjà vu une technique qui permet de répondre au problème de l’échange : il s’agit des fichiers, qui peuvent être ouverts, lus, et modifiés par n’importe quel processus et n’importe quand, pourvu qu’ils en aient les droits. En dehors de fichiers, il n’existe qu’une seule autre technique qui permette d’échanger des données : la mémoire partagée. Comme son nom l’indique, elle permet à un ou plusieurs processus ayant droit de partage de lire ou écrire directement dans un segment particulier de la mémoire. Nous la traitons au paragraphe 3.3 de ce chapitre. Il est toutefois indispensable de comprendre dès le départ une chose simple : la plupart du temps, il ne suffit pas savoir comment échanger ; encore faut-il savoir quand le faire. Ce deuxième problème, qui n’est pas celui du partage, mais de la synchronisation, est tout aussi important. Pour voir cela, considérons le cas élémentaire d’un processus A qui veut envoyer un message à un processus B par l’intérmédiaire d’un fichier, ou d’une zone mémoire partagée. Si le message que veut envoyer A est plus long que 512 octets, quand B saura t’il que A a terminé d’écrire sa question ? Pour y répondre, on peut convenir que le fichier d’échange fonctionne comme une boîte à lettres, munies d’un drapeau qui indique si oui ou non il y a du courrier à relever dans la boîte. On peut penser alors à utiliser une structure de données comparable à la suivante : struct BAL { char plein; char message; }; et le processus A désirant déposer un message commence d’abord par remplir le champ message, puis positionne le champ plein à 1 en dernier. À supposer que les échanges se fassent en mémoire (ou dans des fichiers de 20001 caractères) par l’intermédiare d’une variables de type struct BAL struct BAL echange; le code que devra exécuter A sera alors : 1. Sauf si cette mémoire est partagée, ce qui fera justement l’objet d’un paragraphe de ce chapitre 24 question Processus A Processus B réponse Figure 3.1 – Échanges de données entre deux processus fonctionnant en client-serveur ecrire(echange.message); echange.plein = 1; tandis que B devra exécuter : while (echange.plein == 0) {} lire(echange.message); question.plein = 0; On peut alors voir d’emblée que même si cette solution fonctionne, elle est largement critiquable : elle implique en effet une boucle while dans le processus B, qui ne fera absolument rien, sinon d’attendre que le drapeau plein change d’état en consommant 100% du temps CPU pendant qu’elle attend... en pure perte ! On parle d’attente active dans pareil cas. Si les échanges se font via un fichier, la situation est encore pire : il faut relire le fichier en permanence. Il faut évidemment éviter cela. Or, le diagramme d’état des processus Unix, qui est reproduit en figure 4.1, permet justement d’éviter cela, en faisant basculer le processus en attente à l’état bloqué, où il n’est plus exécuté. Le processus ne repassera à l’état prêt que sur réception d’un signal, lequel n’est émis que lorsqu’une donnée est disponible ; nous verrons cela en détail au chapitre 3 lorsque nous étudierons les signaux. Il y a également un deuxième problème : si A veut envoyer des messages en boucle infinie, il doit attendre un acquittement de la part de B, lui signifiant que le dernier message dans la BAL a bien été lu, et peut être écrasé par un nouveau. Sinon le premier message est perdu. Il faut donc rajouter un membre supplémentaire dans la struct BAL pour que B puisse notifier son acquittement, et A devra lui aussi faire une boucle while vide pour attendre activement cet acquittement. Le problème de la synchronisation est traité en détail au chapitre 4. Pour l’instant, retenons simplement qu’échanger des données sans synchronisation ne présente pas grand intérêt en soi, et que les deux problèmes d’échange et de synchronisation sont presque toujours indissociables. À l’origine, IPC est un ensemble de services introduits par AT&T dès les premières versions de System V, son système UNIX propriétaire. Ils comprennent : 1. La mémoire partagée, dont la fonction est d’offrir le partage de segments de mémoire entre processus. Elle est toujours utilisée de nos jours. 2. Les sémaphores, qui servent à synchroniser les processus. Ils sont eux aussi toujours utilisés, mais on leur préfère souvent l’interface POSIX, qui est moins complexe que l’interface System V. Ils seront traités au chapitre 4. 3. Les files de messages, qui combinent les deux ressources précédentes de manière trans- parennte pour offrir une solution de partage synchronisée. Elles ne sont toutefois plus très utilisées, car largement supplantées par les tubes. 25 Tous ces services permettent des échanges directs entre processus sans passer explicitement par les fichiers. Or, il se trouve qu’il existe aussi un autre moyen de synchroniser et d’échanger des données, en n’utilisant que les fichiers : il s’agit des verrous. Nous présentons donc aussi cette solution dans ce chapitre, puisqu’elle répond elle aussi au problème de l’échange et de la synchronisation. 3.2 Les verrous de fichiers Les verrous de fichiers constituent le moyen le plus simple d’empêcher que deux processus ou plus n’accèdent en même temps à un fichier, ou à un segment de fichier. Il permettent de considérer le fichier comme une ressource à utilisation exclusive, également appelée ressource mutex. La logique des verrous est la suivante : — Un verrou n’est rattaché à un fichier et un seul — Il ne peut y avoir deux verrous rattaché à un même fichier — Lorsqu’un processus veut accéder à un fichier, il doit demander la permission préalable au système en demandant à verouiller le fichier. De deux choses l’une alors : — Soit le fichier n’est pas déjà verouillé, auquel cas la demande est acceptée, le fichier est verrouillé, et le processus poursuit son exécution. — Soit le fichier est déjà verouillé, auquel cas le système fait basculer le processus demandeur vers l’état bloqué. Il ne sera débloqué (pour repasser à l’état prêt) que lorsque le verrou posé aura été levé. — Après avoir utilisé le fichier, le processus devérouille le fichier. — Le verrouillage de fichier est une opération atomique, ç-à-d que si deux processus demandent à poser un verrou sur le même fichier en même temps, seul l’un d’entre eux y parviendra – l’autre sera bloqué. Le processus choisi par le système dans pareil cas est imprévisible. — La levée des verrous non levés explictement est automatiquement faite par le système lorsque le processus meurt. Ces règles fonctionnent aussi bien pour le verrouillage de tout un fichier que pour un segment seulement : seule la fonction à appeler change. 3.2.1 Verrouiller un fichier en intégralité avec flock() #include int flock(int fd, int operation); La fonction pose ou lève un verrou, selon que operation == LOCK_EX ou operation == LOCK_UN, sur le fichier identifié par fd, ouvert par open() avec le droit d’écriture (O_RDWR ou O_WRONLY). Elle renvoie 0 en cas de succès, et une valeur négative dans le cas contraire. Remarquer que comme il n’existe qu’un seul verrou par fichier, deux descripteurs menant au même fichier (par un dup) manipulent en fait le même verrou. 3.2.2 Mise en œuvre. Le listing 3.1 montre une mise en œuvre élémentaire de flock(). Le main() va d’abord crééer un fichier, et y inscrire la valeur entière 1. Il créé ensuite N fils. Tous les processus (y compris le père) vout alors rouvrir le fichier, et éxécuter une vingtaine de fois : — Lire l’entier du fichier, et l’afficher à l’écran — L’incrémenter — Réécrire la valeur incrémentée, et l’afficher à l’écran Listing 3.1 – Mise en œuvre de flock() pour l’accès exclusif à un fichier 1 #include 2 #include < s t d l i b. h> 3 #include 26 4 #include 5 #include 6 #include 7 8 #define N 2 9 10 int main ( ) 11 { 12 int n= 1 , i , f = open ( " /tmp/ echange " , O_RDWR | O_TRUNC | O_CREAT, 0 6 4 4 ) ; ; 13 14 15 w r i t e ( f , &n , s i z e o f ( int ) ) ; /∗ p è r e é c r i t l a v a l e u r i n i t i a l e dans l e f i c h i e r ∗/ 16 close ( f ); 17 18 f o r ( i= 0 ; i < N; i ++) /∗ c r é é N f i l s ∗/ 19 i f ( f o r k ( ) == 0 ) 20 break ; 21 22 /∗ TOUS l e s p r o c e s s u s e x é c u t e n t c o n r u r r e n t i e l l e m e n t l e code c i −d e s s o u s ∗/ 23 p r i n t f ( "PID␣%d␣ debout \n" , g e t p i d ( ) ) ; 24 srandom ( g e t p i d ( ) ) ; 25 26 /∗ r o u v r i r l e f i c h i e r ∗/ 27 f= open ( " /tmp/ echange " , O_RDWR) ; 28 29 f o r ( i= 0 ; i < 2 0 ; i ++) 30 { 31 f l o c k ( f , LOCK_EX) ; 32 33 /∗ l e c t u r e de l ’ e n t i e r ∗/ 34 l s e e k ( f , 0 , SEEK_SET ) ; 35 r e a d ( f , &n , s i z e o f ( int ) ) ; 36 p r i n t f ( "PID␣%d␣ a ␣ l u ␣%d\n" , g e t p i d ( ) , n ) ; 37 38 /∗ i n c r é m e n t e r , e t r é é c r i r e l a n o u v e l l e v a l e u r ∗/ 39 n=n+1; 40 l s e e k ( f , 0 , SEEK_SET ) ; 41 w r i t e ( f , &n , s i z e o f ( int ) ) ; 42 p r i n t f ( "PID␣%d␣ a ␣ é c r i t ␣%d\n" , g e t p i d ( ) , n ) ; 43 44 /∗ l i b é r e r l e v e r r o u ∗/ 45 u s l e e p ( random ( ) % 2 0 0 0 0 0 ) ; 46 f l o c k ( f , LOCK_UN) ; 47 u s l e e p ( random ( ) % 2 0 0 0 0 0 ) ; 48 } 49 50 p r i n t f ( "PID␣%d␣ meurt. \ n" , g e t p i d ( ) ) ; 51 52 return 0 ; 53 } Les appels à usleep() ont pour but de mettre en attente le processus appelant pendant un temps indéterminé. Les premières lignes affichées sont les suivantes : PID 3852 debout PID 3852 a lu 1 27 PID 3852 a écrit 2 PID 3853 debout PID 3854 debout PID 3853 a lu 2 PID 3853 a écrit 3 PID 3854 a lu 3 PID 3854 a écrit 4 PID 3853 a lu 4 PID 3853 a écrit 5 PID 3852 a lu 5 PID 3852 a écrit 6 PID 3854 a lu 6 PID 3854 a écrit 7 PID 3852 a lu 7 PID 3852 a écrit 8 PID 3854 a lu 8 PID 3854 a écrit 9 PID 3852 a lu 9 PID 3852 a écrit 10 PID 3854 a lu 10 PID 3854 a écrit 11 On constate que le comportement est bien celui attendu : c’est le même PID qui lit puis écrit. Il n’est jamais interrompu, malgré les appels à usleep(). Enlever les appels à flock() annule cette garantie. Les verrous de fichiers n’offrent malheureusement pas plus que la garantie d’accès exclusif à un fichier, ce qui limite leur utilité en pratique. 3.3 La mémoire partagée Si l’échange de données par fichiers s’avère trop lent pour l’application envisagée, la seule alter- native possible est d’utiliser la mémoire partagée. Cette technique permet à plusieurs processus de partager un même segment alloué en mémoire centrale. Ceci est possible grâce au mécanisme de traduction par la MMU à l’aide des tables de régions par processus, et de régions en mémoire centrale, que nous avons vu au chapitre 1. Nous renvoyons à cet effet le lecteur à la figure 1.4, et produisons une figure simplifiée en figure 3.2 pour les besoins de nos explications. L’allocation de mémoire partagée se passe en deux temps : 1. Un premier processus demande l’allocation d’un segment mémoire d’une taille spécifiée en mémoire centrale. Ceci est fait par la fonction shmget(), qui renvoie en retour un identifiant de mémoire partagée (c’est un entier). Ce premier processus peut alors continuer son existence, mais aussi mourrir : la mémoire allouée par shmget() est une ressource permanente, elle n’est pas conditionnée au processus qui l’a créé, et continue à exister malgré sa mort. 2. Tout processus détenant connaissant la valeur de cet identifiant, et possédant les droits pour le faire, peut alors rattacher le segment de mémoire partagée à son propre espace mémoire en invoquant shmat() sur la clé retournée par shmget(). L’action de shmat() consiste essen- tiellement à compléter les tables de régions par processus et de régions en mémoire centrale en créant les entrées supplémentaires nécessaires, et les remplissant avec les pointeurs adéquats, comme le suggère la figure 3.2. A noter que le rattachement du segment mémoire n’est possible que moyennant des droits UGO (user-group-others) qui sont spécifiés à shmget() lors de la création. Accéder à un segment de mémoire partagée ou à un fichier met en jeu le même système de droits. 3.3.1 Création de mémoire partagée : la fonction shmget() #include 28 Mémoire centrale Table des régions Table des par processus régions. Table des code x x. x x processus x. data x partagée x (shmget) 0 tas x x shmat. x. 1 (autre). 2 x x x 3... x x x... code x x x data x. x x. x x x. tas x x struct U struct U x (autre) shmat x x x x x Figure 3.2 – Allocation d’un segment de mémoire partagée #include int shmget(key_t key, size_t size, int shmflg); La fonction shmget() alloue un segment contigu de size octets en mémoire centrale. Le compor- tement de shmget() est conditionné par la valeur de key, et de deux bits particuliers des drapeaux shmflg : — Si key vaut la valeur particulière IPC_PRIVATE, cela signifie que le système a la liberté de choisir la valeur d’identifiant à retourner. Si ce n’est pas le cas, cela signifie que c’est cette valeur qui devra être retournée, et pas une autre. De deux choses l’une alors : l’identifint désigne déjà un segment alloué. Le comportement dépend alors de deux bits de shmflg. — Si shmflg & IPC_CREAT != 0, cela signifie que vous demandez la création d’un nouveau seg- ment en mémoire. Si de plus key != IPC_PRIVATE et que ce segment existe déjà, cela provo- quera une erreur. Sinon, le segment sera créé avec la valeur de key que vous avez spécifiée. — Si au contraire shmflg & IPC_CREAT == 0, alors shmget() ne crééra rien, mais regardera si key désigne un segment existant, et renverra -1 si ce n’est pas le cas. — Si shmflg & IPC_EXCL != 0 et shmflg & IPC_CREAT != 0 simultanément, alors shmget() échouera s’il existe déjà un segment d’identifiant key. Les 9 bits de poids faible de shmflg contiennent les droits d’accès UGO au segment de mémoire partagé. La fonction renvoie toujours un identifiant positif en cas de succès, et une valeur négative en cas d’erreur. 3.3.2 Attacher et détacher un segment : les fonctions shmat() et shmdt() #include #include void *shmat(int shmid, const void *shmaddr, int shmflg); int shmdt(const void *shmaddr); La fonction shmat() rattache le segment de mémoire partagé d’identifiant shmid à l’espace mé- 29 moire adressable par le processus appelant. Si shmaddr == NULL, le système choisit lui-même l’adresse à laquelle le rattachement est fait, et renvoie un pointeur vers elle en valeur de retour. Si shmaddr != NULL, alors le système essaiera de faire le rattachement à la valeur spécifiée exactement, sauf si shmflg & SHM_RND != 0, auquel cas l’adresse libre la plus proche multiple de SHMLBA sera utilisée. Si shmflg & SHM_RDONLY != 0, le segment est rattaché avec permission de lecture seulement. Par défaut, il y a permissions de lecture et écriture sous réserve que les droits le permettent. La fonction renvoie un pointeur vers l’adresse de base du segment, ou NULL en cas d’erreur. D’autres bits sont éga- lement possibles pour shmflg sur certains systèmes (notamment, SHM_PAGEABLE et SHM_SHARE_MMU). La fonction shmdt() permet quant à elle de détacher le segment shmaddr, prélablement attaché par shmat(). Elle renvoie 0 en cas de succès, une valeur négative dans le cas contraire. 3.3.3 Consulter, modifier, ou détruire un segment : la fonction shmctl() #include #include int shmctl(int shmid, int cmd, struct shmid_ds *buf); La fonction shmctl exécute la commande cmd sur le segment d’identifiant shmid. Si cmd == IPC_STAT, elle consulte l’état du segment et affecte la variable pointée par buf en conséquence. Au contraire, si cmd == IPC_SET, elle affecte le segment en fonction des informations qu’elle trouve dans *buf. Sur Linux : struct shmid_ds { struct ipc_perm shm_perm; size_t shm_segsz; time_t shm_atime; time_t shm_dtime; time_t shm_ctime; pid_t shm_cpid; pid_t shm_lpid; shmatt_t shm_nattch;... }; Enfin, si cmd == IPC_RMID , elle supprime le segment. 3.3.4 Mise en œuvre Nous allons montrer comment mettre en œuvre la mémoire partagée à l’aide de trois programmes : — creation.c, qui ne fait rien d’autre que créer un segment de mémoire partagée de 200 octets, et écrire la valeur de la clé. — modif.c, qui prend la clé spécifiée en paramètre, rattache le segment en mémoire, affiche son contenu, le modifie, puis le détache. — suppression.c, qui supprime le segment dont la clé est spécifiée en paramètre. Création du segment Le listing 3.2 montre le code du programme responsable de la création. Il fait très peu de choses. Listing 3.2 – Programme creation.c : création d’un segment de mémoire partagée 1 #include 2 #include 3 #include 30 4 5 int main ( ) 6 { 7 int key= shmget (IPC_PRIVATE, 2 0 0 , IPC_CREAT | IPC_EXCL | 0 6 6 0 ) ; 8 9 p r i n t f ( " c l é ␣=␣%d\n" , key ) ; 10 11 return 0 ; 12 } On pourra toutefois se convaincre qu’il fait bien ce qui est attendu grâce à la commande ipcs (IPC status) du shell : $ gcc -o creation creation.c $./creation clé = 5177364 $ ipcs -m ------ Segment de mémoire partagée -------- clef shmid propriétaire perms octets nattch états 0x0052e2c1 0 postgres 600 56 5 0x00000000 4096016 xavier 600 4194304 2 dest 0x00000000 5111827 xavier 600 524288 2 dest 0x00000000 5177364 xavier 660 200 0 Consultation et modification du segment Le programme de modification modif.c est présenté listing 3.3. Il faut toujours lui fournir la clé de segment en premier paramètre. Si on lui passe ensuite une chaine en paramètre supplémentaire, il initialise la mémoire du segment avec cette chaîne. Sinon, il incrémente tous les caractères de ce qu’il trouve en mémoire, et affiche le résultat. Listing 3.3 – Programme modif.c : modification de la mémoire partagée 1 #include 2 #include < s t d l i b. h> 3 #include 4 #include 5 #include 6 #include 7 8 int main ( int argc , char ∗ argv [ ] ) 9 { 10 char ∗mem; 11 int key= a t o i ( argv [ 1 ] ) , i ; 12 13 /∗ a t t a c h e r l e segment ∗/ 14 mem= shmat ( key , NULL, 0 ) ; 15 a s s e r t (mem != NULL ) ; 16 17 i f ( a r g c > 2 ) /∗ f o n c t i o n n e m e n t en i n i t i a l i s a t i o n ∗/ 18 { 19 s t r c p y (mem, argv [ 2 ] ) ; 20 p r i n t f ( "J ’ a i ␣ i n i t i a l i s é : ␣%s \n" , mem ) ; 21 } 22 e l s e /∗ f o n c t i o n n e m e n t en m o d i f i c a t i o n ∗/ 23 { 31 24 p r i n t f ( "J ’ a i ␣ t r o u v é : ␣%s \n" , mem ) ; 25 f o r ( i= 0 ; i < 200 && mem[ i ] != 0 ; i ++) 26 mem[ i ]++; 27 p r i n t f ( "J ’ a i ␣ é c r i t : ␣%s \n" , mem ) ; 28 } 29 30 /∗ d é t a c h e r l e segment ∗/ 31 a s s e r t ( shmdt (mem) == 0 ) ; 32 33 return 0 ; 34 } On pourra là encore se convaincre que la mémoire partagée est bien une ressource permanente : $ gcc -o modif modif.c $ $./modif 5177364 abcdefghj J’ai initialisé: abcdefghj $ $./modif 5177364 J’ai trouvé: abcdefghj J’ai écrit: bcdefghik $./modif 5177364 J’ai trouvé: bcdefghik J’ai écrit: cdefghijl Suppression du segment Listing 3.4 – Programme suppression.c : suppression du segment de mémoire partagée 1 #include 2 #include < s t d l i b. h> 3 #include 4 #include 5 #include 6 7 int main ( int argc , char ∗ argv [ ] ) 8 { 9 int key= a t o i ( argv [ 1 ] ) ; 10 11 a s s e r t ( s h m c t l ( key , IPC_RMID, NULL) == 0 ) ; 12 p r i n t f ( " Segment ␣%d␣ supprimé. \ n" , key ) ; 13 14 return 0 ; 15 } Elle est réalisée par le programme suppression.c, présenté listing 3.4. Un nouvel appel à ipcs -m dans le shell permettra de se convaincre que l’effet est bien celui recherché : $ gcc -o suppression suppression.c $./suppression 5177364 Segment 5177364 supprimé. $ ipcs -m | grep 5177364 $ 32 while (write(...) > 0) while (read(...) > 0) processus tube processus rédacteur a,b,c,d,e,f a,b,c,d,e,f lecteur a,b,c,d,e,f Figure 3.3 – Fonctionnement schématique d’un tube avec un producteur (rédacteur) et un consom- mateur (lecteur) 3.4 Les tubes Les tubes constituent la solution de communication inter-processus synchronisée la plus élégante, et la plus simple à utiliser qui soit. Schématiquement, un tube est un objet système qui s’apparente à une quantité limitée de mé- moire, et à une paire de descripteurs de fichiers : — les données qui sont écrites sur l’entrée du tube, sont recopiées par le système vers l’espace mémoire qu’il a alloué lors de la création du tube. Si cet espace est saturé, l’écriture est bloquante. — à l’inverse, lorsqu’une lecture est demandée sur la sortie du tube, le système recopie les données les plus anciennes depuis la mémoire vers la sortie, et libère la mémoire. S’il n’y a plus de données en mémoire, la lecture est bloquante. Le tube fonctionne donc comme une file FIFO (first in, first out), et le caractère bloquant des lec- tures et écritures, avec la mémoire limitée, fait qu’il implémente exactement la solution du producteur- consommateur : — un ou plusieurs processus rédacteurs produisent des données, qu’ils insèrent à l’entrée du tube — à l’autre bout du tube, se trouvent des consommateurs, qui récupèrent les données en les lisant — entre les deux, l’espace de stockage est limité ; il y a blocage soit lorsque cet espace est vide pour la lecture, soit lorsqu’il est saturé pour l’écriture. La figure 3.3 schématise l’utilisation typique d’un tube avec un processus rédacteur et un processus lecteur ; mais il peut parfaitement y avoir plusieurs producteurs et plusieurs consommateurs, sans que la sémantique de fonctionnement du tube s’en trouve modifiée. Il existe deux sortes de tubes : les tubes anonymes, et les tubes nommés. Dans les deux cas, il ne faut jamais oublier trois choses : — Les tubes sont des objets uniques, donc partagés par les processus (il n’y pas duplication de tube lorsqu’un processus forke, par exemple). C’est justement ce qui fait d’eux des outils de communication entre processus. — La communication dans un tube est toujours unidirectionnelle : de l’entrée du tube vers la sortie, et jamais l’inverse. Chercher à écrire dans la sortie d’un tube n’a pas beaucoup plus de sens que de lire son entrée, et provoque une erreur dans un cas comme dans l’autre. — La quantité de mémoire allouée par le système lors de la création d’un tube est toujours limitée. Par défaut, elle est de 4096 octets (4 KO) sur la plupart des systèmes. 3.4.1 Les tubes anonymes Création et héritage Un tube anonyme est créé par la fonction système pipe(), dont le prototype est le suivant : #include int pipe(int pipefd); La fonction attend en paramètre un pointeur vers un tableau de deux entiers, qui, après création du tube, contiendront les descripteurs de fichiers de la TDF que le système aura retenus pour désigner l’entrée et la sortie du tube : — filedes désigne l’entrée du tube 33 — filedes désigne la sortie du tube Du point de vue des fichiers, créer un tube par un appel à pipe() équivaut à faire 2 appels à open() pour ouvrir l’entrée et la sortie du tube simultanément. L’entrée et la sortie se manipulent alors comme des fichiers ordinaires infinis, qui ont la particularité de ne pas avoir de pointeur de fichier (comme c’est le cas pour l’entrée standard, et la sortie standard). Un appel à pipe() en début de programme laissera typiquement les tables de fichiers dans l’état présenté en figure 3.4. Figure 3.4 – Etat typique des tables de fichiers après un appel à pipe() en début de programme L’inversion 1-0 pour désigner l’entrée-sortie est fondé sur un moyen mnémotechnique bien utile : 1 est à rapprocher de la sortie standard (sur laquelle on ne peut qu’écrire), et 0 de l’entrée standard (sur laquelle on ne peut que lire). Un tube anonyme est hérité lors d’un fork(), ç-à-d que l’algorithme de duplication des entrées présenté au paragraphe 2.7 s’applique en l’état. Forker après avoir créé un tube résultera en un passage à 2 des compteurs de référence, et donnera la configuration de la figure 3.5. Remarquer que, comme déjà dit plus tôt, le tube lui-même n’est pas dupliqué, ce qui signifie que père et fils peuvent accéder tous les deux aux deux côtés du tube. La transmission des tubes anonymes par héritage est à double tranchant : — d’un côté, on a l’inconvénient que le tube ne peut être accédé que par la descendance du processus qui l’a créé ; — mais d’un autre, cela empêche que n’importe quel processus puisse s’imiscer dans une commu- nication qui ne le concerne pas, même si c’est par erreur qu’il le fait et non par intrusion. Figure 3.5 – Etat des tables de fichiers après un appel à pipe(), puis fork() 34 Mise en œuvre élémentaire Le programme présenté en listing 3.5 va nous permettre de mettre en lumière ce qui vient d’être dit. Il est des plus simples qui soient : après avoir créé un tube puis forké, le processus père se contente d’envoyer une chaîne de 19 caractères dans le tube. Cette chaîne est relue par le fils à l’autre bout du tube. Le programme n’a pas d’effet très spectaculaire : $ gcc -o pipe1 pipe1.c $./pipe1 Fils a lu ’Salutation du père’ $ mais il permet bien de valider qu’une chaîne, qui était inacessible au fils, a transité d’un processus à l’autre. Remarquer également que le sleep(1) en ligne 12 permet d’assurer le caractère bloquant de la lecture côté fils : le read() ne sort dans le fils qu’une fois le write() terminé dans le père. Listing 3.5 – Envoi d’une chaîne de caractère entre deux processus via un tube anonyme 1 #include 2 #include 3 4 int main ( ) 5 { 6 int f [ 2 ] ; 7 8 pipe ( f ) ; 9 10 i f ( f o r k ( ) > 0 ) /∗ code du p è r e ∗/ 11 { 12 sleep (3); 13 w r i t e ( f [ 1 ] , " S a l u t a t i o n ␣du␣ p è r e " , 1 9 ) ; 14 } 15 e l s e /∗ code du f i l s ∗/ 16 { 17 char msg [ 2 0 0 ] ; 18 19 r e a d ( f [ 0 ] , msg , 2 0 0 ) ; 20 p r i n t f ( " F i l s ␣ a ␣ l u ␣’% s ’ \ n" , msg ) ; 21 } 22 23 return 0 ; 24 } Transmission en continu On peut se demander dès lors comment modifier le programme 3.5 pour qu’il transmette des chaînes de caractères « en continu », et ce jusqu’à ce que le processus père termine. C’est ce que fait le programme du listing 3.6. Par rapport au programme précédent, on notera les différences remarquables suivantes : — Le père lit en continu l’entrée standard (fichier 0). La lecture se fait en réalité ligne à ligne, et ce jusqu’à ce qu’elle soit fermée (par un Ctr-D au clavier), ce qui conduira read() à retourner 0. Comme auparavant, la chaîne lue est recopiée sur l’entrée du tube. — Le fils fait lui aussi des lectures continues sur la sortie du tube. La boucle while s’arrête là encore lorsque read() renvoie 0, ce qui n’est possible que lorsque le tube est devenu inutilisable, parce que plus aucun processus ne peut écrire dedans. — C’est précisément la raison pour laquelle un close(f) a été fait à la ligne précédente. Si cet appel n’avait pas été fait, le compteur de référence cref en TFO serait toujours resté à 1, car 35 le fils a hérité d’un descripteur de fichier lors du fork, donc pourrait toujours écrire lui-même dans le tube. Supprimer l’appel à close conduirait le fils à bloquer, et attendre indéfiniement une donnée qui ne peut plus être envoyée que par lui ou sa descendance, et qui n’arrivera jamais. L’information essentielle à retenir est donc la suivante : un appel à read() sur une sortie de tube ne sortira avec une valeur de retour nulle qu’à la double condition i) qu’il n’y ait plus de données dans le tube, et ii) que le compteur de référence assoc

Use Quizgecko on...
Browser
Browser