Les processus
Définition
Jusqu'à présent nous avons parlé de programme, mais on utilise plutôt le terme processus pour désigner un programme en cours d'exécution.
Un processus est caractérisé par
- son code (le code binaire du programme qu'il exécute)
- ses données:
- statiques: variables globales, variables statiques des fonctions
- pile: variables automatiques (locales)
- tas: zones mémoire allouées dynamiquement,
- des données conservées par le noyau pour la gestion du processus: identificateur, utilisateur(s) attaché(s) au processus, fichiers en cours d'utilisation, etc
Etat d'un processus
Plusieurs processus peuvent sembler s'exécuter en même temps mais le nombre maximum de processus qui peuvent s'exécuter simultanément est le nombre de processeurs du système. Le système d'exploitation fait fonctionner un à un les processus pendant un court laps de temps et les met en pause, chaque processus utilise tour à tour le processeur plusieurs fois par seconde ce qui donne l'impression de la simultanéité d'exécution.
Pour un processus, on distingue les états suivants.
- En cours d'exécution (running): soit disposant du processeur, soit en attente du processeur.
- En sommeil (sleeping): en attente d'une ressource.
- Stoppé (stopped): arrêté généralement par CTRL-S, par exemple, ou par un debogueur.
- Zombie: le processus est terminé mais le noyau conserve encore des informations à son sujet qui pourront être consultées par un autre processus avant suppression définitive.
Le PID
Les processus sont identifiés par un numéro unique attribué par le noyau: le pid (Processus ID). Les appels-système qui concernent les processus utilisent le numéro pid pour désigner le processus qui fait l'objet de l'appel.
Un processus peut obtenir son pid par l'appel système getpid().
#include <unistd.h>
pid_t getpid(void)
Cette fonction retourne le pid du processus appelant.
Le type pid_t est un type entier dont la taille peut varier d'un système à l'autre.
Exemple:
#include <unistd.h>
#include <stdio.h> // Pour printf
int main(int argc, char **arv, char **envp)
{
pid_t pid;
pid = getpid();
printf("Mon pid est %d\n", pid);
return 0;
}
La création de processus
Un processus est toujours créé par un autre processus, son "père". Un processus créé par un autre est un "fils" de celui-ci.
L'appel système qui réalise la création d'un processus fils est fork().
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
Le processus créé exécute le même programme que son père et commence son exécution au retour de l'appel de la fonction fork(). Il s'agit donc d'un clonage de processus dans son "état" d'exécution. Le "père" et le "fils" continuent donc leur exécution juste après l'appel de fork, le code de retour de fork permet de les distinguer.
La fonction fork() retourne
- -1 dans le processus père si elle échoue (par manque de ressources système pour créer un processus), auquel cas aucun processus n'est créé. Le code d'erreur est alors stocké dans la variable errno.
- le pid du fils créé, dans le processus père si elle réussit
- 0 dans le processus fils.
En cas d'échec (valeur retournée -1), le variable errno définie dans <errno.h> contient une valeur qui indique la cause de l'erreur:
- EAGAIN: le noyau ne dispose momentanément pas d'assez de ressources pour créer le processus mais on peut réessayer plus tard.
- ENOMEM: le noyau ne dispose pas d'assez de mémoire pour créer le processus.
Par exemple voici un programme qui exécute fork et affiche le résultat:
#include <unistd.h>Lorsque ce programme est exécuté, il affiche ceci (les numéros de processus changent pour chaque exécution):
#include <stdio.h>
int main(int argc, char **argv, char **envp)
{
pid_t res_fork;
printf("Mon PID est %d\n", getpid());
res_fork = fork();
printf("La valeur retournée par fork() est %d\n"
"Mon PID est %d\n", res_fork, getpid());
return 0;
}
Mon PID est 12111
La valeur retournée par fork() est 0
Mon PID est 12112
La valeur retournée par fork() est 12112
Mon PID est 12111
On peut voir que le processus "père", avant le fork possède le PID 12111, ensuite il appelle la fonction fork() où l'espace d'adressage du processus est cloné. Dans ce cas ci, le fils s'exécute en premier lieu, on peut le voir car son code de retour de la fonction fork est 0. Le fils à effectivement un numéro de processus différent du père (ici 12112). Lorsque le processus père reprend son exécution il a obtenu le PID du fils comme code de retour de la fonction fork.
On voit que le code de retour permet de savoir comment continuer l'exécution selon qu'il s'agisse du père ou du fils.
Auparavant les zones mémoires des données du processus père étaient dupliquées pour le processus fils à sa création, ce qui rendait la création d'un processus fort onéreuse en ressources CPU et mémoire. Actuellement, dans Linux et dans d'autres systèmes, à la création d'un processus, le père et le fils partagent les mêmes pages mémoire et c'est seulement lorsqu'un des deux processus modifie une donnée que la page mémoire qui la contient est dupliquée. Ce mécanisme s'appelle "copy on write" (copie lors de l'écriture) et allège la création de processus. Il est d'autant plus utile que bien souvent le processus fils va directement "charger" le code d'un autre programme que celui qu'exécute son père et n'aura donc aucun usage d'une copie des données de celui-ci (voir plus loin).
Remarque
Le premier processus est créé par le noyau au démarrage du système et reçoit le pid 1. Il s'agit généralement du processus init. Il est l'ancêtre commun de tous les processus.
L'appel système getppid() permet à un processus d'obtenir le pid de son père.
#include <unistd.h>Exemple
pid_t getppid(void);
#include <unistd.h>
#include <stdio.h>
int main(int argc, char **argv, char **envp)
{
pid_t pid_fils;
printf("Je suis le père et mon pid est %d\n", getpid());
switch (pid_fils = fork())
{
case -1:
perror("Erreur lors de fork()");
break;
case 0:
printf("Je suis le fils, mon pid est %d et celui de mon père"
" %d\n", getpid(), getppid());
break;
default:
printf("Je suis le père, mon pid est toujours %d et celui de"
" mon fils est %d\n", getpid(), pid_fils);
break;
}
return 0;
}
Lorsqu'il est exécuté, ce code affiche (les PID changent à chaque exécution):
Je suis le père et mon pid est 12903
Je suis le fils, mon pid est 12904 et celui de mon père 12903
Je suis le père, mon pid est toujours 12903 et celui de mon fils est 12904
La terminaison d'un processus
Dans les exemples précédents, les processus se terminaient avec la fin de la fonction main(), cette fonction doit retourner (à l'aide de return) un entier qui correspond à la valeur (ou code) de retour du processus et qui peut être consultée par son père et sert au processus fils à communiquer à son père une information sur ses circonstances de terminaison (succès ou erreur).
La convention veut qu'un processus retourne une valeur nulle pour signaler des circonstances normales de terminaison (sans erreur) et une valeur non nulle arbitraire pour indiquer des conditions particulières de terminaison (telles qu'une erreur). La valeur non nulle peut spécifier le type d'erreur à son père mais il n'existe pas de convention sur les numéros de retour.
Il existe une fonction permettant de terminer un processus et qui peut être invoquée dans une autre fonction que main(), il s'agit de la fonction exit().
#include <stdlib.h>
void exit(int status);
L'argument de la fonction exit() est le code de retour du processus.
Le fichier d'en-tête <stdlib.h> fournit aussi les macros EXIT_SUCCESS et EXIT_FAILURE qui peuvent être utilisées comme argument de return ou exit() pour signaler une terminaison normale ou anormale sans indication supplémentaire du processus.
Avant la terminaison du processus suite à return ou exit() un certain nombre d'opérations sont encore réalisées avant la fin effective du processus, à savoir, l'exécution éventuelle de fonctions enregistrées grâce aux fonctions atexit() ou on_exit(), la fermeture des flux (fichiers) encore ouverts, la suppression de fichiers temporaires créés par tmpfile(), la fermeture des descripteurs ouverts et l'invocation d'un appel-système de terminaison du processus.
La fonction _exit() permet également de mettre fin à un processus mais sans exécuter les fonctions enregistrées grâce aux fonctions atexit() ou on_exit() et sans utiliser le gestionnaire de signaux.
#include <unistd.h>
void _exit(int code);
Cette fonction peut aussi s'écrire _Exit() et est alors déclarée dans <stdlib.h>.
On ne revient bien entendu pas de ces deux fonctions.
Lorsqu'un processus se termine, le noyau récupère les ressources qui lui étaient allouées mais conserve différentes informations sur les circonstances de terminaison du processus, en particulier, sa valeur de retour. Le processus reste donc présent dans la liste des processus du système mais dans l'état zombie.
Le processus ne sera définitivement supprimé des tables du noyau que lorsque son père aura consulté ces données au moyen de fonctions que nous allons aborder ci-après. Si un processus père se termine avant son fils, celui-ci est adopté par le processus de pid 1 (en général init) qui se chargera de consulter son état de terminaison.
L'attente de la fin d'un fils
La fonction wait() permet à un processus de se mettre en attente de la terminaison d'un de ses fils et de récupérer les informations sur l'état de terminaison de celui-ci.
#include <unistd.h>
pid_t wait(int *stat_loc);
Un processus qui exécute cette fonction est suspendu (état sommeil) jusqu'à la terminaison d'un de ses fils sauf si ce processus n'a pas de fils, auquel cas la fonction retourne -1 ou s'il existe un/des fils à l'état zombie auquel cas la fonction retourne directement le pid d'un de ces fils.
Lorsqu'un fils se termine, le processus père est réveillé et la fonction retourne le pid de ce fils.
On doit passer à la fonction un pointeur vers un entier dans lequel la fonction placera l'information sur les circonstances de terminaison du fils. Si on ne souhaite pas récupérer cette information, on peut passer le pointeur NULL.
L'accès au code de retour du processus fils s'effectue en testant d'abord si le processus s'est terminé volontairement, en retournant de main() ou en utilisant les fonctions exit() ou _exit() (on verra une autre cause de terminaison de processus dans le chapitre sur les signaux), par la macro WIFEXITED() qui prend comme argument l'entier dont l'adresse est passée à la fonction wait() et produit un valeur non nulle dans ces circonstances. Ensuite, la macro WEXITSTATUS() qui prend comme argument le même entier produit le code de retour du processus.
La fonction waitpid():
#include <sys/wait.h>
pid_t waitpid (pid_t pid, int *status, int options);
permet de se mettre en attente de la fin du fils dont le PID est indiqué par le paramètre pid.
Si la valeur pour le paramètre pid est -1, waitpid() se comporte comme wait() et correspond donc à l'attente d'un fils quelconque. Il est également possible de passer une valeur nulle ou négative (différente de -1) pour le paramètre pid mais ceci ne sera pas abordé ici.
Le paramètre option peut prendre la valeur nulle ou une combinaison de drapeaux définies par les macros WNOHANG et WUNTRACED. La valeur WNOHANG rend l'appel non bloquant, c'est-à-dire que le processus n'est pas suspendu si le fils indiqué n'est pas terminé au moment de l'appel. Auquel cas la fonction retourne immédiatement la valeur 0. La valeur WUNTRACED permet d'obtenir les informations d'un fils stoppé et ne sera pas détaillée ici.
En cas d'échec, la fonction retourne -1 et la variable errno contient une valeur permettant d'identifier la cause de l'échec qui peut être entre autres:
- ECHILD: le pid indiqué ne correspond pas à un processus existant ou correspond à un processus qui n'est pas un fils du processus qui a effectué l'appel à waitpid().
- EINVAL: la valeur de l'argument option n'est pas valide.
Exemple.
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main(int argc, char **argv, char **envp)
{
pid_t pid_fils;
int info;
pid_t res;
printf("Je suis le père et mon PID est %d\n", getpid());
switch (pid_fils = fork())
{
case -1:
perror("Erreur lors du fork");
exit(EXIT_FAILURE);
case 0:
printf("Je suis le fils, mon PID est %d et celui de mon père"
" %d\n", getpid(), getppid());
sleep(2);
exit(2);
default:
if ((res = waitpid(pid_fils, &info, 0)) == -1 )
{
perror("Erreur lors de waitpid");
exit(EXIT_FAILURE);
}
else
{
printf("Je suis le père, le PID de mon fils était %d\n",res);
if ( WIFEXITED(info) )
printf("Mon fils a renvoyé la valeur %d\n",
WEXITSTATUS(info));
else
printf("Mon fils s'est arrêté à cause d'un signal\n");
}
}
return EXIT_SUCCESS;
}
Le recouvrement d'un processus
Un processus créé par fork() exécute le même programme que son père ce qui est très limitatif.
Les fonctions de la famille exec() permettent de "remplacer" le programme qu'exécute un processus. C'est ce qu'on appelle un recouvrement.
La plus simple des fonctions de cette famille est la fonction execl().
#include <unistd.h>
int execl (const char *path, const char *arg, ...);
Cette fonction remplace le code du processus qui l'exécute par le code du programme dont le chemin est indiqué dans son premier paramètre. Les zones mémoire correspondant aux données sont également remplacées. Il n'y a pas de création de nouveau processus. Le processus conserve son entrée dans la table des processus du noyau et conserve par là certaines ressources de l'ancier programme, en particulier les "fichiers" ouverts. Le processus conserve bien entendu son pid.
Les paramètres suivants, en nombre variable, sont ceux qui seront passés à ce programme et qui seront accessibles via le paramètre argv de la fonction main() de celui-ci. La liste des paramètres doit impérativement se terminer par un pointeur NULL.
Par convention, la valeur du premier paramètre est le nom du programme invoqué.
Si l'appel réussit, la fonction ne revient pas car le programme qui l'a appelée a été remplacé par le programme spécifié comme premier paramètre de execl(). Si la fonction revient, c'est qu'il y a eu une erreur (la valeur de retour est -1) et la variable errno permet de déterminer la cause de l'erreur, entre autres:
- EACCES: le fichier spécifié par path n'existe pas, l'utilisateur auquel est attaché le processus n'y a pas accès ou ne peut pas l'exécuter, ...
- ENOEXEC: le format du fichier n'est pas valide
- ENAMETOOLONG: le chemin du fichier est trop long
- ...
Exemple
prog1.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char **argv, char **envp)
{
printf("Je suis le processus %ld et j'exécute le programme prog1\n",
(long)getpid());
(void)execl("./prog2", "un", "deux", "trois", NULL);
perror("Erreur lors de exec");
return EXIT_FAILURE;
}
prog2.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char **argv, char **envp)
{
printf("Je suis le processus %ld et j'exécute le programme prog2\n",
(long)getpid());
printf("Mes paramètres sont:\n");
int i;
for ( i = 0; i < argc; ++i )
printf("Param %d: %s\n", i, argv[i]);
return EXIT_SUCCESS;
}
Le second code source doit être compilé sous le nom "prog2" et être placé dans le même répertoire que le premier programme:
gcc -Wall -o prog1 prog1.c
gcc -Wall -o prog2 prog2.c
Lors de l'exécution du programme 1, on constate qu'il s'exécute jusque l'appel de execl(), et qu'ensuite c'est le code du second programme qui est exécuté:
$./prog1
Je suis le processus 11151 et j'exécute le programme prog1
Je suis le processus 11151 et j'exécute le programme prog2
Mes paramètres sont:
Param 0: un
Param 1: deux
Param 2: trois
La famille exec
Les autres membres de la famille de fonctions exec() sont:
- int execlp (const char *file, const char *arg, ...);
- int execle (const char *path, const char *arg , ..., char *const envp[]);
- int execv (const char *path, char *const argv[]);
- int execvp (const char *file, char *const argv[]);
- int execve (const char *fichier, char *const argv [], char *const envp[]);
Les fonctions qui contiennent la lettre "L" dans leur nom, acceptent une liste variable d'arguments (terminée par un pointeur NULL) correspondant aux paramètres du programme à charger. Celles qui contiennent un "V", utilisent un tableau de chaînes terminé par un pointeur NULL.
Les fonctions qui contiennent un "P" dans leur nom recherchent le programme à charger dans le PATH. Il s'agit d'une variable d'environnement (cfr plus loin) contenant une liste de chemins séparés par le caractère deux points ":".
Les fonctions qui contiennent un "E" permettent de spécifier un nouvel environnement pour le programme chargé sous la forme d'un tableau de chaînes de la forme "nom=valeur" terminé par un pointeur NULL. Les autres fonctions transmettent une copie de l'environnement du processus appelant. L'environnement d'un processus est un tableau de chaînes de caractères de la forme "nom=valeur" accessibles via la variable char **envp ou via le 4ème paramètre de la fonction main() (ou encore via la fonction getenv() de <stdlib.h>). On peut modifier son environnement grâce aux fonctions clearenv(), putenv(), setenv(), unsetenv() de <stdlib.h>. L'environnement permet à un processus de communiquer des informations à ses fils mais comme l'environnement est propre à un processus, un processus fils ne peut pas modifier l'environnement de son père ni l'inverse.
Toutes ces fonctions sont définies à partir de execve() qui est le seul véritable appel-système.
Le lancemement d'un processus qui exécute un code différent de celui de son père s'effectue pratiquement toujours donc de la façon suivante.
- On effectue un appel à fork() dans le père.
- Au retour de l'appel dans le père, celui-ci poursuit son exécution.
- Au retour de l'appel dans le fils, on effectue un appel à une fonction de la famille exec() pour exécuter un autre programme.
La fonction system()
La fonction system() lance l'exécution de la commande indiquée en paramètre, celle-ci est exécutée au travers d'un shell et non pas directement dans l'espace d'adressage du processus. Voici la signature de cette fonction:#include <stdlib.h>
int system(const char *string);
En cas de succès, la fonction ne revient que lorsque le processus exécutant la commande se termine et la valeur de retour de la fonction est la valeur de retour de ce processus. En cas d'erreur, erreur lors du fork(), par exemple, la fonction retourne immédiatement -1. Si le shell ne peut être lancé, la fonction retourne 127.
Exemple
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv, char **envp)
{
int res;
printf("Avant l'appel de system()\n");
res = system("ps -f");
printf("Après l'appel de system(). Valeur de retour %d\n", res);
return EXIT_SUCCESS;
}
Commandes utiles
La commande ps permet d'afficher des informations sur des processus et dispose d'un grand nombre d'options (documentées dans la page de manuel) pour indiquer les processus concernés et les informations à afficher.
La commande ps puise les informations sur les processus dans le répertoire /proc qui possède pour chaque processus un sous-répertoire nommé par le pid de celui-ci. Le sous-répertoire d'un processus contient diverses informations sur le processus telles que son état, le programme exécuté, ... Le répertoire /proc n'est pas un répertoire résidant sur disque mais un répertoire "virtuel" dont le contenu des fichiers est produit dynamiquement par le noyau et qui permet d'obtenir différentes informations sur le système ainsi que de configurer dynamiquement certaines fonctionnalités du noyau.
L'utilitaire pstree affiche l'arbre généalogique des processus de même que la commande ps axf (pour la version GNU de ps).
La commande top affiche en temps réel des informations sur les processus classés selon différents critères: utilisation CPU, mémoire, ...

