CSC 4103 – Programmation système

Portail informatique
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. 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 :
PèreFils
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

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".
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.

Dans le bloc "B2" du père, c'est-à-dire après avoir attendu la création du fichier sync, affichez le PID du père, l'adresse de var et la valeur de var.

Qu'observez vous ?

La sortie du programme est la suivante : $ ./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

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 :
  • 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.

Cet exercice vous montre donc que la même adresse virtuelle dans le père et dans le fils est bien associé à une adresse physique différente.
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 :
  • 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.

Essayez de lancer les commandes suivantes : $ ./memory >/dev/null $ strace ./memory >/dev/null $ strace ./memory >/dev/null 2>&1 $ strace ./memory 2>&1 >/dev/null

Pour quelle raison seule la dernière commande permet d'obtenir la redirection excomptée ? 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 ? $ 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

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.
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. $ strace ./memory 2>&1 >/dev/null | grep Je write(1, "[16103] Je suis ton p\303\250re\n[16103"..., 83) = 83

La fonction système est donc write
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 ? $ 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 L'option -f permet de tracer les enfants. Sans cette option, strace ne trace que le père.
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 : for(int i=0; i<n; i++) fork(); printf("[%d] say hello\n", getpid());

Combien de processus sont créés si vous lancez votre programme avec comme argument 4 ? Et avec 10 ? 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.
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).