TP INFO 2 : Système d'exploitation

Retour sur la page d'accueil

Vous devez rédiger un compte-rendu (en pdf) qui compile vos réponses aux différentes questions de ce TP. Ce compte-rendu sera noté. Vous devrez me le rendre sur Celene à la fin de la séance.

Dans ce TP, nous utiliserons le langage C. Pour rappel, le code doit être enregistré dans un fichier portant l'extension .c, et peut être compilé en console avec la commande gcc (par exemple, gcc mon_programme.c). Par défaut, votre programme sera nommé a.out et pourra être exécuté en console par l'instruction ./a.out (attention, vous devez vous trouver dans le répertoire du fichier a.out). Si vous souhaitez nommer autrement votre programme, vous pouvez utiliser l'option -o (par exemple, gcc mon_programme.c -o mon_programme pour nommer le programme mon_programme)

I Quelques rappels sur les processus

Nous allons travailler principalement sur les processus. Un processus n'est rien d'autre qu'un programme informatique, et peut être défini par les ressources suivantes:

  • Un ensemble d'instructions à exécuter les unes après les autres
  • Des données stockées en mémoire vive

Le rôle du système d'exploitation est de lui attribuer les ressources dont il a besoin, c'est à dire l'exécution de ses instructions par le processeur (le module qui effectue les calculs sur votre ordinateur), et un espace d'adressage dédié dans la mémoire.

Un grand nombre de processus peuvent être exécutés en même temps de façon indépendante sur les ordinateurs modernes. Par exemple, j'utilise actuellement Firefox pour naviguer sur internet, et j'écoute en même temps de la musique depuis mon lecteur multimédia VLC. De mon point de vue, le processus de chacune de ces applications s'exécute en même temps. Et pourtant, le nombre de processeurs est limité sur ma machine (partons du principe que je n'en ai qu'un seul), alors comment est-ce possible ?

Les instructions sont en fait exécutées une par une par le processeur. C'est le système d'exploitation qui décide quel sera l'ordre d'exécution de ces instructions, c'est ce qu'on appelle l'ordonnancement. Il alternera régulièrement entre les instructions des différents processus, c'est ce qui nous donne l'impression que tout s'exécute en même temps.

Mettons que mes deux processus aient les instructions suivantes:

Firefox :
  1. instruction-f-1;
  2. instruction-f-2;
  3. instruction-f-3;
  4. instruction-f-4;
VLC :
  1. instruction-v-1;
  2. instruction-v-2;
  3. instruction-v-3;
  4. instruction-v-4;

Le système d'exploitation pourra, par exemple, ordonnancer les instructions à effectuer par le processeur de la façon suivante :

  1. instruction-f-1;
  2. instruction-f-2;
  3. instruction-v-1;
  4. instruction-f-3;
  5. instruction-v-2;
  6. instruction-v-3;
  7. instruction-f-4;
  8. instruction-v-4;

Les processus peuvent créer de nouveaux processus. En fait, chaque processus sera initié par un autre. Ces deux processus seront appelés respectivement processus fils, et processus père. Seul le processus init, le premier exécuté au démarrage du système d'exploitation, n'a pas de père. Par contre, chaque processus sera son descendant. On peut représenter l'ensemble des processus comme un arbre dont la racine est le processus init, et dont chaque branche relie un processus père à l'un de ses fils. Le système d'exploitation affectera à chaque processus :

  • un identifiant (PID)
  • un identifiant de processus parent (PPID), qui sera le PID de son père ; par exemple, si un processus de PID 12 crèe un autre processus de PID 45, le PPID du processus 45 sera 12
Pour commencer, lisez le manuel de la commande ps : http://www.linux-france.org/article/man-fr/man1/ps-1.html.

II Programmation C et processus.

Question 1 : Commençons par observer les processus de votre machine. Lancez quelques processus, ouvrez un terminal, et tapez la commande ps -l. Commentez (quels sont les processus listés, quelle est leur hiérarchie...). Tapez ensuite ps -aux, retrouvez les identifiants des processus que vous avez lancé, et retrouvez le processus init.

Avant de passer à la suite, étudiez les manuels de fork, exit et wait :

http://manpagesfr.free.fr/man/man2/fork.2.html
http://manpagesfr.free.fr/man/man3/exit.3.html
http://manpagesfr.free.fr/man/man2/wait.2.html

Pour créer un nouveau processus en C, on utilise la fonction fork. Attention, les deux processus (père et fils) utiliseront le même code source, ce qui rend la programmation et la gestion des processus peu intuitive. La fonction fork renvoie un pid :


pid = fork();
            

À partir de l'appel à fork, le processus est dédoublé en deux processus, le père et le fils, ayant chacun leur propre mémoire et leur propre suite d'instructions. Chacun des deux processus exécutera le code qui suit l'appel de fork. Par exemple, si on a le code suivant :


