Chapitres couverts : Process Creation et Process Termination
APIs principales : fork(), execve(), wait(), waitpid(), exit(), _exit(), atexit(), kill(), sigprocmask(), sigsuspend()
Objectif du cours
Ce cours explique comment Linux crée, exécute et termine les processus.
L'idée importante à comprendre est la suivante : sous Unix/Linux, on ne crée pas directement un nouveau programme comme avec CreateProcess() sous Windows. On fait généralement deux étapes :
- fork() : duplique le processus courant.
- exec() : remplace le programme du processus courant par un autre programme.
- wait()/waitpid() : permet au parent d'attendre la fin de l'enfant et de récupérer son code de retour.
- exit()/_exit() : termine le processus.
Code: Select all
fork() = créer un clone du processus
exec() = remplacer le programme courant
wait() = attendre la mort d'un enfant
exit() = terminer proprement côté libc
_exit() = terminer directement côté noyau
1. Rappel : c'est quoi un processus ?
Un processus est une instance d'un programme en cours d'exécution. Ce n'est pas juste le fichier exécutable sur le disque.
Un processus possède notamment :
- un PID, c'est-à-dire un identifiant de processus ;
- un PPID, c'est-à-dire le PID du processus parent ;
- un espace mémoire virtuel : code, heap, stack, données globales ;
- une table de descripteurs de fichiers ;
- un répertoire courant ;
- un environnement ;
- un masque de signaux ;
- des dispositions de signaux ;
- des ressources noyau associées.
Donc quand on parle de processus, il faut toujours penser à deux gros blocs :
- la mémoire du processus ;
- les descripteurs de fichiers ouverts par le processus.
2. Création de processus avec fork()
Prototype
Code: Select all
#include <unistd.h>
pid_t fork(void);
Le processus qui appelle fork() est appelé processus parent.
Après l'appel à fork(), le parent et l'enfant continuent tous les deux à exécuter le code à partir de la ligne qui suit fork().
La seule différence immédiate est la valeur de retour de fork().
Valeur de retour de fork()
Code: Select all
-1 : erreur, l'enfant n'a pas été créé
0 : on est dans le processus enfant
>0 : on est dans le parent, et la valeur est le PID de l'enfant
Code: Select all
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
printf("Je suis l'enfant, PID=%ld, PPID=%ld\n",
(long)getpid(), (long)getppid());
} else {
printf("Je suis le parent, PID=%ld, enfant=%ld\n",
(long)getpid(), (long)pid);
}
return 0;
}
Code: Select all
if (pid == -1) -> erreur
if (pid == 0) -> code de l'enfant
if (pid > 0) -> code du parent
3. Ce que fork() copie
Après un fork(), l'enfant ressemble énormément au parent.
Il hérite notamment :
- d'une copie de l'espace mémoire ;
- des variables globales ;
- de la pile ;
- du heap ;
- des descripteurs de fichiers ouverts ;
- du répertoire courant ;
- de l'environnement ;
- du masque de signaux ;
- des dispositions de signaux ;
- de plusieurs attributs liés au processus.
La mémoire et les fichiers n'ont pas le même comportement.
4. Mémoire après fork() : copy-on-write
Conceptuellement, fork() crée une copie de la mémoire du parent.
Donc l'enfant possède ses propres variables.
Exemple :
Code: Select all
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int global = 10;
int main(void)
{
int local = 20;
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
global = 111;
local = 222;
printf("enfant : global=%d local=%d\n", global, local);
} else {
sleep(1);
printf("parent : global=%d local=%d\n", global, local);
}
return 0;
}
Code: Select all
enfant : global=111 local=222
parent : global=10 local=20
Mais en interne, Linux optimise avec le copy-on-write
Si Linux copiait immédiatement toute la mémoire du parent, fork() serait très coûteux.
En réalité, Linux utilise une optimisation appelée copy-on-write, souvent abrégée COW.
Le principe :
- juste après fork(), parent et enfant pointent vers les mêmes pages physiques ;
- ces pages sont marquées en lecture seule ;
- si un des deux processus tente d'écrire dans une page, une faute de page se produit ;
- le noyau copie alors uniquement cette page ;
- le processus qui écrit reçoit sa propre copie modifiable.
Code: Select all
Avant écriture : parent et enfant partagent les mêmes pages physiques.
Après écriture : la page modifiée est copiée pour celui qui écrit.
5. Descripteurs de fichiers après fork()
C'est ici que beaucoup se trompent.
La mémoire est séparée logiquement, mais les fichiers ouverts ont un comportement différent.
Après fork(), l'enfant reçoit une copie de la table des descripteurs du parent.
Mais ces descripteurs pointent vers les mêmes open file descriptions dans le noyau.
Cela signifie que parent et enfant peuvent partager :
- le même offset de fichier ;
- certains flags d'état du fichier, comme O_APPEND ;
- la même description noyau représentant le fichier ouvert.
Code: Select all
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/wait.h>
int main(void)
{
int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("open");
exit(EXIT_FAILURE);
}
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
write(fd, "ENFANT\n", 7);
_exit(0);
} else {
write(fd, "PARENT\n", 7);
wait(NULL);
}
close(fd);
return 0;
}
L'ordre exact dépend du scheduling.
Résultat possible :
Code: Select all
PARENT
ENFANT
Code: Select all
ENFANT
PARENT
Code: Select all
Mémoire après fork() -> séparée logiquement grâce au copy-on-write
Descripteurs après fork() -> copiés, mais peuvent pointer vers la même description noyau
Exemple avec un pipe :
Code: Select all
int pipefd[2];
pipe(pipefd);
pid_t pid = fork();
if (pid == 0) {
close(pipefd[0]); // enfant n'utilise pas la lecture
write(pipefd[1], "hello", 5);
close(pipefd[1]);
_exit(0);
} else {
close(pipefd[1]); // parent n'utilise pas l'écriture
char buf[32];
ssize_t n = read(pipefd[0], buf, sizeof(buf));
close(pipefd[0]);
wait(NULL);
}
6. fork() peut échouer
fork() peut retourner -1.
Les erreurs courantes :
- EAGAIN : limite de processus atteinte, limite utilisateur, manque temporaire de ressources ;
- ENOMEM : mémoire insuffisante pour créer les structures noyau nécessaires.
Code: Select all
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
7. fork() + exec() : le modèle Unix classique
fork() duplique le processus.
Mais souvent, on ne veut pas un clone qui continue le même programme.
On veut lancer un autre programme.
Pour ça, l'enfant appelle une fonction de la famille exec().
API bas niveau
Code: Select all
#include <unistd.h>
int execve(const char *pathname, char *const argv[], char *const envp[]);
Si execve() réussit, il ne revient jamais.
S'il revient, c'est qu'il y a une erreur.
Exemple fork + execl
Code: Select all
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main(void)
{
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
execl("/bin/ls", "ls", "-l", NULL);
// On arrive ici seulement si execl échoue.
perror("execl");
_exit(127);
}
int status;
waitpid(pid, &status, 0);
return 0;
}
Parce qu'après fork(), l'enfant a copié l'état du parent, y compris les buffers stdio.
Si exec() échoue, on veut terminer l'enfant rapidement sans déclencher certains nettoyages libc du parent.
En pratique, dans un enfant créé juste pour faire exec(), on utilise souvent _exit() en cas d'erreur.
Famille exec
- execl() : arguments passés un par un.
- execv() : arguments passés dans un tableau.
- execlp() : cherche le programme dans le PATH.
- execvp() : version tableau + PATH.
- execle() : permet de fournir un environnement.
- execve() : syscall bas niveau utilisée derrière.
Code: Select all
char *argv[] = { "ls", "-la", NULL };
execvp("ls", argv);
perror("execvp");
_exit(127);
8. Attendre un enfant : wait() et waitpid()
Quand un enfant se termine, le parent doit récupérer son état de terminaison.
Pour cela, il utilise wait() ou waitpid().
Prototypes
Code: Select all
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
waitpid() permet de choisir quel enfant attendre.
Exemple :
Code: Select all
int status;
pid_t ret = waitpid(pid, &status, 0);
if (ret == -1) {
perror("waitpid");
}
Code: Select all
if (WIFEXITED(status)) {
printf("enfant terminé normalement, code=%d\n", WEXITSTATUS(status));
}
if (WIFSIGNALED(status)) {
printf("enfant tué par signal %d\n", WTERMSIG(status));
}
Quand un enfant se termine, le noyau conserve certaines informations pour le parent :
- le PID de l'enfant ;
- le code de retour ;
- la raison de terminaison ;
- quelques statistiques.
Un zombie n'exécute plus de code, mais il occupe encore une entrée dans la table des processus.
Résumé
Code: Select all
enfant terminé + parent n'a pas wait() = zombie
enfant terminé + parent fait wait() = ressources finales libérées
9. Race conditions après fork()
Après fork(), on ne sait pas si le parent ou l'enfant sera exécuté en premier.
C'est le scheduler du noyau qui décide.
Exemple :
Code: Select all
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
printf("enfant\n");
} else {
printf("parent\n");
}
return 0;
}
Code: Select all
parent
enfant
Code: Select all
enfant
parent
Si l'ordre est important, il faut synchroniser.
Méthodes de synchronisation possibles
- wait() : le parent attend la fin de l'enfant ;
- pipe : un processus bloque en lecture jusqu'à ce que l'autre écrive ;
- signaux : un processus prévient l'autre ;
- sémaphores ;
- futex/mutex partagé ;
- mémoire partagée + synchronisation.
10. Synchronisation avec les signaux
Un processus peut utiliser un signal pour prévenir l'autre qu'une action est terminée.
Par exemple, l'enfant fait un travail, puis envoie SIGUSR1 au parent avec kill().
APIs utiles
Code: Select all
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigsuspend(const sigset_t *mask);
int kill(pid_t pid, int sig);
- le parent bloque SIGUSR1 avant fork() ;
- l'enfant hérite du masque de signaux ;
- l'enfant fait son travail ;
- l'enfant envoie SIGUSR1 au parent ;
- le parent attend le signal avec sigsuspend() ;
- le parent reprend après réception du signal.
Code: Select all
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>
#include <sys/wait.h>
static volatile sig_atomic_t got_sigusr1 = 0;
static void handler(int sig)
{
(void)sig;
got_sigusr1 = 1;
}
int main(void)
{
struct sigaction sa = {0};
sigset_t block_mask, old_mask, empty_mask;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGUSR1, &sa, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
sigemptyset(&block_mask);
sigaddset(&block_mask, SIGUSR1);
// Important : on bloque SIGUSR1 avant fork()
if (sigprocmask(SIG_BLOCK, &block_mask, &old_mask) == -1) {
perror("sigprocmask");
exit(EXIT_FAILURE);
}
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
sleep(1);
kill(getppid(), SIGUSR1);
_exit(0);
}
sigemptyset(&empty_mask);
while (!got_sigusr1) {
sigsuspend(&empty_mask);
}
// On restaure le masque original
if (sigprocmask(SIG_SETMASK, &old_mask, NULL) == -1) {
perror("sigprocmask restore");
exit(EXIT_FAILURE);
}
printf("Le parent a reçu SIGUSR1\n");
waitpid(pid, NULL, 0);
return 0;
}
Il ne faut pas faire n'importe quoi dans un handler de signal.
Dans un handler, on évite printf(), malloc(), free(), etc.
On modifie souvent seulement une variable de type volatile sig_atomic_t.
11. Le cas spécial vfork()
Prototype
Code: Select all
#include <unistd.h>
pid_t vfork(void);
Historiquement, vfork() a été créé pour éviter le coût de copie de fork() quand l'enfant fait immédiatement exec().
Avec vfork() :
- l'enfant partage temporairement l'espace mémoire du parent ;
- le parent est suspendu jusqu'à ce que l'enfant fasse exec() ou _exit() ;
- l'enfant ne doit pas modifier les variables du parent ;
- l'enfant ne doit pas retourner de la fonction appelante ;
- l'enfant ne doit pas appeler exit() ;
- l'enfant doit rapidement appeler exec() ou _exit().
Code: Select all
pid_t pid = vfork();
if (pid == 0) {
// Dangereux : modifier des variables locales ou globales peut corrompre le parent.
exit(0); // mauvais avec vfork()
}
Code: Select all
pid_t pid = vfork();
if (pid == 0) {
execl("/bin/ls", "ls", NULL);
_exit(127);
}
Dans beaucoup de programmes modernes, on évite vfork() sauf besoin précis.
12. Terminaison de processus
Un processus peut se terminer de deux façons principales :
- terminaison normale : return depuis main(), exit(), _exit() ;
- terminaison anormale : signal fatal, crash, segmentation fault, kill, etc.
Code: Select all
return 0;
exit(0);
_exit(0);
Code: Select all
SIGSEGV // segmentation fault
SIGABRT // abort()
SIGKILL // kill -9
SIGTERM // demande de terminaison
13. _exit() : terminaison directe côté noyau
Prototype
Code: Select all
#include <unistd.h>
void _exit(int status);
Elle ne revient jamais.
Le status est transmis au parent, qui pourra le récupérer avec wait() ou waitpid().
En pratique, seuls les 8 bits de poids faible sont généralement utilisés pour le code de retour classique.
Convention :
Code: Select all
0 = succès
!= 0 = erreur
Code: Select all
#include <unistd.h>
int main(void)
{
_exit(0);
}
_exit() ne fait pas le nettoyage de haut niveau de la libc.
Elle ne flush pas les buffers stdio et n'appelle pas les handlers atexit().
14. exit() : terminaison propre côté libc
Prototype
Code: Select all
#include <stdlib.h>
void exit(int status);
Elle termine le programme proprement côté utilisateur, puis finit par appeler une primitive de terminaison côté noyau.
Quand on appelle exit(), plusieurs choses se produisent :
- les fonctions enregistrées avec atexit() sont appelées ;
- les buffers stdio sont vidés ;
- les streams stdio sont fermés ;
- le processus est ensuite terminé.
Code: Select all
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
printf("Fin du programme\n");
exit(0);
}
Code: Select all
int main(void)
{
return 0; // équivalent pratique à exit(0)
}
15. Différence entre exit() et _exit()
Tableau mental
Code: Select all
exit() : nettoyage libc + flush stdio + handlers atexit + terminaison
_exit() : terminaison directe, sans nettoyage stdio, sans handlers atexit
- Programme normal : return ou exit().
- Enfant après fork() qui doit mourir sans exec() : souvent _exit().
- Enfant après échec de exec() : _exit(127).
- Programme qui doit vider ses fichiers stdio : exit() ou fflush() avant _exit().
- Code bas niveau où il faut éviter les handlers libc : _exit().
Code: Select all
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("hello");
_exit(0);
}
Avec exit() :
Code: Select all
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
printf("hello");
exit(0);
}
16. Handlers de sortie avec atexit()
Une application peut enregistrer des fonctions à exécuter automatiquement lors de la terminaison normale du programme.
Prototype
Code: Select all
#include <stdlib.h>
int atexit(void (*function)(void));
Exemple
Code: Select all
#include <stdio.h>
#include <stdlib.h>
static void cleanup1(void)
{
printf("cleanup1\n");
}
static void cleanup2(void)
{
printf("cleanup2\n");
}
int main(void)
{
atexit(cleanup1);
atexit(cleanup2);
printf("main terminé\n");
return 0;
}
Code: Select all
main terminé
cleanup2
cleanup1
Attention
Les handlers atexit() sont appelés avec exit() ou return depuis main().
Ils ne sont pas appelés avec _exit().
Code: Select all
exit(0); // appelle les handlers atexit()
_exit(0); // n'appelle pas les handlers atexit()
17. on_exit() : extension GNU
Sur certains systèmes, notamment GNU/Linux, il existe aussi on_exit().
C'est une extension non standard POSIX.
Prototype
Code: Select all
#define _GNU_SOURCE
#include <stdlib.h>
int on_exit(void (*function)(int, void *), void *arg);
- le status de sortie ;
- un argument utilisateur.
Code: Select all
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
static void cleanup(int status, void *arg)
{
printf("status=%d arg=%s\n", status, (char *)arg);
}
int main(void)
{
on_exit(cleanup, "test");
exit(42);
}
18. Ce que le noyau nettoie quand un processus meurt
Quand un processus termine, le noyau récupère beaucoup de ressources.
Par exemple :
- les descripteurs de fichiers ouverts sont fermés ;
- les streams et ressources noyau associées aux fichiers sont libérés quand plus personne ne les référence ;
- les mappings mmap() sont retirés ;
- la mémoire virtuelle du processus est libérée ;
- les segments de mémoire partagée attachés sont détachés ;
- certains verrous et ressources IPC sont relâchés selon leur type ;
- le parent reçoit SIGCHLD quand un enfant termine ;
- l'état de terminaison est gardé jusqu'à wait()/waitpid().
Le noyau nettoie les ressources noyau du processus.
Mais il ne va pas forcément faire le nettoyage logique de ton application.
Exemples :
- supprimer un fichier temporaire ;
- écrire une dernière ligne de log via stdio ;
- libérer proprement une ressource applicative distante ;
- envoyer un message propre à un autre processus ;
- flush une structure de haut niveau.
19. SIGCHLD, zombies et orphelins
Quand un enfant meurt, le parent reçoit normalement SIGCHLD.
Ce signal indique au parent qu'un enfant a changé d'état, souvent parce qu'il s'est terminé.
Le parent doit ensuite faire wait() ou waitpid() pour récupérer le status.
Zombie
Un zombie est un processus déjà mort, mais dont le parent n'a pas encore récupéré le status.
Code: Select all
enfant mort + parent n'a pas wait() = zombie
Un processus orphelin est un processus dont le parent est mort avant lui.
Dans ce cas, il est adopté par un processus système comme init/systemd, qui se chargera ensuite de le récolter.
Exemple simple avec SIGCHLD
Code: Select all
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <errno.h>
static volatile sig_atomic_t child_dead = 0;
static void sigchld_handler(int sig)
{
(void)sig;
child_dead = 1;
}
int main(void)
{
struct sigaction sa = {0};
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
sleep(1);
_exit(42);
}
while (!child_dead) {
pause();
}
int status;
if (waitpid(pid, &status, 0) == -1) {
perror("waitpid");
exit(EXIT_FAILURE);
}
if (WIFEXITED(status)) {
printf("code enfant=%d\n", WEXITSTATUS(status));
}
return 0;
}
Dans un vrai programme avec plusieurs enfants, un seul SIGCHLD peut correspondre à plusieurs enfants morts.
Il faut souvent utiliser waitpid(-1, &status, WNOHANG) dans une boucle.
Exemple :
Code: Select all
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
// traiter chaque enfant terminé
}
20. Le piège fork() + stdio buffers + exit()
Ce point est très important.
Les fonctions comme printf(), fprintf(), puts() utilisent des buffers en espace utilisateur.
Ces buffers appartiennent à la libc.
Quand on fait fork(), ces buffers sont copiés dans l'enfant.
Donc si un buffer contient déjà du texte non flushé, le parent et l'enfant peuvent tous les deux le vider ensuite.
Exemple piège
Code: Select all
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("hello ");
fork();
exit(0);
}
Le parent et l'enfant appellent exit(), donc les deux flushent leur copie.
Résultat possible :
Code: Select all
hello hello
Parce que le buffering dépend de la destination :
- stdout vers un terminal : souvent line-buffered ;
- stdout vers un fichier : souvent fully-buffered ;
- stderr : souvent non bufferisé.
Code: Select all
./programme
Code: Select all
./programme > out.txt
- mettre un \n si stdout est line-buffered vers un terminal ;
- appeler fflush(stdout) avant fork() ;
- utiliser _exit() dans l'enfant si l'enfant ne doit pas flush les buffers hérités ;
- éviter de mélanger fork(), stdio et exit() sans réfléchir.
Code: Select all
printf("hello ");
fflush(stdout);
pid_t pid = fork();
if (pid == 0) {
_exit(0);
}
waitpid(pid, NULL, 0);
21. Exemple complet : mini shell fork + exec + wait
Voici un exemple très simple qui lance une commande externe.
Code: Select all
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
int main(void)
{
char *argv[] = { "ls", "-la", NULL };
pid_t pid = fork();
if (pid == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
execvp(argv[0], argv);
perror("execvp");
_exit(127);
}
int status;
if (waitpid(pid, &status, 0) == -1) {
perror("waitpid");
exit(EXIT_FAILURE);
}
if (WIFEXITED(status)) {
printf("commande terminée avec code %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("commande tuée par signal %d\n", WTERMSIG(status));
}
return 0;
}
- le parent crée un enfant avec fork() ;
- l'enfant remplace son programme par ls avec execvp() ;
- si execvp() échoue, l'enfant termine avec _exit(127) ;
- le parent attend la fin de l'enfant avec waitpid() ;
- le parent analyse le status de sortie.
22. Comparaison rapide avec Windows
Sous Windows, la création d'un nouveau programme passe souvent par :
Code: Select all
CreateProcess()
Sous Linux/Unix, le modèle classique est :
Code: Select all
fork();
exec();
wait();
Code: Select all
Windows : CreateProcess() = créer + charger un programme
Linux : fork() + exec() = dupliquer puis remplacer
Il permet au parent de préparer l'environnement de l'enfant avant exec().
Exemples :
- rediriger stdout vers un fichier ;
- connecter stdin/stdout à un pipe ;
- changer le répertoire courant ;
- modifier les variables d'environnement ;
- fermer certains descripteurs ;
- préparer une sandbox simple ;
- changer les droits ou l'utilisateur selon le contexte.
23. Bonnes pratiques
Avec fork()
- Toujours tester pid == -1.
- Séparer clairement le code parent et le code enfant.
- Ne jamais supposer que le parent passe avant l'enfant.
- Fermer les descripteurs inutiles dans chaque processus.
- Utiliser wait()/waitpid() pour éviter les zombies.
- Utiliser _exit() dans l'enfant si l'enfant termine sans exec().
- Utiliser fflush() avant fork() si des buffers stdio sont déjà remplis.
- Se rappeler que exec() ne revient pas en cas de succès.
- Mettre le code d'erreur juste après exec().
- Utiliser _exit(127) après un échec exec() dans l'enfant.
- Utiliser execvp() si on veut chercher dans le PATH.
- Utiliser execve() si on veut contrôler précisément argv et envp.
- Utiliser return depuis main() ou exit() pour une terminaison normale.
- Utiliser atexit() pour les nettoyages applicatifs simples.
- Ne pas confondre nettoyage libc et nettoyage noyau.
- Comprendre que exit() flush les buffers stdio.
- Utiliser _exit() pour quitter immédiatement.
- Utiliser _exit() dans l'enfant après fork() si exec() échoue.
- Ne pas attendre de flush stdio avec _exit().
- Ne pas attendre d'appel des handlers atexit().
24. Erreurs classiques à éviter
Erreur 1 : oublier que fork() retourne deux fois
Code: Select all
fork();
printf("hello\n");
Erreur 2 : ne pas tester l'erreur de fork()
Code: Select all
pid_t pid = fork();
if (pid == 0) {
// enfant
}
Erreur 3 : utiliser exit() dans l'enfant après fork() sans réfléchir
Cela peut flush des buffers hérités du parent ou appeler des handlers atexit() enregistrés avant fork().
Erreur 4 : oublier wait()
Cela peut créer des zombies.
Erreur 5 : croire que la mémoire est partagée
Après fork(), modifier une variable dans l'enfant ne modifie pas celle du parent.
Pour partager réellement de la mémoire, il faut utiliser mmap() partagé, shm_open(), System V shared memory, etc.
Erreur 6 : croire que les fichiers ne sont pas liés
Les descripteurs sont copiés, mais ils peuvent pointer vers la même open file description.
Donc l'offset peut être partagé.
Erreur 7 : dépendre de l'ordre d'exécution
Sans synchronisation, l'ordre parent/enfant n'est pas garanti.
25. Mini schéma global
Code: Select all
Parent
|
| fork()
|
+------------------+
| |
Parent Enfant
| |
| waitpid() | execvp("ls", ...)
| | si succès : nouveau programme
| | si erreur : _exit(127)
|
| récupère status
|
fin
Code: Select all
création -> exécution -> terminaison -> zombie temporaire -> wait -> disparition finale
26. Mini défis pour s'entraîner
- Écrire un programme qui fait fork() trois fois et compter combien de processus existent à la fin.
- Créer un enfant qui modifie une variable, puis vérifier que le parent garde l'ancienne valeur.
- Créer un fichier avant fork(), puis faire écrire parent et enfant dedans.
- Créer un enfant qui exécute /bin/ls avec execvp().
- Faire un parent qui attend l'enfant avec waitpid() et affiche son code de retour.
- Tester printf("hello") avant fork(), puis rediriger stdout vers un fichier pour observer le piège des buffers.
- Remplacer exit() par _exit() dans l'enfant et comparer.
- Faire une synchronisation simple parent/enfant avec un pipe ou un signal.
27. Résumé final
Process Creation
- fork() crée un enfant presque identique au parent.
- Le parent reçoit le PID de l'enfant.
- L'enfant reçoit 0.
- En cas d'erreur, fork() retourne -1.
- La mémoire est séparée logiquement grâce au copy-on-write.
- Les descripteurs sont copiés mais peuvent partager la même open file description.
- L'ordre d'exécution parent/enfant n'est pas garanti.
- fork() est souvent suivi de exec().
- Le parent doit souvent utiliser wait()/waitpid().
- exit() termine proprement côté libc.
- _exit() termine directement côté noyau.
- return depuis main() est équivalent en pratique à exit(status).
- atexit() permet d'enregistrer des handlers de fin.
- exit() flush les buffers stdio.
- _exit() ne flush pas les buffers stdio.
- Après fork(), les buffers stdio sont copiés.
- Un enfant terminé devient zombie jusqu'à wait().
- SIGCHLD prévient le parent qu'un enfant a changé d'état.
Code: Select all
Sous Linux, fork() duplique le processus, exec() remplace le programme,
wait() récupère la mort de l'enfant, et exit()/_exit() terminent le processus.
