Cours ‐ Processus - vbridonneau/CoursSysteme GitHub Wiki

Processus

Gestion de processus

Fonction Description
fork Création d'un processus
wait Attente d'un processus fils quelquonque
waitpid Attente d'un processus fils en mentionnant son pid
exit Permet de sortir d'un processus fils
_exit Permet de sortir d'un processus fils sans appelé de fonctions atexit ou on_exit
atexit Exécuter une fonction lorsque exit est appelée
on_exit Exécuter une fonction avec un paramètre lorsque exit est appelée
getpid Obtention de l'identifiant d'un processus
getppid Obtention de l'identifiant du parent d'un processus
getuid Obtention de l'identifiant de l'utilisateur
geteuid Obtention de l'identifiant effectif de l'utilisateur

Création d'un processus

Pour créer un processus, il suffit d'utiliser l'appel système fork. Cet appel système va dupliquer le processus (copie des variables et de la pile notamment). Il est défini comme suit :

int fork();

Lorsqu'un processus appelle fork, un nouveau processus est créé. Son fils commence son exécution à l'endroit où l'appel à fork a été réalisé. Ce qui permet de différencier le père du fils est le résultat renvoyé par la fonction. En effet, le père va recevoir le pid (identifiant de processus) de son fils. C'est une valeur strictement positive. Le fils en revanche va recevoir la valeur 0 lui indiquant donc qu'il est le fils. On peut donc savoir si un processus créé est un fils ou un père. Cette information est importante, car un père doit toujours s'assurer que son fils termine sa tâche avant de pouvoir lui même terminer. Un exemple d'utilisation de fork est le suivant :

#include <sys/types.h>
#include <unistd.h>

int pidFils = fork();
if (!pidFils) {
    /* On est le fils */
    /* ... */
    exit(EXIT_SUCCES);
} else {
    /* On est le père */
    /* ... */
    waitpid(pidFils, NULL, 0); /* Attend que le fils ait terminé de s'exécuter */
    exit(EXIT_SUCCESS);
}

Pour bien comprendre ce qu'il se passe, on peut illustrer le principe de la création de nouveaux processus avec le schéma suivant :

octorat

Une autre façon de comprendre ce qu'il se passe est de voir l'appel à fork comme une duplication de tous les états d'un processus (code, variabales globales, pile, tas et fichiers ouverts). Avant d'appeler fork, il n'y a qu'un processus. Lorsque fork est appelée, un deuxième apparait, copie du premier, avec donc, sa propre pile, ses variables indépendantes (dont les valeurs sont les mêmes que celles du premier), etc. . C'est d'ailleurs pour cela que la valeur renvoyée par fork et contenue dans pid est différente entre le père et le fils : comme cette valeur sert à les différencier et que tout est copie, fork peut renvoyer des valeurs différentes entre le père et le fils.

Terminaison d'un processus

Lorsque le fils d'un processus termine, celui-ci envoit un signal à son père (souvent SIGCHLD) pour lui indiquer qu'il a terminé de s'exécuter. Un tel signal est émis quand un processus fils appelle _exit ou exit. En ce qui concerne le père, il peut ou non continuer son exécution, mais il devra à un moment attendre que ses fils termine de s'exéuter pour pouvoir terminer. S'il ne le fait pas, ces processus seront considérés comme des démons et rattaché au processus init. Pour attendre un processus, le parent peut appeler l'une des deux fonctions waitpid ou wait:

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *wstatus);

pid_t waitpid(pid_t pid, int *wstatus, int options);

La fonction waitpid est plus précise car elle permet d'attendre un processus précis et peut être paramétrée via le champ option. Voyons un exemple d'utilisation. Mettons que l'on souhaite attendre un processus dont le pid est stocké dans une variable pidFils, on peut exécuter le code suivant :

int main() {
    int pidFils = fork();
    if (pidFils > 0) {
        /* Père */
        /* ... */
        waitpid(pidFils, NULL /* Si l'on a pas besoin du status */, 0);
    } else {
        /* Fils */
        exit(EXIT_SUCCESS);
    }
}

Ces appels sont par défaut bloquant, ce qui veut dire que ces fonctions attendent qu'un processus fils termine avant de rendre la main. Pour s'en rendre compte, on peut exécuter le code suivant :

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

int main() {
    int pidFils = fork();
    if (pidFils > 0) {
        /* Père */
        waitpid(pidFils, NULL, 0);
        printf("[Père] : Le fils à terminé\n");
    } else {
        /* Fils */
        printf("[Fils] : Pause de 3 secondes.\n");
        sleep(3);
        printf("[Fils] : Je me termine.\n");
        exit(EXIT_SUCCESS);
    }
}

Il se peut que le fils que l'on souhaite attendre n'ait pas encore terminé de s'exécuter. Si l'on souhaite reprendre l'exécution et attendre le fils plus tard, il est possible de renseigner l'option WNOHANG ce qui veut dire que l'appel à waitpid ne doit pas être bloquant. On pourra alors écrire :