pid_t pid;
pid = fork();
printf("je m'exécute !");
            

chacun des deux processus affichera "je m'exécute !". La valeur de retour de fork (pid) dépend du processus. Si c'est le fils, pid vaut 0, sinon, pid est une valeur entière différente de zéro. On peut donc se servir de pid pour exécuter du code uniquement dans le processus père, ou dans le fils. Par exemple, si on exécute le code suivant :


pid_t pid;
pid = fork();
if(pid == 0){
    printf("je suis le fils ! ");
}
else{
    printf("je suis le père ! ");
}
printf("Nous sommes père et fils !");
            

Le processus père affichera "je suis le père ! Nous sommes père et fils !" et le processus fils affichera "je suis le fils ! Nous sommes père et fils !"

La fonction exit permet de terminer un processus. exit prend en paramètre une valeur de retour. Lorsque exit est appelée, le système d'exploitation termine le processus, libère sa mémoire, puis attend que le père du processus récupère la valeur de retour donnée en paramètre avant de détruire définitivement le processus.


exit(status);
            

Il faut donc que le processus père récupère la valeur de retour de son fils. Pour cela, il utilise la fonction wait, qui prend l'adresse d'une variable en paramètre, attend que le processus fils se termine, et récupère la valeur de retour du fils dans cette variable. La fonction wait renvoie le pid du processus fils qui s'est terminé.


pid_fils = wait(&status);
            

L'exemple ci-dessous récapitule l'utilisation de ces différentes fonctions.


int status;
pid_t pid;
pid = fork();
if(pid == 0){
    printf("je suis le fils ! ");
    exit(0); //termine avec le retour 0
}
else{
    printf("je suis le père ! ");
    wait(&status); //récupère le retour de fils, donc status = 0
}
            

Le schéma ci-dessous représente l'exécution des deux processus père et fils. Les flèches verticales représentent l'exécution d'un processus dans le temps. Deux flèches parallèles représentent deux processus qui s'exécutent simultanément, typiquement le père et son fils. On distingue deux cas de figure:

  • Lorsque le fils a terminé son travail, et qu'il appelle la fonction exit, le père n'a pas encore terminé son travail, et donc n'a pas encore appelé la fonction wait. Dans ce cas, le processus fils doit attendre que le père appelle wait, et est inactif pendant ce laps de temps. Sur le schéma, cette inactivité est représentée par des pointillés.
  • Lorsque le père a terminé son travail, et qu'il appelle la fonction wait, le fils n'a pas encore terminé son travail, et donc n'a pas encore appelé la fonction exit. Dans ce cas, le processus père doit attendre que le fils appelle exit, et est inactif pendant ce laps de temps. Sur le schéma, cette inactivité est représentée par des pointillés.

Il est important de toujours utiliser la fonction wait lorsqu'on crée un processus. Sinon, le processus fils, en essayant de transmettre sa valeur de retour, continuera d'exister pour rien en consommant des ressources du système d'exploitation. Dans ce cas, on dira que le processus est un "zombie", ou un processus "défunt". Retenez bien ceci, ce sera important pour la suite !

Lorsque le processus père se termine, ses fils non-terminés se retrouvent orphelins. Ils sont alors adoptés en tant que fils d'un processus du système d'exploitation. Celui-ci attendra (si besoin) qu'ils se terminent et récupérera leurs valeurs de retour afin qu'ils ne restent pas processus zombies.

Question 2 : Exécutez le code suivant, qu'affiche-t-il ? expliquez ce résultat. (rappel : la fonction sleep(n) marque une pause de n secondes dans l'exécution du programme)


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

int main(int argc, char * argv[]) {
    pid_t pid;
    int stat;
    pid=fork();
    if (pid == 0){
        for (int i = 0; i < 5 ; i++){
            printf("--%d--\n",i);
            sleep(1);
        }
        exit(0);
    }
    else{
        for (int i = 0; i < 5 ; i++){
            printf("--%d--\n",i);
            sleep(1);
        }
        wait(&stat);
    }   
    
}
            

Question 3 : Exécutez le code suivant, qu'affiche-t-il ? Expliquez ce résultat.


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

int main(int argc, char * argv[]) {
    pid_t pid;
    int stat;
    pid=fork();
    for (int i = 0; i < 5 ; i++){
        printf("--%d--\n",i);
        sleep(1);
    }
    if(pid == 0)
        exit(0);
    else
        wait(&stat);
}
            

Question 4 : Exécutez le code suivant, qu'affiche-t-il ? Expliquez ce résultat.


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

