fork en C : wait, waitpid, exec, pipe et dup2

fork duplique ton processus, waitpid lit son code de sortie, exec le remplace, pipe et dup2 les relient. Tout est exécuté en vrai, zombie compris.

Par
Publié le

Tu appelles fork() une seule fois et ton programme affiche deux fois la même ligne, dans un ordre qui change d'un lancement à l'autre. Ou bien ton pipe entre deux commandes ne rend jamais la main. Les processus en C tiennent en cinq appels (fork, waitpid, exec, pipe et dup2) qui se combinent toujours de la même façon, et chacun cache un piège qu'on voit très bien dès qu'on lance le programme.

En bref

fork() crée une copie du processus qui l'appelle : les deux continuent après l'appel, et la valeur de retour dit lequel est lequel (0 dans l'enfant, le PID de l'enfant dans le parent, -1 en cas d'échec). Le parent récupère la fin de l'enfant et son code de sortie avec wait ou waitpid, sinon l'enfant reste à l'état de zombie. execvp remplace le programme du processus par un autre, et pipe avec dup2 branchent la sortie d'un processus sur l'entrée d'un autre, comme ls | wc -l dans le shell.

Que fait fork en C ?

fork(2) duplique le processus qui l'appelle. Au retour de l'appel, deux processus exécutent le même programme, à la même ligne, avec chacun sa copie de la mémoire (variables, tas, tampons de printf) et des descripteurs de fichiers ouverts. Dans le code, ce qui les distingue est la valeur que fork leur a renvoyée.

fork.c
#include <stdio.h>#include <unistd.h>
int main(void){    printf("avant fork : PID %d\n", getpid());    fflush(stdout);
    pid_t pid = fork();
    if (pid == -1) {        perror("fork");        return 1;    }    if (pid == 0)        printf("enfant : PID %d, parent %d\n", getpid(), getppid());    else        printf("parent : PID %d, enfant %d\n", getpid(), pid);    return 0;}
Terminal
$ gcc -Wall -Wextra fork.c -o fork$ ./forkavant fork : PID 14parent : PID 14, enfant 15enfant : PID 15, parent 14$ ./forkavant fork : PID 17parent : PID 17, enfant 18enfant : PID 18, parent 1

avant fork n'apparaît qu'une fois : cette ligne s'exécute avant la duplication. Les deux suivantes viennent chacune d'un processus. Regarde le deuxième lancement : l'enfant annonce un parent numéro 1. Le parent avait déjà terminé quand l'enfant a appelé getppid(), et un processus orphelin est adopté par le processus 1 (ici le shell du conteneur ; sur un système classique, init ou un ancêtre déclaré « subreaper »).

Qui s'exécute en premier, le parent ou l'enfant ?

Rien ne le garantit. La page de manuel de fork ne promet aucun ordre, c'est l'ordonnanceur du système qui décide. Ce petit programme ne fait qu'afficher qui il est :

ordre.c
#include <stdio.h>#include <unistd.h>
int main(void){    if (fork() == 0)        printf("enfant\n");    else        printf("parent\n");    return 0;}

On le lance mille fois et on compte quelle ligne arrive en premier :

Terminal
$ gcc -Wall -Wextra ordre.c -o ordre$ for i in $(seq 1000); do ./ordre | head -n 1; done | sort | uniq -c      1 enfant    999 parent

Le parent gagne presque toujours sur cette machine, et une autre série de mille lancements a donné 983 contre 17. « Presque toujours » est exactement le genre de résultat qui fait passer tes tests et casse le programme le jour où la machine est chargée. Si l'ordre compte, synchronise les processus : le parent attend l'enfant avec waitpid, ou les deux se parlent par un pipe.

Pourquoi printf s'affiche deux fois après fork ?

Parce que fork copie aussi le tampon de printf. Ce programme n'a qu'un seul printf, appelé avant le fork :

double.c
#include <stdio.h>#include <unistd.h>
int main(void){    printf("Bonjour\n");    fork();    return 0;}
Terminal
$ gcc -Wall -Wextra double.c -o double$ ./doubleBonjour$ ./double | catBonjourBonjour

Dans le terminal, une seule ligne. Dans un pipe, deux. Quand la sortie standard est un terminal, elle est vidée à chaque \n : « Bonjour » est déjà parti quand fork s'exécute. Quand elle va dans un pipe ou un fichier, elle est mise en tampon par blocs : le texte attend dans la mémoire du processus, fork duplique cette mémoire, et chacun des deux processus vide sa copie en se terminant.

La correction tient en une ligne : fflush(stdout); juste avant fork(), comme dans fork.c plus haut. Ce mécanisme de tampon explique aussi pourquoi un printf disparaît avant un crash, on le détaille dans printf n'affiche rien.

Attendre l'enfant avec wait et waitpid

waitpid(2) bloque le parent jusqu'à la fin de l'enfant demandé et range dans un int la façon dont il s'est terminé. Cet entier est codé : on le lit avec des macros de <sys/wait.h>, jamais directement.

status.c
#include <stdio.h>#include <stdlib.h>#include <sys/wait.h>#include <unistd.h>
int main(void){    pid_t pid = fork();
    if (pid == -1) {        perror("fork");        return 1;    }    if (pid == 0) {        printf("enfant : je termine avec le code 3\n");        exit(3);    }
    int status;
    if (waitpid(pid, &status, 0) == -1) {        perror("waitpid");        return 1;    }    if (WIFEXITED(status))        printf("parent : enfant %d terminé, code %d\n", pid, WEXITSTATUS(status));    else if (WIFSIGNALED(status))        printf("parent : enfant %d tué par le signal %d\n", pid, WTERMSIG(status));    return 0;}
Terminal
$ gcc -Wall -Wextra status.c -o status$ ./statusenfant : je termine avec le code 3parent : enfant 3051 terminé, code 3

Les quatre macros à connaître :

MacroCe qu'elle donne
WIFEXITED(status)vrai si l'enfant a fini normalement, par exit ou un return dans main
WEXITSTATUS(status)le code de sortie, réduit à ses 8 bits de poids faible ; à lire seulement si WIFEXITED est vrai
WIFSIGNALED(status)vrai si un signal a tué l'enfant
WTERMSIG(status)le numéro de ce signal, 11 pour un SIGSEGV ; seulement si WIFSIGNALED est vrai

wait(&status) revient à waitpid(-1, &status, 0) : attendre n'importe lequel des enfants. waitpid permet de viser un PID précis, ou de ne pas bloquer du tout avec l'option WNOHANG. Le shell fait la même chose après chaque commande : c'est ce code qu'affiche echo $?, et un programme tué par un signal y apparaît comme 128 plus le numéro du signal (le 139 d'un segmentation fault).

C

exit-status

L'autre côté du statut de sortie : un programme qui additionne ses arguments et ne dit rien sur sa sortie standard quand il échoue. Le code de retour porte toute l'information, 1 sans argument, 2 pour un argument qui n'est pas un entier, 3 pour un débordement, et seul write est autorisé.

Ouvrir l'exercice

Qu'est-ce qu'un processus zombie ?

Un enfant terminé que son parent n'a pas encore attendu. Le noyau garde de lui le strict minimum (son PID, son statut de fin, son usage des ressources) pour que le parent puisse appeler wait plus tard. Ce programme crée un enfant qui se termine tout de suite, pendant que le parent dort vingt secondes avant de l'attendre :

zombie.c
#include <stdio.h>#include <sys/wait.h>#include <unistd.h>
int main(void){    pid_t pid = fork();
    if (pid == 0)        return 0;    printf("enfant %d terminé, le parent dort 20 s sans appeler wait\n", pid);    fflush(stdout);    sleep(20);    waitpid(pid, NULL, 0);    return 0;}
Terminal
$ gcc -Wall -Wextra zombie.c -o zombie$ ./zombie &[1] 14enfant 15 terminé, le parent dort 20 s sans appeler wait$ ps -o pid,ppid,stat,cmd  PID  PPID STAT CMD    1     0 Ss   bash --norc --noprofile -i   14     1 S    ./zombie   15    14 Z    [zombie] <defunct>   17     1 R+   ps -o pid,ppid,stat,cmd$ kill -9 15$ ps -o pid,ppid,stat,cmd  PID  PPID STAT CMD    1     0 Ss   bash --norc --noprofile -i   14     1 S    ./zombie   15    14 Z    [zombie] <defunct>   20     1 R+   ps -o pid,ppid,stat,cmd$ wait[1]+  Done                    ./zombie$ ps -o pid,ppid,stat,cmd  PID  PPID STAT CMD    1     0 Ss   bash --norc --noprofile -i   23     1 R+   ps -o pid,ppid,stat,cmd

Le processus 15 a l'état Z et la mention <defunct> : il ne tourne plus, sa mémoire a été rendue, mais il garde sa place dans la table des processus. Même kill -9 ne l'en déloge pas, puisqu'il n'y a plus rien à tuer. Il disparaît dès que le parent appelle waitpid, ici à la fin de sa sieste. Un zombie isolé ne coûte presque rien. Un serveur qui crée un enfant par requête sans jamais les attendre finit, lui, par remplir la table des processus, et plus aucun fork ne passe.

Pour se débarrasser d'un zombie, il faut donc que son parent l'attende, ou que le parent se termine : les zombies sont alors adoptés par init, qui les attend automatiquement.

exec : remplacer le programme du processus

fork copie le programme courant ; pour en lancer un autre, l'enfant appelle une fonction de la famille exec. execvp(3) cherche la commande dans les dossiers du PATH, comme le shell, et prend ses arguments dans un tableau terminé par NULL dont la première case est, par convention, le nom de la commande.

exec.c
#include <stdio.h>#include <unistd.h>
int main(void){    char *args[] = {"ps", "-o", "pid,cmd", NULL};
    printf("PID %d, je lance ps\n", getpid());    fflush(stdout);    execvp("ps", args);    perror("execvp");    return 1;}
Terminal
$ gcc -Wall -Wextra exec.c -o exec$ ./execPID 3059, je lance ps  PID CMD    1 bash --norc --noprofile -i 3059 ps -o pid,cmd

Le programme annonce le PID 3059, puis ps liste un processus 3059 qui s'appelle maintenant ps. exec ne crée pas de processus : il écrase le code, les données et la pile du processus courant avec ceux du nouveau programme, en gardant le même PID. Les lignes après execvp ne s'exécutent que si l'appel a échoué, d'où le perror juste derrière, sans test.

Tout ne disparaît pas dans le remplacement. Les descripteurs de fichiers ouverts restent ouverts, sauf ceux marqués « close-on-exec » : c'est ce qui permet de rediriger la sortie d'un programme avant de le lancer. Le tampon de printf, lui, disparaît avec le reste de la mémoire. Sans le fflush de la ligne 9, quand la sortie va dans un pipe, la ligne « PID ..., je lance ps » est perdue.

execve : No such file or directory

execve, l'appel système sous toutes les fonctions exec, ne cherche rien dans le PATH. Lui passer "ls", c'est lui demander le fichier ls du dossier courant :

execve.cÀ éviter
#include <stdio.h>#include <unistd.h>
extern char **environ;
int main(void){    char *args[] = {"ls", NULL};
    execve("ls", args, environ);    perror("execve");    return 127;}
Terminal
$ gcc -Wall -Wextra execve.c -o execve$ ./execveexecve: No such file or directory

Donne le chemin complet (/bin/ls) ou utilise execvp, qui fait la recherche dans le PATH à ta place.

Le même message arrive quand le fichier existe bel et bien. La page de manuel d'execve le dit : l'erreur ENOENT signale que le fichier, ou l'interpréteur d'un script, ou celui d'un exécutable ELF, est introuvable. Un cas classique : un script enregistré avec des fins de ligne Windows, \r\n. Ici, script.c fait la même chose qu'execve.c mais appelle execv("./salut.sh", args) sur un script de deux lignes qui existe bien :

Terminal
$ ./scriptexecv ./salut.sh: No such file or directory$ ./salut.shbash: ./salut.sh: cannot execute: required file not found$ head -n 1 salut.sh | od -c0000000   #   !   /   b   i   n   /   s   h  \r  \n0000013

La première ligne demande l'interpréteur /bin/sh\r, avec un retour chariot collé au nom, et ce fichier-là n'existe pas. sed -i 's/\r$//' salut.sh enlève les \r et le script se lance.

Reproduire ls | wc -l avec pipe et dup2

pipe(2) crée un tuyau à sens unique dans le noyau et renvoie deux descripteurs : fd[0] pour lire, fd[1] pour écrire. Créé avant fork, il est hérité par les enfants, qui peuvent alors se parler. Il reste à faire croire à ls que sa sortie standard est ce tuyau : c'est le rôle de dup2(2).

  1. lsenfant 1, stdout devient fd[1]
  2. pipetampon dans le noyau
  3. wc -lenfant 2, stdin devient fd[0]
  4. terminalstdout hérité du parent
Ce que fait ./pipe, comme ls | wc -l
pipe.c
#include <stdio.h>#include <stdlib.h>#include <sys/wait.h>#include <unistd.h>
int main(void){    char *ls_args[] = {"ls", NULL};    char *wc_args[] = {"wc", "-l", NULL};    int fd[2];
    if (pipe(fd) == -1) {        perror("pipe");        return 1;    }
    pid_t ls = fork();    if (ls == 0) {        dup2(fd[1], STDOUT_FILENO);        close(fd[0]);        close(fd[1]);        execvp("ls", ls_args);        perror("ls");        exit(127);    }
    pid_t wc = fork();    if (wc == 0) {        dup2(fd[0], STDIN_FILENO);        close(fd[0]);        close(fd[1]);        execvp("wc", wc_args);        perror("wc");        exit(127);    }
    close(fd[0]);    close(fd[1]);    waitpid(ls, NULL, 0);    waitpid(wc, NULL, 0);    return 0;}
Terminal
$ gcc -Wall -Wextra pipe.c -o pipe$ ls | wc -l20$ ./pipe20

dup2(fd[1], STDOUT_FILENO) fait du descripteur 1, la sortie standard, une copie de l'extrémité d'écriture du tuyau. ls écrit sur sa sortie standard comme d'habitude, sans savoir qu'elle mène à un autre processus. Une fois la copie faite, les descripteurs d'origine ne servent plus : chaque enfant les ferme avant son execvp, et le parent ferme les siens avant d'attendre.

Le pipe qui ne se termine jamais

Retire les deux close du parent (lignes 37 et 38) et relance :

Terminal
$ gcc -Wall -Wextra oubli.c -o oubli$ timeout 5 ./oubli$ echo $?124

Rien ne s'affiche, et timeout a dû tuer le programme au bout de cinq secondes (124 est son code pour « temps écoulé »). wc lit jusqu'à la fin du flux, et un tuyau n'annonce la fin du flux que quand plus aucun processus ne tient son extrémité d'écriture ouverte. ls a fini et fermé la sienne, mais le parent garde toujours fd[1]. wc attend donc des données qui ne viendront jamais, et le parent attend wc : les deux sont bloqués pour toujours. La règle, que la page pipe(7) répète : chaque processus ferme les extrémités dont il ne se sert pas, le plus tôt possible.

C

pipe_chain

Généralise pipe.c à un nombre quelconque de commandes. pipe_chain reçoit un tableau de commandes déjà découpées, prêtes pour execvp, relie la sortie de chacune à l'entrée de la suivante, attend tous les enfants et ferme chaque descripteur dès qu'il ne sert plus. Seuls pipe, fork, dup2, execvp, close, wait et exit sont autorisés.

Ouvrir l'exercice
Processes and signals
4 exercices

Questions fréquentes

Combien de processus crée fork(); fork(); ?

Quatre processus en tout, celui de départ compris. Le premier fork donne deux processus, et chacun des deux exécute le second fork. Avec n appels à la suite, tu obtiens 2 puissance n processus, d'où les « fork bombs » quand l'appel est dans une boucle sans fin.

Le parent et l'enfant partagent-ils leurs variables ?

Non. L'enfant reçoit une copie de la mémoire au moment du fork : une variable modifiée dans l'enfant garde son ancienne valeur dans le parent. Pour échanger des données, il faut un canal explicite, comme un pipe. Les descripteurs de fichiers, eux, désignent les mêmes fichiers ouverts, avec la même position de lecture.

Quelle différence entre wait et waitpid ?

wait attend la fin de n'importe quel enfant. waitpid attend un enfant précis (ou n'importe lequel avec -1) et accepte des options, comme WNOHANG pour vérifier sans bloquer. wait(&status) équivaut exactement à waitpid(-1, &status, 0).

Comment supprimer un processus zombie ?

Pas avec kill : il est déjà terminé. Il disparaît quand son parent appelle wait ou waitpid, ou quand le parent se termine, puisque init l'adopte alors et l'attend. Si des zombies s'accumulent, c'est le code du parent qu'il faut corriger.

Pourquoi execvp plutôt qu'execve ?

execvp cherche la commande dans le PATH et reprend l'environnement courant, comme le shell. execve veut un chemin exact et un environnement explicite. Pour lancer une commande système par son nom, execvp évite le No such file or directory de execve("ls", ...).

Sources

À toi d'écrire le code.