TP INFO 2 : Système d'exploitation

Retour sur la page d'accueil

Les mesures de confinement ne nous permettent pas de faire le TP d'informatique (partie système d'exploitation) en présentiel. Vous devez donc le faire de votre côté, à distance... mais pas de panique, le sujet a été adapté en fonction. Les exercices sont guidés, et vous n'avez pas besoin d'installer quoi que ce soit sur votre machine.

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 avant le 12 avril.

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.

Pour ce TP, on utilisera onlineGDB : https://www.onlinegdb.com/online_c_compiler

Vous pouvez compiler et exécuter votre code sur cet outil en ligne en cliquant sur "run". Votre code sera alors compilé en un fichier a.out, qui sera exécuté sur le système d'exploitation d'une machine virtuelle gérée par le serveur de onlineGDB. Ainsi, vous n'avez rien à installer, et nous ne travaillerons pas directement sur votre système d'exploitation.

Commençons par observer les processus auxquels vous avez accès sur onlineGDB. Pour cela, on va faire un programme qui exécute ps -aux. Nous utiliserons la fonction execl, qui remplace les instructions du processus qui l'appelle par celles d'un programme passé en argument.

Avant toute chose, il faut consulter la documentation de execl : https://linux.die.net/man/3/execl

execl prend quatre paramètres : le chemin de la commande à exécuter (pour ps : /bin/ps, le nom de la commande (pour ps : ps), et son attribut (pour ps -aux : -aux). On ne s'occupera pas du quatrième paramètre. On devra donc appeler : execl("/bin/ps", "ps", "-aux",(char *) 0);

Question 1 : Compilez, et exécutez le code suivant. Quel est le résultat ? Combien de processus voyez-vous, et à quoi correspondent-ils ? Expliquez.


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

int main(int argc, char * argv[]) {
    execl("/bin/ps", "ps", "-aux",(char *) 0);
}
            

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 : 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' (utilisez le champ "Command line arguments" pour donner les arguments avant de cliquer sur run). 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");            
    }   
    
}
            

Sur ma machine personnelle, j'ai exécuté le programme de la question 6 avec la commande ./a.out 5 10 (donc avec 5 et 10 en paramètres), J'ai ensuite tapé immédiatement la commande ps pour retrouver des informations sur mes processus dans une autre console :


ps -la | grep 'a.out\|PID'
            

J'ai obtenu le résultat suivant.


            F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
            0 S  1000  5376  5275  0  80   0 -  1093 hrtime pts/2    00:00:00 a.out
            1 S  1000  5377  5376  0  80   0 -  1093 hrtime pts/2    00:00:00 a.out
            

Question 6 : Commentez ce résultat. Quel sont les PID de mes processus ? Lequel est le fils ? Lequel est le père ?

Sur ma machine personnelle, j'ai exécuté le programme de la question 6 avec la commande ./a.out 5 10. J'ai attendu 6 secondes, puis j'ai tapé la commande ps comme dans la question 7. Voici ce que j'obtiens :


            F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
            1 S  1000  5391  1549  0  80   0 -  1093 hrtime pts/2    00:00:00 a.out
            

Question 7 : Commentez ce résultat. Quels sont les processus que j'observe ?

Sur ma machine personnelle, j'ai exécuté le programme de la question 6 avec la commande ./a.out 10 5. J'ai attendu 6 secondes, puis j'ai tapé la commande ps comme dans la question 7. Voici ce que j'obtiens :


            F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
            0 S  1000  5399  5275  0  80   0 -  1093 hrtime pts/2    00:00:00 a.out
            1 Z  1000  5400  5399  0  80   0 -     0 -      pts/2    00:00:00 a.out <defunct>
            

Question 8 : Commentez ce résultat. Quels sont les processus que j'observe ?

Question 9 : À vous de jouer maintenant ! Écrivez un programme qui crée 2 processus, l’un faisant la commande ls -l, l’autre ps -l (utilisez execl cf. question 1 ! le chemin de ps est /bin/ps). Le père devra attendre la fin de ses deux fils et afficher quel processus a terminé 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 10 : 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;
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 11 : 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;
}