int main(int argc, char * argv[]) {
    pid_t pid1, pid2;
    int stat;
    pid1=fork();
    if (pid1 == 0){
        for (int i = 0; i < 5 ; i++){
            printf("--%d--\n",i);
            sleep(1);
        }
        exit(0);
    }
    pid2=fork();
    if (pid2 == 0){
        for (int i = 0; i < 5 ; i++){
            printf("--%d--\n",i);
            sleep(1);
        }
        exit(0);
    }
    wait(&stat);
    wait(&stat);
}
            

Question 5 : Exécutez le code suivant, qu'affiche-t-il ? Expliquez ce résultat.


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

int main(int argc, char * argv[]) {
    pid_t pid1, pid2;
    int stat;
    pid1=fork();
    if (pid1 == 0){
        for (int i = 0; i < 5 ; i++){
            printf("--%d--\n",i);
            sleep(1);
        }
    }
    pid2=fork();
    if (pid2 == 0){
        for (int i = 0; i < 5 ; i++){
            printf("--%d--\n",i);
            sleep(1);
        }
        exit(0);
    }
    wait(&stat);
    wait(&stat);
}
            

Question 6 : Que fait le code suivant ? (rappel : argv[i] est le i-ème argument donné à l'exécution du programme) Exécutez ce code avec les arguments '10 5' et '5 10' Qu'obtenez-vous ? Expliquez.


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

int main(int argc, char * argv[]) {
    pid_t pid;
    int attente_fils,attente_pere;
    if(argc != 3)
        perror("usage: ex1 n m\n");
        
    attente_pere = atoi(argv[1]); //ascii to integer
    attente_fils = atoi(argv[2]);
    
    pid=fork();
    if(pid == 0){
        sleep(attente_fils);
        printf("fils attente finie\n");
    }
    else{
        sleep(attente_pere);
        printf("pere attente finie\n");            
    }   
    
}
            

Question 7 : Ouvrez deux terminaux. Sur le premier, exécutez le programme de la question 6 avec la commande ./a.out 5 10 (donc avec 5 et 10 en paramètres), puis tapez immédiatement la commande ps -al dans le second terminal. Commentez le résultat. Quels sont les PID de vos processus ? Lequel est le fils ? Lequel est le père ?

Question 8 : Ouvrez deux terminaux. Sur le premier, exécutez le programme de la question 6 avec la commande ./a.out 5 10 , attendez 6 secondes, puis tapez la commande ps -al dans le second terminal. Commentez ce résultat.

Question 9 : Ouvrez deux terminaux. Sur le premier, exécutez le programme de la question 6 avec la commande ./a.out 10 5 , attendez 6 secondes, puis tapez la commande ps -al dans le second terminal. Commentez ce résultat.

La fonction execl, remplace les instructions du processus qui l'appelle par celles d'un programme passé en argument. Vous pouvez consulter la documentation de execl ici : https://linux.die.net/man/3/execl

execl prend plusieurs paramètres : le chemin de la commande à exécuter (pour ps : /bin/ps), le nom de la commande (pour ps : ps), et ses attributs (pour ps -aux : -aux). Attention, si l'on souhaite utiliser plusieurs attributs, chaque attribut doit être passé par un paramètre différent. Enfin, le dernier paramètre doit toujours être la chaîne de caractères nulle : (char *) 0. Pour ps -aux on devra donc appeler :
execl("/bin/ps", "ps", "-aux",(char *) 0);

Question 10 : À vous de jouer maintenant ! Écrivez un programme qui crée 2 processus, l’un affichant les chiffres de 1 à 300, l’autre -1 à -300. Le père devra attendre la fin de ses deux fils et afficher quel processus a terminé en premier. Exécutez ensuite plusieurs fois votre programme, est-ce toujours le même processus fils qui termine en premier ?

Pour vous aider, voici le début et la fin du code.


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

int main(int argc, char * argv[]) {
    pid_t pid1,pid2,pid_premier;
    int status;
    
    //TO DO !
    
    pid_premier = wait(&status);
    wait(&status);
    printf("Premier processus a finir : %d\n", (pid_premier==pid1)?1:2);
}
            

III Les processus légers : les threads.

Les threads, contrairement aux processus que nous venons de voir, n'ont pas d'espace mémoire qui leur soit propre ; ils partagent donc la même mémoire que le programme qui les a créés. Cependant, comme les processus, ils possèdent leur propre suite d'instructions, indépendante des autres threads ou processus. L'avantage principal est qu'un thread utilise moins de ressources du système d'exploitation qu'un processus, cependant, il faut être très prudent lorsqu'un programme utilise des threads, puisque la gestion du partage de la mémoire est laissée au programmeur, et peut amener de nombreux bugs.

En C, une nouvelle fois, les threads utilisent tous le même code source. La syntaxe est cependant différente de celle des processus.

Tout d'abord, la fonction pthread_create permet de créer un thread. Elle prend comme paramètre, entre autres, l'adresse d'une variable de type pthread_t, et l'adresse mémoire d'une fonction. Le thread exécutera alors cette fonction. La fonction pthread_join prend en paramètre une variable de type pthread_t, et permet au programme ayant créé le thread de se synchroniser avec ce dernier.


pthread_t tid;
void* thread(void *arg)
{
    // code du thread
    return NULL;
}

int main(void)
{
    pthread_create(&tid1, NULL, &thread, NULL);
    // code du thread principale
    pthread_join(tid, NULL);
}
            

Ainsi, toutes les variables globales (c'est à dire celles qui sont déclarées en dehors des accolades des fonctions) seront accessibles par tous les threads en même temps .

Par contre, les variables locales (c'est à dire celles qui sont déclarées à l'intérieur des accolades des fonctions, en particulier des fonctions exécutées par des threads), de par leur portée, ne seront accessibles qu'au thread qui exécute la fonction.

Il est temps de concrétiser tout cela sur des exemples !

Question 11 : Que fait ce code ? Compilez-le (gcc fichier.c -lpthread) et exécutez-le, que renvoie-il ? Expliquez le résultat.


#include <pthread.h>
#include <unistd.h> 
#include <stdio.h>
#include <stdlib.h>

pthread_t tid1, tid2;
void* thread(void *arg)
{
    int i = 0;
    for (i = 0; i < 5 ; i++){
        printf("--%d--\n",i);
        sleep(1);
    }
    return NULL;
}

int main(void)
{
    pthread_create(&tid1, NULL, &thread, NULL);
    pthread_create(&tid2, NULL, &thread, NULL);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    return 0;
}
            

Question 12 : Que fait ce code ? Exécutez-le, que renvoie-il ? Expliquez le résultat.


#include <pthread.h>
#include <unistd.h> 
#include <stdio.h>
#include <stdlib.h>

pthread_t tid1, tid2;
int i = 0;
void* thread(void *arg)
{
    for (i = 0; i < 5 ; i++){
        printf("--%d--\n",i);
        sleep(1);
    }
    return NULL;
}

int main(void)
{
    pthread_create(&tid1, NULL, &thread, NULL);
    pthread_create(&tid2, NULL, &thread, NULL);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    return 0;
}
            

Il est possible de fournir un argument à la fonction qui est appelée par le thread. Cet argument sera nécessairement un pointeur. Il sera donc possible de communiquer de l'information au programme ayant appelé le thread à travers cette variable. Cet argument est le quatrième paramètre de la fonction pthread_create. Voyons cela avec un exemple. Le programme suivant crée un thread et lui donne en argument un pointeur sur un entier dont la valeur est 0. La fonction du thread affiche la valeur de cet argument, puis le modifie pour lui donner la valeur 1. Après l'exécution du thread, on affiche cette variable dans le programme principale. Normalement, on devrait afficher 0 dans le thread, et 1 dans le programme principal. Vérifiez cela en exécutant ce code par vous même.


#include <pthread.h>
#include <unistd.h> 
#include <stdio.h>
#include <stdlib.h>

void* thread(void *arg)
{
    int * j = arg;
    printf("Dans le thread : %d\n",*j);
    *j = 1;
    return NULL;
}

int main(void)
{
    pthread_t tid;
    int i = 0;
    pthread_create(&tid, NULL, &thread, (void *) &i);
    pthread_join(tid, NULL);
    printf("Dans le main : %d\n",i);
}
            

Question 13 : Écrivez un programme générant deux threads qui jouent à "pierre-feuille-ciseaux". Chacun des deux threads doit générer aléatoirement un entier r entre 0 et 2. Si r=0, le thread doit afficher "Pierre", Si r=1, le thread doit afficher "Feuille", sinon le thread doit afficher "Ciseaux". Le programme principal doit récupérer les r des deux threads, et indiquer lequel des deux threads a gagné, ou si il y a égalité.

Question 14 : Écrivez un programme qui demande de rentrer un entier n au clavier, et qui crée n threads. Les threads recevront comme arguments les entiers successifs entre 0 et n . Chacun des thread doit afficher "je suis le thread arg " ou arg est la valeur de l'argument du thread. Dans quel ordre apparaissent ces affichages ? Selon vous, est-ce normal ?

Question 15 : Même question avec des processus. Attention, en cas d'erreur, vous risquez de générer des processus en cascade, ce qui risque de faire planter votre machine, il vous est donc vivement conseillé d'enregistrer une copie de votre travail avant de vous attaquer à cette question. Chacun des processus doit afficher son PID ainsi qu'un chiffre différent entre 1 et n . Dans quel ordre apparaissent ces affichages ? Selon vous, est-ce normal ?