Appels systèmes
Cette session d'exercice a pour but de vous familiariser avec les sémaphores et la mémoire virtuelle,
et d'observer ce que sont les appels systèmes.
Mémoire virtuelle, strace
et synchronisation (~ 80mn)
Le but de cet exercice est de vous faire manipuler la commande strace,
de vous exercer à synchroniser des
processus, et d'en profiter pour observer la mémoire virtuelle
de deux processus.
Techniquement, vous allez observer que deux adresses virtuelles identiques
dans deux processus différents correspondent à deux adresses physiques différentes.
Comme vous l'avez vu dans le cours, lorsque le processeur accède à un emplacement
mémoire, il utilise une adresse virtuelle qu'il traduit ensuite en adresse
physique via la table des pages.
Par définition, chaque processus possède sa propre table des pages.
Le système d'exploitation utilise ce mécanisme de mémoire virtuelle
pour isoler les processus :
pour la même adresse virtuelle, le système d'exploitation associe
deux cadre de pages physiques distincts à deux processus distincts.
Première partie : synchronisons des processus
Dans un premier temps, on vous demande d'écrire un programme
nommé memory.c.
Ce programme doit créer un nouveau processus avec la fonction
fork().
Le père doit afficher "Je suis ton père" précédé du PID
du père, alors que le fils doit afficher "Hhh hhh non, non"
précédé du PID du fils.
À partir de l'appel à fork(), vous avez deux processus
indépendants qui s'exécutent en parallèle.
Il n'y a donc aucun ordre imposé entre l'exécution des instructions
du père et celles du fils. L'ordonnanceur du noyau du système d'exploitation
peut en effet choisir d'exécuter les processus sur deux processeurs différents,
auquel cas, l'ordre d'exécution des instructions du père et du fils est aléatoire.
Le noyau du système d'exploitation peut aussi choisir d'exécuter le père et le fils sur le même processeur.
Dans ce cas, il peut choisir d'exécuter des instructions du fils avant
celles du père et inversement : il n'y a donc aussi aucune garantie.
Dans cette question, nous synchronisons le père et le fils, c'est à dire que nous
assurons qu'une série d'instructions "B2" du père est forcément exécutée
après une série d'instructions "B1" du fils.
Pour effectuer cette synchronisation, nous utilisons l'existence d'un fichier
que nous nommons sync.
Après avoir affiché son message, le père doit attendre que
le fichier sync existe.
Pour cela, il lui suffit de boucler tant qu'il n'arrive pas à ouvrir le fichier
en lecture seule. Lorsque la boucle se termine (donc, quand le fichier sync existe), le père le supprime (en utilisant la fonction unlink), puis
exécute la série d'instruction "B2".
De son côté, le fils doit créer le fichier, ce qui débloque le père.
En plaçant la séquence d'instructions "B1" juste avant cette création,
nous assurons que la série d'instructions "B1" sera forcément exécutée avant la série d'instructions "B2".
Pour schématiser, l'algorithme que nous vous proposons est donc le suivant :
Vérifiez que "B1" s'exécute bien avant "B2" en affichant la phrase
"Avant" dans "B1" et de la phrase "Après" dans "B2".
Père | Fils |
Affiche "Je suis ton père"
Tant que
L'ouverture du fichier "sync" en lecture seule rate
Fin tant que
Supprimer le fichier "sync"
Bloc d'instruction B2 |
Affiche "Hhh hhh non, non"
Bloc d'instruction B1
Ouvre le fichier "sync" en mode écriture/création
|
Deuxième partie : observons la mémoire virtuelle
Définissez une variable globale nommée var et initialisée
à 42.
Dans le bloc "B1" du fils, c'est-à-dire avant de créer le fichier
sync :
- affectez la valeur 666 à var,
- affichez le PID du fils, l'adresse de var et la valeur de var.
La sortie du programme est la suivante :
On peut observer que var a la même adresse dans le père en le fils.
Pourtant, cette variable a deux valeur différentes, alors que les deux affichages
ont bien lieu après la modification de var :
$ ./memory
[3076] Je suis ton père
[3077] Hhh hhh non, non
[3077] Before
[3077] var is at 0x1066be040 and contains 666
[3076] After
[3076] var is at 0x1066be040 and contains 42
- dans le père, on s'assure que l'affichage a lieu après l'affectation à 666 puisque le père attend la création du fichier, laquelle n'a lieu qu'après l'affectation dans le fils;
- dans le fils, on s'assure que l'affichage a lieu après la modification simplement parce que la séquence de code du fils commence par modifier var avant d'afficher la valeur.
Troisième partie : traçons nos processus
Nous souhaitons maintenant utiliser la commande strace pour
identifier les appels systèmes effectués par notre programme.
Pour cela, nous allons avoir besoin de chaîner la sortie de strace
avec la commande grep. On veut donc lancer une commande
comme strace memory | grep motif.
Malheureusement, comme strace
effectue ses affichages sur sa sortie d'erreur et comme
le tube filtre la sortie standard, il va falloir faire preuve d'ingéniosité et
trouver les redirections adéquates. Techniquement, il faut :
Pour quelle raison seule la dernière commande permet d'obtenir la redirection excomptée ?
- rediriger la sortie standard vers /dev/null de façon à ne plus voir les affichages effectués par le programme memory,
- rediriger la sortie d'erreur vers la sortie standard.
$ ./memory >/dev/null
$ strace ./memory >/dev/null
$ strace ./memory >/dev/null 2>&1
$ strace ./memory 2>&1 >/dev/null
Les premières et secondes commandes envoient stdout vers /dev/null. Elles nous débarassent
bien de la sortie de memory. Mais strace effectue toujours
ses affichages sur la sortie d'erreur.
La troisième commence par envoyer stdout vers /dev/null puis envoie stderr vers stdout,
qui part donc vers /dev/null. La dernière commande envoie stderr vers la flux de sortie
standard du terminal (/dev/stdout)
puis envoie stderr vers /dev/null, ce qui correspond aux spécifications.
En cherchant la chaîne de caractère "sync" dans la sortie de strace,
identifiez la fonction système permettant d'ouvrir un fichier et celle permettant de détruire un fichier.
Pour quel raison le nombre d'appels à la fonction d'ouverture peut varier d'une exécution sur l'autre ?
La fonction d'ouverture est donc open et celle de destruction unlink.
Le nombre de open varie car il est possible d'appeler le open
dans le père avant d'avoir créé sync dans le fils.
$ strace ./memory 2>&1 >/dev/null | grep sync
open("sync", O_RDONLY) = -1 ENOENT (No such file or directory)
...
open("sync", O_RDONLY) = -1 ENOENT (No such file or directory)
open("sync", O_RDONLY) = 3
unlink("sync") = 0
$ strace ./memory 2>&1 >/dev/null | grep sync
open("sync", O_RDONLY) = 3
unlink("sync") = 0
Quelle fonction système est appelée lorsque vous utilisez printf ?
Pour vous guider, il vous suffit de trouver la chaîne de caractère "Je"
dans la sortie de strace.
La fonction système est donc write
$ strace ./memory 2>&1 >/dev/null | grep Je
write(1, "[16103] Je suis ton p\303\250re\n[16103"..., 83) = 83
Pour quelle raison n'arrivez-vous pas à trouver l'appel système engendré par
l'écriture de "Hhh hhh non, non" avec strace ?
Ressayez en utilisant l'option -f de strace. Que
fait cette option ?
L'option -f permet de tracer les enfants. Sans cette option, strace
ne trace que le père.
$ strace ./memory 2>&1 >/dev/null | grep "Hhh hhh non, non"
$ strace -f ./memory 2>&1 >/dev/null | grep "Hhh hhh non, non"
[pid 16119] write(1, "[16119] Hhh hhh non, non\n[16119]"..., 84) = 84
Sémaphores (~ 60mn)
Le but de cet exercice est de vous faire utiliser les sémaphores et de réviser les processus.
Écrivez un programme nommé sem.c.
Ce programme doit convertir son premier argument (argv[1]) en entier,
le stocker dans une variable entière nommée n, et afficher
cette variable.
Vous veillerez à terminer votre programme avec un message d'erreur adéquat si
le nombre d'argument est incorrect.
Ajoutez à la fin de votre programme le code suivant :
Combien de processus sont créés si vous lancez votre programme avec comme
argument 4 ? Et avec 10 ?
for(int i=0; i<n; i++)
fork();
printf("[%d] say hello\n", getpid());
Il vous est fortement déconseillé de lancer votre programme avec un argument supérieur à 20
si vous ne voulez pas bloquer votre machine.
On crée exactement 2n processus.
On le montre facilement par récurrence :
- Si n vaut 0, alors on a un unique processus, le père, c'est à dire 2^0.
- On suppose qu'on crée 2n processus lorsque l'argument est n. Si l'argument est n+1, pendant les tours de boucles de 0 à n, on va créer 2n processus. Au tour de boucle n+1, chacun des 2n processus va engendrer un fils. On va donc doubler le nombre de processus, soit en créer 2n+1 au total.
On souhaite maintenant que chacun des processus simule un travail.
En supposant que P est le PID d'un processus
Au lieu d'afficher "[P] say hello", chaque processus doit afficher "[P] start job", dormir
une seconde (fonction sleep), puis afficher "[P] stop job".
On souhaite maintenant mettre en place un contrôle d'accès avec un sémaphore.
Ce contrôle d'accès assure qu'il n'existe jamais plus de K processus en train
d'exécuter la séquence de code constituée des deux affichages ("start job"/"stop job") et de l'attente.
Cette technique de contrôle est souvent utilisée dans les serveurs : de façon à éviter les attaques
par déni de service, les serveurs n'acceptent qu'un nombre limité de clients simultanément.
Pour commencer, nous ne n'autorisons qu'un unique processus à travailler.
Nous vous proposons donc de :
- Créer un sémaphore à un unique jeton dans le père avant de lancer les enfants,
- Utiliser ce sémaphore pour protéger la section critique constituée de l'affichage "[K] start job", de l'attente d'une seconde et de l'affichage "[K] stop job".
Nous souhaitons maintenant autoriser 4 processus à travailler en même temps.
Malheureusement, nous n'avons jamais détruit notre sémaphore dans l'exercice précédent.
Le sémaphore est réouvert à chaque lancement du programme, mais il reste pré-initialisé
avec un unique jeton.
Dans cette question, nous vous demandons de détruire le sémaphore en utilisant votre shell préféré, en sachant
que Linux cache les sémaphores dans le répertoire /dev/shm.
Détruire le sémaphore en utilisant le shell n'est pas idéal. Nous souhaitons donc
détruire le sémaphore dans notre programme.
Nous pourrions bien sûr écrire tout un protocole pour ne détruire le sémaphore
que dans le père, après nous être assuré que tous les enfants sont terminés.
Mettre en place cet algorithme est difficile.
Pour cette raison, nous vous conseillons de détruire le sémaphore avec la commande
sem_unlink juste après l'avoir créé, c'est-à-dire avant de l'avoir
utilisé !
De façon inattendue, le système d'exploitation va bien détruire le sémaphore
dans le système de fichier (dans /dev/shm), mais va maintenir le sémaphore
artificiellement vivant pendant qu'il existe des processus qui l'utilisent.
Le sémaphore ne sera alors détruit définitivement par le système d'exploitation
que lorsque le dernier processus qui l'utilise se termine.
Vérifiez qu'on détruisant votre sémaphore avant de l'utiliser, le sémaphore continue quand même à fonctionner.
La technique de destruction anticipée que vous venez d'apprendre est très commode et vous
pouvez l'utiliser avec de nombreuses ressources systèmes comme les fichiers ordinaires ou les tubes.
Cette technique permet de détruire des ressources systèmes par anticipation tout en
les laissant fonctionnelles pour le processus, ce qui évite d'avoir à mettre en œuvre
un protocole de terminaison lourd pour se synchroniser sur la fin de l'ensemble des processus d'une application.
Ma première banque (~ 40mn)
Le but de cet exercice est de vous faire programmer une petite banque
de façon à réviser les fichiers et les sémaphores.
La gestion de la banque est particulièrement simple. On vous demande d'écrire
un programme nommé bank prenant deux arguments.
Le premier argument est un nom d'utilisateur
et le second une somme à créditer
(qui peut être négative si vous voulez débiter le compte).
Pour simplifier l'explication de l'algorithme du programme bank,
nous nommons le permier paramètre name et le second
money
Le programme va, dans l'ordre :
- ouvrir le fichier name en lecture seule ("r").
- si le fichier existe, le programme lit la somme actuellement stockée dans le fichier dans une variable nommée balance, puis ferme le fichier,
- sinon, le programme affecte 0 à balance car il s'agit d'une création de compte.
- ajouter money à balance,
- afficher name et balance.
- ouvrir le fichier name en écriture/troncation ("w"),
- écrire balance dans le fichier,
- fermer le fichier,
Écrivez le programme bank sans encore vous soucier des problèmes
d'accès concurrents.
Téléchargez le script ci-dessous qui vérifie que votre programme est correct.
Pour quelle raison, lorsque vous lancez ce script, Ronflex finit si pauvre ?
Incohérence causée par l'accès concurrent au fichier name.
Corrigez votre script en utilisant des sémaphores.
D'après vous, pour quelle raison, si vous ne mettez pas la fermeture finale du fichier dans la section
critique protégée par le sémaphore, votre programme reste incorrect ?
N'hésitez pas à exposer vos théories à votre enseignant !
Si on ne met pas le fclose dans la section critique, l'écriture est effectuée
en mémoire (dans le buffer d'écriture de fwrite), mais elle n'est pas encore propagée
vers l'inode.
Le problème n'existe pas si on utilise des entrées/sorties non bufferisée
(c'est le cas de read/write).