Création de Processus : La Fonction fork()
Sous Linux, la fonction système fork() est le mécanisme fondamental pour créer un nouveau processus. Elle duplique le processus appelant (le parent) pour générer un nouveau processus (l'enfant). Après un appel réussi à fork(), le système d'exploitation fournit deux processus pratiquement identiques, avec quelques distinctions clés.
Prototype et Comportement de fork()
#include <unistd.h>
pid_t fork(void);
La fonction fork() renvoie un entier de type pid_t :
- Dans le processus enfant :
0est renvoyé. - Dans le processus parent : le Process ID (PID) du processus enfant est renvoyé.
- En cas d'erreur :
-1est renvoyé dans le processus parent, et aucun enfant n'est créé.
Exemple Simple de fork()
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h> // Pour exit()
int main() {
pid_t id_processus_cree = fork();
if (id_processus_cree == -1) {
perror("Erreur lors de la création du processus");
exit(EXIT_FAILURE);
} else if (id_processus_cree == 0) {
// Ce bloc est exécuté par le processus enfant.
printf("Processus Enfant : Mon PID est %d, le PID de mon parent est %d.\n", getpid(), getppid());
printf("Processus Enfant : La valeur de 'id_processus_cree' est %d.\n", id_processus_cree);
sleep(1); // Laisse au parent le temps de s'exécuter parfois
} else {
// Ce bloc est exécuté par le processus parent.
printf("Processus Parent : Mon PID est %d, le PID de mon enfant est %d.\n", getpid(), id_processus_cree);
printf("Processus Parent : La valeur de 'id_processus_cree' est %d.\n", id_processus_cree);
sleep(1); // Laisse à l'enfant le temps de s'exécuter parfois
}
return 0;
}
Mécanisme de fork() au Niveau du Noyau
Lorsqu'un processus appelle fork(), le noyau Linux exécute plusieurs étapes critiques :
- **Duplication du Contexte Parent :** Le noyau crée une nouvelle entrée dans la table des processus (un nouveau descripteur de processus ou PCB) pour l'enfant. Il duplique les registres CPU, l'état de la pile du noyau et une partie du contexte du processus parent dans le PCB de l'enfant.
- **Duplication des Ressources :**
- **Espace d'Adresse Virtuelle :** L'espace d'adresse virtuelle du parent (représenté par
mm_struct) est dupliqué pour l'enfant. Cependant, les pages mémoire physiques correspondantes ne sont pas copiées immédiatement. Elles sont marquées en lecture seule et partagées entre le parent et l'enfant. - **Descripteurs de Fichiers :** La table des descripteurs de fichiers (
files_struct) est également copiée, ce qui signifie que le parent et l'enfant partagent les mêmes fichiers ouverts.
- **Espace d'Adresse Virtuelle :** L'espace d'adresse virtuelle du parent (représenté par
- **Différenciation :** Le noyau modifie la valeur de retour dans la pile du noyau de l'enfant à
0, et celle du parent au PID de l'enfant. - **Planification :** L'enfant est ajouté à la file d'attente des processus exécutables, prêt à être planifié.
L'apparente similitude des adresses de variables (comme id_processus_cree) mais la divergence de leurs valeurs après un fork() s'explique par le concept de copie à l'écriture (Copy-On-Write).
Copie à l'Écriture (Copy-On-Write - COW) et Pagination à la Demande
Le noyau Linux utilise des techniques d'optimisation mémoire pour rendre fork() plus efficace :
- **Copie à l'Écriture (COW) :** Après un
fork(), le parent et l'enfant partagent initialement les mêmes pages de mémoire physique pour leurs segments de données et de code, mais ces pages sont marquées en lecture seule. Si l'un des processus tente d'écrire dans une de ces pages partagées, une faute de page se produit. Le noyau intercepte cette faute, effectue une copie de la page physique concernée, et donne à chaque processus sa propre copie modifiable. Cela évite de copier l'intégralité de la mémoire si les processus ne modifient qu'une petite partie de celle-ci. - **Pagination à la Demande :** Le noyau ne charge les pages d'un programme en mémoire physique que lorsqu'elles sont effectivement accédées. Si une adresse virtuelle n'a pas de page physique correspondante dans la table des pages (ou si elle est swapée), une faute de page est déclenchée, et le noyau alloue une page physique et y charge les données nécessaires.
Terminaison des Processus
La terminaison d'un processus consiste principalement à libérer les ressources système qui lui étaient allouées. Cela inclut la libération de son espace d'adresse virtuelle (mm_struct), ses descripteurs de fichiers (files_struct), et finalement son descripteur de processus (PCB) une fois que le parent l'a récupéré.
Scénarios et Méthodes de Sortie
Un processus peut se terminer de plusieurs manières :
- **Fin d'Exécution Normale :**
- Retour de la fonction
main(). - Appel à
exit()(qui effectue un nettoyage, y compris le vidage des tampons de flux, avant d'appeler_exit()). - Appel à
_exit()ou_Exit()(sortie immédiate, sans vidage des tampons).
- Retour de la fonction
- **Fin d'Exécution Anormale :**
- Par la réception d'un signal (ex: SIGKILL, SIGSEGV).
Code de Retour (Code d'Exit)
Le code de retour est une petite valeur entière (généralement entre 0 et 255) qu'un processus transmet à son parent lors de sa terminaison. Il indique le succès ou l'échec de l'exécution :
0indique une exécution réussie.- Toute valeur non nulle est généralement interprétée comme un échec ou un avertissement spécifique.
Attente des Processus (Process Waiting)
Nécessité de l'Attente
L'attente des processus enfants par leurs parents est cruciale pour plusieurs raisons :
- **Prévention des Processus Zombies :** Lorsqu'un processus enfant se termine, son descripteur de processus (PCB) reste dans le système jusqu'à ce que son parent l'ait "récolté" en appelant une fonction d'attente. Si le parent n'attend jamais, le PCB de l'enfant devient un "processus zombie", occupant inutilement des ressources du noyau.
- **Récupération des Ressources :** L'attente permet au processus parenet de libérer complètement les ressources associées au processus enfant terminé, y compris son PCB.
- **Obtention du Statut de Terminaison :** Le parent peut récupérer le code de retour de l'enfant ou des informations sur la cause de sa terminaison (par exemple, s'il a été tué par un signal).
Méthodes d'Attente : wait() et waitpid()
1. La fonction wait()
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
wait() suspend l'exécution du processus appelant jusqu'à ce qu'un de ses processus enfants se termine. Elle renvoie le PID de l'enfant qui s'est terminé ou -1 en cas d'erreur. Le paramètre status est un pointeur vers un entier où le système stocke les informations de terminaison de l'enfant.
2. La fonction waitpid()
pid_t waitpid(pid_t pid, int *status, int options);
waitpid() offre plus de contrôle que wait() :
pid:-1: Attend n'importe quel processus enfant (équivalent àwait()).> 0: Attend le processus enfant avec l'ID spécifié.0: Attend n'importe quel processus enfant du même groupe de processus que l'appelant.< -1: Attend n'importe quel processus enfant dont le groupe de processus est égal à la valeur absolue depid.
status: Identique àwait().options:0: Comportement bloquant par défaut.WNOHANG: Rend l'appel non bloquant. Si aucun enfant n'est terminé,waitpid()renvoie0.
Interprétation du Statut de Terminaison
L'entier status n'est pas un simple code, mais un bitmap contenant diverses informations. Des macros sont fournies pour son interprétation :
WIFEXITED(status): Retourne vrai si l'enfant s'est terminé normalement (viaexit()ou un retour demain()).WEXITSTATUS(status): SiWIFEXITEDest vrai, cette macro retourne le code de sortie de l'enfant (les 8 bits de poids faible).WIFSIGNALED(status): Retourne vrai si l'enfant a été terminé par un signal non intercepté.WTERMSIG(status): SiWIFSIGNALEDest vrai, cette macro retourne le numéro du signal qui a causé la terminaison.
Exemple d'Attente avec wait()
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h> // Pour wait() et les macros de statut
int main() {
pid_t id_enfant;
int statut_sortie; // Pour stocker le statut de sortie
id_enfant = fork();
if (id_enfant == -1) {
perror("Échec de la création du processus enfant");
exit(EXIT_FAILURE);
} else if (id_enfant == 0) {
// Code du processus enfant
printf("Enfant (PID: %d) démarre. Il va dormir puis sortir.\n", getpid());
sleep(3); // L'enfant dort pendant 3 secondes
printf("Enfant (PID: %d) se termine avec le code 42.\n", getpid());
exit(42); // L'enfant sort avec le code 42
} else {
// Code du processus parent
printf("Parent (PID: %d) attend l'enfant (PID: %d).\n", getpid(), id_enfant);
// Attendre que l'enfant se termine
pid_t pid_recupere = wait(&statut_sortie);
if (pid_recupere == -1) {
perror("Erreur lors de l'attente du processus enfant");
exit(EXIT_FAILURE);
}
printf("Parent : L'enfant (PID: %d) a été récupéré.\n", pid_recupere);
if (WIFEXITED(statut_sortie)) {
printf("Parent : L'enfant s'est terminé normalement. Code de sortie : %d.\n", WEXITSTATUS(statut_sortie));
} else if (WIFSIGNALED(statut_sortie)) {
printf("Parent : L'enfant a été terminé par un signal. Numéro de signal : %d.\n", WTERMSIG(statut_sortie));
} else {
printf("Parent : L'enfant s'est terminé de manière inattendue.\n");
}
}
return EXIT_SUCCESS;
}
Remplacement de l'Image Programme (Famille de Fonctions exec)
Après un fork(), il est courant que le processus enfant souhaite exécuter un programme différent de celui de son parent. C'est le rôle de la famille de fonctions exec.
Principe de Fonctionnement
Les fonctions exec remplacent l'image mémoire du processus appelant par celle d'un nouveau programme. Cela signifie que le code, les données, la pile et le tas du processus existant sont écrasés par ceux du nouveau programme. Le Process ID (PID) du processus reste inchangé, tout comme les descripteurs de fichiers ouverts (à moins que le drapeau FD_CLOEXEC ne soit défini pour un descripteur spécifique).
Au niveau du noyau :
- Le noyau libère l'ancien espace d'adresse virtuelle et les segments de code et de données associés.
- Un nouvel espace d'adresse est configuré pour le programme à charger.
- Le fichier exécutable (généralement au format ELF sous Linux) est analysé, et ses segments (texte, données) sont mappés dans le nouvel espace d'adresse. Le chargement en mémoire physique se fait souvent à la demande.
- La pile est réinitialisée avec les arguments de la ligne de commande et les variables d'environnement du nouveau programme.
- Le pointeur d'instruction (program counter) est mis à jour pour pointer vers le point d'entrée du nouveau programme.
La Famille de Fonctions exec
Il existe six fonctions exec, toutes déclarées dans <unistd.h>, mais seul execve() est un appel système direct ; les autres sont des wrappers de bibliothèque autour de celui-ci.
int execl(const char *path, const char *arg, ...);
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 *path, char *const argv[], char *const envp[]);
Ces fonctions se distinguent par leurs suffixes :
l(liste) : Les arguments de la ligne de commande sont passés sous forme de liste de chaînes de caractères individuelles, terminées parNULL.v(vecteur) : Les arguments de la ligne de commande sont passés sous forme d'un tableau (vecteur) de chaînes de caractères, dont le dernier élément estNULL.p(chemin) : La fonction utilise la variable d'environnementPATHpour rechercher le fichier exécutable, ce qui permet de ne pas spécifier le chemin absolu. Sansp, le chemin complet est requis.e(environnement) : Permet de spécifier explicitement un nouvel ensemble de variables d'environnement sous forme de tableau de chaînes, dont le dernier élément estNULL. Sanse, le processus hérite l'environnement du processus appelant.
Toutes ces fonctions ne retournent un entier que si une erreur se produit (retournent -1). En cas de succès, le nouveau programme prend le contrôle et la fonction appelante ne revient jamais.
Exemple d'Utilisation des Fonctions exec
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> // Pour la famille de fonctions exec
int main() {
printf("Processus initial (PID: %d) va tenter d'exécuter un nouveau programme.\n", getpid());
char *arguments_ls[] = {"ls", "-l", "/tmp", NULL}; // Arguments pour "ls -l /tmp"
char *environnement_personnalise[] = {"MON_VAR=valeur_speciale", "TERM=xterm", NULL}; // Environnement sur mesure
// --- Exemple 1: execlp (recherche dans PATH, arguments en liste) ---
// printf("Exécution de 'ls -l /usr/local' via execlp...\n");
// execlp("ls", "ls", "-l", "/usr/local", NULL);
// Si réussi, le code ci-dessous n'est jamais atteint.
// --- Exemple 2: execv (chemin complet, arguments en vecteur) ---
// printf("Exécution de 'ls -l /tmp' via execv...\n");
// execv("/bin/ls", arguments_ls);
// Si réussi, le code ci-dessous n'est jamais atteint.
// --- Exemple 3: execve (chemin complet, arguments en vecteur, environnement personnalisé) ---
printf("Exécution de 'env' avec un environnement personnalisé via execve...\n");
char *arguments_env[] = {"env", NULL}; // Arguments pour la commande 'env'
execve("/usr/bin/env", arguments_env, environnement_personnalise);
// Si une des fonctions exec renvoie, cela signifie qu'une erreur s'est produite.
perror("Erreur lors de l'exécution d'un nouveau programme");
exit(EXIT_FAILURE);
return 0; // Inaccessible si exec réussit
}