int main() {
    int pidFils = fork();
    if (pidFils > 0) {
        /* Père */
        /* ... */
        int ret = waitpid(pidFils,
                          NULL /* Si l'on a pas besoin du status */,
                          WNOHANG /* Pour ne pas attendre si le fils n'a pas terminé */);
        if (ret == 0) {
            /* Le fils n'a pas terminé */
            /* Fait des choses ... */
            waitpid(pidFils, NULL, 0); /* Attend vraiment que le fils ait terminé */
        }
    } else {
        /* Fils */
        /* ... */
        exit(EXIT_SUCCESS);
    }
}

Récupération du status d'exit

Lorsque l'on appelle wait ou waitpid, on remarque que l'un des paramètres est un pointeur vers un entier nommé wstatus. Ce paramètre contient la valeur passée en paramètre à exit ou _exit. Il faut cependant faire attention. En effet, si la variable se nomme wstatus et pas status, ce n'est pas pour rien. wstatus la valeur qui sera mise dans la variable passée en paramètre contient d'autres information que le status du processus ayant terminé. On trouve entre autre d'autres information comme le fait de savoir si un programme s'est terminé correctement ou pas, le signal qui l'a fait se terminer, etc. Pour récupérer ces informaitions, des macros on été définie afin d'y accéder. Voici un tableau en donnant certaines :

Macro Description
WEXITSTATUS Accède à la valeur passée à exit ou en retour de main.
WIFEXITED Retourne vrai si le processus a terminé normalement (appel à exit ou retour de main).
WIFSIGNALED Retourne vrai si le processus a terminé par reception d'un signal.
WTERMSIG Retourne la valeur du signal ayant causée la fin du processus.

Informations sur les processus

Il existe des méthodes pour pouvoir accéder au pid du programme en cours d'exécution ou à celui de son parent. Les fonctions associées sont respectivement getpid et getppid

Nom Description
getpid Accède au pid du processus courrant
getppid Accède au pid du parent du processus courrant

Leur prototype est le suivant :

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);
pid_t getppid(void);

Voici un exemple en C d'un programme qui crée trois processus : un père, son fils et un petit fils. Chacun affiche son pid et celui de son père. Cela donne :

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

int main() {
    int pidFils = fork();
    if (pidFils > 0) {
        /* Père */
        waitpid(pidFils, NULL, 0);
        printf("[Père] : %d -> (parent) %d\n", getpid(), getppid());
    } else {
        int pidPetitFils = fork();
        if (pidPetitFils > 0) {
            /* Fils */
            waitpid(pidPetitFils, NULL, 0);
            printf("[Fils] : %d -> (parent) %d\n", getpid(), getppid());
            exit(EXIT_SUCCESS);
        } else {
            /* Petit fils */
            printf("[Petit fils] : %d -> (parent) %d\n", getpid(), getppid());
            exit(EXIT_SUCCESS);
        }
        /* Fils */
        affichePidAndPere("Fils");
        exit(EXIT_SUCCESS);
    }
    return 0;
}

Il existe d'ailleurs un moyen d'afficher l'arbre des processus via une ligne de commande :

ps axjf

Le rendu aura la forme suivante :

PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    0     1     0     0 ?           -1 Sl       0   0:00 /init
    1     7     7     7 ?           -1 Ss       0   0:00 /init
    7     8     7     7 ?           -1 S        0   0:00  \_ /init
    8     9     9     9 pts/0        9 Ss+   1000   0:00      \_ -bash
    1    10    10    10 ?           -1 Ss       0   0:00 /init
   10    11    10    10 ?           -1 S        0   0:00  \_ /init
   11    12    12    12 pts/1      839 Ss    1000   0:00      \_ -bash
   12    45    45    12 pts/1      839 S     1000   0:01          \_ zsh
   45   839   839    12 pts/1      839 R+    1000   0:00              \_ ps axjf

Code lors de l'appel à exit

Lorsque l'on sort d'un programme, que ce soit par un simple return dans le main ou un appel à exit, le programme ne se termine pas sans rien faire. En effet, certaines procédures sont appelées afin d'effectuer certaines tâches (vider le buffer de stdout par exemple). Ces tâches sont spécifiées par des appels à deux fonctions : atexit et on_exit.

#include <stdlib.h>

int atexit(void (*function)(void));
int on_exit(void (*function)(int /* status */, void * /* argument */), void *arg);

Les arguments de atexit et on_exit sont ce que l'on appelle des pointeurs de fonctions. De tels pointeurs servent à stocker l'adresse d'une fonction afin de pouvoir l'exécuter plus tard. Pour pouvoir passer l'adresse d'une fonction, il suffit tout simplement de passer son nom et c'est tout. Surtout il ne faut pas mettre les parenthèses, car cela revient à appeler la fonction et pas à passer son adresse. Voici un exemple d'utilisation de atexit afin d'illustrer l'usage des pointeurs de fonctions :

