Cours Linux bas niveau — Création et terminaison de processus

Ce forum est dédié à apprendre le développement de programmes user mode sur Linux

Moderator: Rick

Post Reply
Hydraxx
Site Admin
Posts: 46
Joined: Mon Jan 12, 2026 4:04 pm
Location: France
Contact:

Cours Linux bas niveau — Création et terminaison de processus

Post by Hydraxx »

Cours Linux bas niveau — Création et terminaison de processus

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.
Résumé mental très simple

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.
Dans Linux, beaucoup de choses sont représentées comme des fichiers ou des descripteurs : fichiers classiques, sockets, pipes, terminaux, etc.
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.
Ces deux éléments sont essentiels pour comprendre fork().


2. Création de processus avec fork()

Prototype

Code: Select all

#include <unistd.h>

pid_t fork(void);
fork() crée un nouveau processus appelé processus enfant.
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
Exemple minimal

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;
}
À retenir

Code: Select all

if (pid == -1)  -> erreur
if (pid == 0)   -> code de l'enfant
if (pid > 0)    -> code du parent
Ce modèle est tellement important qu'il faut le connaître par coeur.


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.
Mais attention : copier ne veut pas dire que tout est partagé de la même manière.

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;
}
Résultat typique :

Code: Select all

enfant : global=111 local=222
parent : global=10 local=20
L'enfant a modifié ses propres copies. Le parent n'est pas affecté.

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.
Donc mentalement

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.
C'est pour ça que fork() est performant, surtout quand il est suivi immédiatement d'un exec().


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.
Exemple avec offset partagé

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;
}
Le parent et l'enfant écrivent dans le même fichier et partagent l'avancement de l'offset.
L'ordre exact dépend du scheduling.

Résultat possible :

Code: Select all

PARENT
ENFANT
ou :

Code: Select all

ENFANT
PARENT
À retenir

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
C'est pour ça qu'après un fork(), on ferme souvent les descripteurs inutiles.

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.
Il faut toujours tester le retour de fork().

Code: Select all

pid_t pid = fork();

if (pid == -1) {
    perror("fork");
    exit(EXIT_FAILURE);
}
Ne jamais écrire du code sérieux en supposant que fork() réussit toujours.


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[]);
execve() remplace l'image mémoire du processus courant par un nouveau programme.
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;
}
Pourquoi _exit(127) dans l'enfant ?

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.
Exemple avec execvp() :

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);
wait() attend n'importe quel enfant.
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");
}
Analyser le status

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));
}
Pourquoi wait() est important ?

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.
Tant que le parent n'appelle pas wait(), l'enfant terminé reste sous forme de zombie.

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;
}
Sortie possible :

Code: Select all

parent
enfant
ou :

Code: Select all

enfant
parent
Il ne faut jamais dépendre d'un ordre d'exécution non garanti.
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);
Idée
  • 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.
Exemple simplifié

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;
}
Attention

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);
vfork() ressemble à fork(), mais il a des règles beaucoup plus dangereuses.
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 dangereux

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()
}
Utilisation correcte typique

Code: Select all

pid_t pid = vfork();

if (pid == 0) {
    execl("/bin/ls", "ls", NULL);
    _exit(127);
}
Aujourd'hui, fork() avec copy-on-write est déjà très optimisé.
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.
Exemples de terminaison normale :

Code: Select all

return 0;
exit(0);
_exit(0);
Exemples de terminaison anormale :

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);
_exit() termine directement le processus.
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
Exemple :

Code: Select all

#include <unistd.h>

int main(void)
{
    _exit(0);
}
Important

_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);
exit() est une fonction de la bibliothèque C.
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é.
Exemple

Code: Select all

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

int main(void)
{
    printf("Fin du programme\n");
    exit(0);
}
Dans un programme normal, return depuis main() revient pratiquement à appeler exit().

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
Quand utiliser quoi ?
  • 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().
Piège classique

Code: Select all

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

int main(void)
{
    printf("hello");
    _exit(0);
}
Le texte "hello" peut ne pas s'afficher, car il est peut-être encore dans le buffer stdio.

Avec exit() :

Code: Select all

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

int main(void)
{
    printf("hello");
    exit(0);
}
Le buffer stdio est normalement flush pendant exit().


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));
Si l'enregistrement réussit, atexit() retourne 0.

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;
}
Sortie typique :

Code: Select all

main terminé
cleanup2
cleanup1
Les handlers sont appelés en ordre inverse d'enregistrement.

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);
La fonction reçoit :
  • le status de sortie ;
  • un argument utilisateur.
Exemple :

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);
}
Pour du code portable, préférer atexit().


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().
Mais attention

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.
Pour ça, il faut utiliser une logique applicative : handlers, protocoles de fermeture, signaux, ou nettoyage explicite.


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
Orphelin

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;
}
Attention

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);
}
Si "hello " n'a pas encore été flush, alors le buffer est dupliqué.
Le parent et l'enfant appellent exit(), donc les deux flushent leur copie.

Résultat possible :

Code: Select all

hello hello
Pourquoi parfois le problème n'apparaît pas ?

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é.
Donc le comportement peut changer selon que tu lances :

Code: Select all

./programme
ou :

Code: Select all

./programme > out.txt
Solutions
  • 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.
Exemple propre :

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;
}
Ce que fait ce code
  • 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.
C'est le modèle de base utilisé par les shells.


22. Comparaison rapide avec Windows

Sous Windows, la création d'un nouveau programme passe souvent par :

Code: Select all

CreateProcess()
CreateProcess() crée directement un nouveau processus avec un nouveau programme.

Sous Linux/Unix, le modèle classique est :

Code: Select all

fork();
exec();
wait();
Comparaison mentale

Code: Select all

Windows : CreateProcess() = créer + charger un programme
Linux   : fork() + exec()  = dupliquer puis remplacer
Ce modèle Unix paraît bizarre au début, mais il est très puissant.
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.
Avec exec()
  • 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.
Avec exit()
  • 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.
Avec _exit()
  • 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");
Le printf est exécuté par le parent et par l'enfant.

Erreur 2 : ne pas tester l'erreur de fork()

Code: Select all

pid_t pid = fork();
if (pid == 0) {
    // enfant
}
Mauvais si fork() retourne -1.

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
Cycle de vie typique

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().
Process Termination
  • 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.
Phrase à retenir

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.
Fin du cours.

Who is online

Users browsing this forum: No registered users and 1 guest