#include <stdlib.h> // atexit
#include <stdio.h>

void foo() {
    printf("Appel a la fonction \"foo\"\n");
}

int main() {
    atexit(foo);
    printf("Ce message sera affiché en premier\n");
    return EXIT_SUCCESS;
}

Si l'on exécute ce code, on aurra en sortie dans le terminal :

Ce message sera affiché en premier
Appel a la fonction "foo"

L'appel à la fonction foo sera effectué après le retour de main, c'est-à-dire lorsque le programme sortira. Un autre chose importante à noté est que lors d'un appel à exit ou lorsque le programme sort de main, le flux standard stdout est vidé et son contenu est affiché dans le terminal (ou redirigé si vous l'avez redirigé).

Sortir d'un programme sans appeler de fonction

Il existe une autre façon de sortir d'un programme lors de son exécution. Si on fait quelques recherches, on réalise que la fonction exit possède une cousine : la fonction _exit. Cette fonction est en réalité un appel système et à la différence de exit, aucune procédure enregistrée avec atexit ou on_exit n'est appelée et le flux de sortie standard stdout est vidé et affiché dans le terminal.

Exécution de programme via execve

Il est possible de lancer des scripts shell ou des programmes directement depuis un code C via l'appel à la fonction execve ou une de ces cousines. Cette fonction est un appel système et son prototype est le suivant :

#include <unistd.h>

int execve(const char *pathname, char *const argv[],
           char *const envp[]);

Comme on peut le voir, cette fonction prend trois paramètres :

  • pathname : le chemin (relatif ou absolue) vers un exécutable.
  • argv : liste des paramètres. Le premier doit être le nom de l'exécutable et doit terminer par NULL.
  • env : les variables d'environnement. On y accède via une variable externe nommée environ. Il est à noter que le nom de l'exécutable ne peut pas être seulement une commande accessible depuis la variable d'environnement PATH. Il faut renseigner le nom complet du chemin. Pour pallier ce problème on peut passer par la fonction execvpe qui elle prend en compte les chemins que PATH contient. Cette fonction est une surcouche de execve. Il en existe d'autres dont voici les prototypes :
#include <unistd.h>

int execl(const char *pathname, const char *arg, ...
                /* (char  *) NULL */);
int execlp(const char *file, const char *arg, ...
                /* (char  *) NULL */);
int execle(const char *pathname, const char *arg, ...
                /*, (char *) NULL, char *const envp[] */);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);

#define _GNU_SOURCE

int execvpe(const char *file, char *const argv[],
                char *const envp[]);

Pour aller plus loin

Envoie de signaux aux processus avec la commande kill

Pour communiquer entre eux, les processus (programmes en cours d'exécution sur une machine) s'envoient des signaux. Ces signaux correspondent souvent à une requête d'un processus à un autre. Parmis les signaux existant, certains sont utiles pour :

Signal Description
TERM Terminer (tuer) un processus
STOP Mettre en pause un processus
CONT Reprendre l'exécution d'un processus

L'utilisation de la commande kill n'est pas le seul moyen d'envoyer des signaux à des processus, il est également possible de le faire en utilisant des raccourcis clavier. Plus précisément, l'utilisation de la touche Ctrl (abrégé en ^) plus une lettre permet d'effectuer des actions dans le terminal. Voici une liste de celles les plus utilisées :

Raccourci Description
^C Envoie le signal SIGTERM à un processus
^D Ferme l'entrée standard
^L Equivaut à clear
^R Fait une recherche dans le terminal des commandes précédentes

L'appel système clone

L'appel système clone permet également la création de processus comme le fait fork mais avec plus de précision sur la manière de créer le processus. Son prototype est le suivant :

#include <sched.h>

int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
/* pid_t *parent_tid, void *tls, pid_t *child_tid */ );

Les différences avec fork se résume au points suivants:

  • Au lieu de revenir à l'appel de clone, le fils appel directement la fonction fn.
  • On doit préciser ou s'exécutera le fils. Cela se fait en précisant l'argument stack qui indique ou sera la pile pour le processus créé.

Pour cet appel système, il faut allouer soit même de la mémoire dans le tas afin qu'une fonction y soit exécutée (On doit tout faire soit même). Il est à noté que la mémoire à allouer pour renseigner la pile peut être obtenue avec l'appel système mmap en donnant l'option MAP_STACK. Attention à bien considérer qu'une pile décroit (les addresses grandissent en descendant). Ainsi, l'adresse à donner correspond à la dernière adresse du tableau allouer et non la première.

Même si fork et clone sont différents dans la manière de fonctionner, on peut toutefois utiliser clone pour créer un nouveau processus. Comme fork, le retour de clone donne le pid du fils. Ceci permet au parent de pouvoir attendre son fils avec l'appel système waitpid.

⚠️ **GitHub.com Fallback** ⚠️