Version sans JavaScript, en lecture seule : les ateliers, les quiz et l’exécution du code n’y sont pas. Version complète · Catalogue · Se connecter
Support de cours · chapitre 6
Processus
Lancer des programmes depuis son code, comme le fait un shell - fork pour dupliquer un processus, wait pour attendre sa fin, exec pour lui faire exécuter un autre programme. Orphelins et zombies, redirections et tubes entre processus, et les signaux.
Programme et processus
Ce chapitre montre comment un programme en lance d'autres. C'est exactement ce que fait un shell chaque fois que vous tapez une commande, et on finira par en écrire un petit.
Le programme est unique et passif : c'est un fichier sur le disque. Le processus est actif, et un même programme peut donner plusieurs processus, même en même temps : si trois personnes lancent vim sur la même machine, il y a un seul programme vim et trois processus.
Identifiants
Chaque processus a un numéro, son pid (process identifier), unique parmi les processus vivants. Il connaît aussi le pid de son père, le processus qui l'a créé : le ppid.
Il a aussi un propriétaire, un utilisateur et un groupe, qui déterminent ses droits : quels fichiers il peut lire, à quels processus il peut envoyer un signal. Il y en a deux jeux :
- l'identité réelle (
uid,gid) : l'utilisateur qui a lancé le processus ; - l'identité effective (
euid,egid) : celle dont le système vérifie les droits.
D'habitude, les deux sont les mêmes. Elles diffèrent quand l'exécutable porte le bit setuid : le processus prend alors pour identité effective celle du propriétaire du fichier, tandis que l'identité réelle reste celle de l'utilisateur qui l'a lancé. C'est ainsi que passwd, qui appartient à root et porte le bit setuid, peut modifier le fichier des mots de passe au nom d'un utilisateur ordinaire. Un tel programme peut basculer d'une identité à l'autre avec seteuid, pour ne garder les droits du propriétaire que le temps nécessaire.
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("pid = %d\n", (int)getpid());
printf("ppid = %d\n", (int)getppid());
printf("uid = %d, euid = %d\n", (int)getuid(), (int)geteuid());
return 0;
}
$ ./ids
pid = 344620
ppid = 344490
uid = 1000, euid = 1000
Les numéros changent à chaque exécution. Le père, ici, est le shell qui a lancé le programme.
Les fonctions de ce chapitre sont celles de la norme POSIX, pas de la norme C. Avec -std=c11, gcc ne les déclare pas toutes : on compile donc les exemples avec gcc -std=c11 -Wall -Wextra -Werror -D_DEFAULT_SOURCE. L'option -D_DEFAULT_SOURCE demande à la bibliothèque de rendre visibles les fonctions POSIX et BSD.
pid_t, le type des pid, est un entier dont la taille dépend du système. Pour l'afficher avec %d, on le convertit en int.
La ligne de commande
main peut recevoir les arguments de la ligne de commande :
#include <stdio.h>
int main(int argc, char *argv[])
{
for (int i = 0; i < argc; i++)
printf("argv[%d] = \"%s\"\n", i, argv[i]);
return 0;
}
$ ./args un "deux mots" 3
argv[0] = "./args"
argv[1] = "un"
argv[2] = "deux mots"
argv[3] = "3"
argc est le nombre d'arguments, argv un tableau de argc chaînes. argv[0] est le nom sous lequel le programme a été lancé, et argv[argc] vaut toujours NULL. C'est le shell qui a découpé la ligne en mots : les guillemets ont groupé deux mots en un seul argument, et ont disparu.
Créer un processus : fork
Dupliquer
Sous Unix, il n'y a qu'une façon de créer un processus : dupliquer celui qui existe. L'appel système fork fait une copie du processus appelant, mémoire et descripteurs compris, et les deux copies reprennent leur exécution au même point : au retour de fork.
#include <unistd.h>
pid_t fork(void);
L'original s'appelle le père, la copie le fils. Ils ont le même code et, au départ, la même mémoire ; ils ne diffèrent que par leur pid, et par ce que fork leur rend :
- dans le père,
forkrend le pid du fils ; - dans le fils,
forkrend0; - en cas d'échec (trop de processus, plus de mémoire),
forkrend-1dans le père, et il n'y a pas de fils.
#include <stdio.h>
#include <unistd.h>
int main(void)
{
pid_t x = fork();
printf("x = %d\n", (int)x);
return 0;
}
$ ./fork1
x = 345272
x = 0
Deux lignes pour un seul printf : il y a désormais deux processus qui l'exécutent. Le premier affichage vient du père, qui a reçu le pid de son fils ; le second, du fils. L'ordre n'est pas garanti : il dépend de l'ordonnanceur du système.
Le schéma père / fils
La valeur de retour permet de donner un travail différent à chacun :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
int n = 10;
pid_t pid = fork();
switch (pid) {
case -1:
perror("fork");
return EXIT_FAILURE;
case 0:
n = n + 1;
printf("fils : n = %d\n", n);
break;
default:
n = n * 2;
printf("père : n = %d, mon fils est %d\n", n, (int)pid);
break;
}
return 0;
}
$ ./fork3
père : n = 20, mon fils est 344627
fils : n = 11
Le fils part avec une copie de n : les deux processus ont chacun leur variable, et ce que l'un y écrit, l'autre ne le voit pas. Deux processus ne partagent pas leur mémoire. Pour communiquer, il leur faut un tube (chapitre 5, et plus bas), un fichier, ou un signal.
Le piège du tampon
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("pid = %d\n", (int)getpid());
pid_t x = fork();
printf("x = %d, pid = %d\n", (int)x, (int)getpid());
return 0;
}
Dans un terminal :
$ ./fork2
pid = 345267
x = 345268, pid = 345267
x = 0, pid = 345268
La première ligne n'apparaît qu'une fois, puisqu'elle est affichée avant le fork. Mais si l'on redirige la sortie vers un fichier :
$ ./fork2 > sortie.txt
$ cat sortie.txt
pid = 344624
x = 344625, pid = 344624
pid = 344624
x = 0, pid = 344625
Elle apparaît deux fois ! printf n'écrit pas directement : il remplit un tampon en mémoire, qui n'est vidé (par un write) que de temps en temps (chapitre 5). Vers un terminal, le tampon est vidé à chaque fin de ligne. Vers un fichier ou un tube, il n'est vidé que lorsqu'il est plein, ou à la fin du programme. Au moment du fork, la première ligne était donc encore dans le tampon, c'est-à-dire dans la mémoire du processus, et fork l'a copiée. Chacun des deux processus l'a écrite en se terminant.
Sans passage à la ligne, le texte reste dans le tampon même vers un terminal : printf("avant "); fork(); printf("après\n"); affiche deux fois avant après, dans tous les cas.
Le cycle de vie des processus
Tout processus a donc un père, sauf le tout premier : init (pid 1), lancé par le noyau au démarrage, dont descendent tous les autres. Sur un Linux actuel, c'est systemd.
Les orphelins
Que devient un processus dont le père meurt avant lui ?
#include <stdio.h>
#include <unistd.h>
int main(void)
{
if (fork() != 0) {
sleep(1); /* le père meurt au bout d'une seconde */
return 0;
}
printf("(1) mon père : %d\n", (int)getppid());
sleep(2); /* le fils lui survit */
printf("(2) mon père : %d\n", (int)getppid());
return 0;
}
$ ./orphelin
(1) mon père : 344633
$ (2) mon père : 2426
Le shell a repris la main dès la mort du père : la seconde ligne arrive après l'invite. Entre-temps, le fils a été adopté. Traditionnellement, c'est init, le processus 1, qui adopte les orphelins, et c'est ce que vous verrez sur un serveur ou dans un conteneur. Sur un poste de travail Linux récent, on obtient autre chose :
$ ps -o pid,ppid,cmd -p 2426
PID PPID CMD
2426 1 /usr/lib/systemd/systemd --user
C'est le gestionnaire de la session de l'utilisateur. Linux permet à un processus de se déclarer « faucheur » (subreaper) : les orphelins de ses descendants lui reviennent, au lieu de remonter jusqu'à init. Dans tous les cas, un processus a toujours un père.
C'est ainsi qu'on détache un programme du terminal pour en faire un démon (daemon), un service qui tourne en arrière-plan : il se duplique, et le père meurt aussitôt.
Les zombies
Quand un processus se termine, il ne disparaît pas tout à fait : le système garde son code de retour (et quelques statistiques), jusqu'à ce que son père vienne le chercher. En attendant, le processus est un zombie : il est mort, il n'occupe plus de mémoire, mais il a toujours une entrée dans la table des processus.
#include <stdio.h>
#include <unistd.h>
int main(void)
{
pid_t pid = fork();
if (pid == 0)
return 0; /* le fils meurt aussitôt */
printf("fils %d\n", (int)pid);
sleep(30); /* le père ne le récupère pas */
return 0;
}
Pendant les trente secondes, ps le montre, avec l'état Z et la mention <defunct> :
$ ./zombie &
fils 344761
$ ps -o pid,ppid,stat,cmd
PID PPID STAT CMD
344761 344759 Z [zombie] <defunct>
Un zombie ne coûte presque rien, mais la table des processus a une taille limitée. Un serveur qui lance des milliers de fils sans jamais les récupérer finit par ne plus pouvoir en créer : fork échoue. Quand le père meurt, ses zombies sont adoptés, et leur père adoptif les récupère aussitôt.
Attendre un fils : wait et waitpid
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
wait bloque le processus jusqu'à ce que l'un de ses fils se termine (ou rend la main aussitôt si un fils est déjà zombie), le récupère, et rend son pid. S'il n'y a aucun fils, il rend -1. waitpid attend un fils précis ; avec -1 comme premier argument, il attend n'importe lequel, et l'option WNOHANG lui fait rendre 0 au lieu d'attendre si aucun fils n'a fini.
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
if (fork() == 0) {
printf(">> fils : j'ai fini\n");
return 0;
}
wait(NULL);
printf("> père : mon fils est mort\n");
return 0;
}
$ ./attente
>> fils : j'ai fini
> père : mon fils est mort
Ici l'ordre est garanti : le père n'affiche rien avant la fin du fils.
Si l'on passe à wait l'adresse d'un entier, il y range la façon dont le fils est mort. Cet entier se décode avec des macros :
| Macro | Signification |
|---|---|
WIFEXITED(status) | vrai si le fils s'est terminé normalement (return de main, ou exit) |
WEXITSTATUS(status) | son code de retour, de 0 à 255 |
WIFSIGNALED(status) | vrai si le fils a été tué par un signal |
WTERMSIG(status) | le numéro de ce signal |
#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 EXIT_FAILURE;
}
if (pid == 0)
return 42; /* meurt aussitôt, avec le code 42 */
int status;
pid_t mort = waitpid(pid, &status, 0);
printf("le fils %d ", (int)mort);
if (WIFEXITED(status))
printf("a terminé avec le code %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status))
printf("a été tué par le signal %d\n", WTERMSIG(status));
return 0;
}
$ ./statut
le fils 344767 a terminé avec le code 42
Si l'on remplace return 42; par abort();, qui envoie au processus le signal SIGABRT (numéro 6) :
$ ./statut
le fils 344769 a été tué par le signal 6
Exécuter un autre programme : exec
Remplacer le programme
fork crée un processus, mais c'est une copie du même programme. Pour lancer un autre programme, il faut un second mécanisme : les fonctions de la famille exec remplacent le programme du processus courant par un autre. Le processus garde son pid, son père et ses descripteurs ouverts ; mais son code, sa mémoire et sa pile sont remplacés par ceux du nouveau programme, qui démarre à son main.
#include <unistd.h>
int execvp(const char *file, char *const argv[]);
int execlp(const char *file, const char *arg, ...); /* ..., (char *)NULL */
fileest le programme à lancer. S'il ne contient pas de/, il est cherché dans les répertoires de la variablePATH, comme le fait le shell (c'est lepdeexecvp). Sinon, c'est un chemin vers l'exécutable.argvest le tableau des arguments, celui que recevra lemaindu nouveau programme. Par convention,argv[0]est le nom du programme, et la dernière case doit contenirNULL, qui marque la fin du tableau.execlpprend les arguments un par un, et le dernier doit êtreNULLlui aussi.
Les autres variantes (execv, execl, execve…) diffèrent par la façon de passer les arguments, l'usage du PATH et l'environnement. Toutes aboutissent à l'appel système execve.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
char *arg[] = { "ls", "-l", NULL };
execvp(arg[0], arg);
perror("execvp"); /* on n'arrive ici qu'en cas d'échec */
return 127;
}
Ce programme affiche la même chose que ls -l : c'est désormais ls qui tourne dans le processus.
fork, exec, wait : lancer une commande
C'est la combinaison des trois qui permet de lancer un programme et de continuer : on crée un fils avec fork, le fils se remplace par le programme voulu avec exec, et le père attend sa fin avec wait. C'est exactement ce que fait un shell.
flowchart LR
S[shell] -->|fork| F[copie du shell]
F -->|exec| L[ls]
L -->|exit| Z[zombie]
S -->|wait| ZVoici un shell minimal. Il lit une ligne, la découpe en mots avec strtok, et lance la commande :
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#define MAX_ARGS 64
/* Découpe ligne en mots, sur place ; argv se termine par NULL. */
static void decouper(char ligne[], char *argv[])
{
int n = 0;
for (char *mot = strtok(ligne, " \t\n"); mot != NULL && n < MAX_ARGS - 1;
mot = strtok(NULL, " \t\n"))
argv[n++] = mot;
argv[n] = NULL;
}
int main(void)
{
char ligne[1024];
char *argv[MAX_ARGS];
for (;;) {
printf("$ ");
fflush(stdout);
if (fgets(ligne, sizeof ligne, stdin) == NULL)
break; /* fin de fichier : Ctrl-D */
decouper(ligne, argv);
if (argv[0] == NULL)
continue; /* ligne vide */
if (strcmp(argv[0], "exit") == 0)
break;
pid_t pid = fork();
if (pid == -1) {
perror("fork");
continue;
}
if (pid == 0) {
execvp(argv[0], argv);
perror(argv[0]);
exit(127);
}
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status) && WEXITSTATUS(status) != 0)
printf("[code %d]\n", WEXITSTATUS(status));
}
printf("\n");
return 0;
}
$ ./minishell
$ echo bonjour le monde
bonjour le monde
$ foo
foo: No such file or directory
[code 127]
$ exit
Le fflush(stdout) après l'invite est nécessaire : "$ " ne finit pas par un passage à la ligne, et resterait sinon dans le tampon. Il vide aussi le tampon avant le fork, pour la raison vue plus haut.
Mesurer une commande
Un exemple plus complet : chrono commande arguments… lance la commande, et affiche en sortant son temps réel d'exécution, et le temps de processeur qu'elle a consommé, comme la commande time. On mesure le temps réel avec l'horloge CLOCK_MONOTONIC, qui ne recule jamais (contrairement à l'heure du système, qu'on peut régler), et on récupère le temps de processeur du fils avec wait4, une variante de waitpid qui remplit aussi une struct rusage.
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
static double secondes(struct timeval t)
{
return t.tv_sec + 1e-6 * t.tv_usec;
}
int main(int argc, char *argv[])
{
if (argc < 2) {
fprintf(stderr, "usage : %s commande [arguments]\n", argv[0]);
return 1;
}
struct timespec debut, fin;
clock_gettime(CLOCK_MONOTONIC, &debut);
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return 1;
}
if (pid == 0) {
execvp(argv[1], argv + 1);
fprintf(stderr, "chrono : impossible de lancer ");
perror(argv[1]);
exit(127);
}
int status;
struct rusage ru;
wait4(pid, &status, 0, &ru);
clock_gettime(CLOCK_MONOTONIC, &fin);
double reel = (fin.tv_sec - debut.tv_sec) + 1e-9 * (fin.tv_nsec - debut.tv_nsec);
fprintf(stderr, "réel %.3f s\n", reel);
fprintf(stderr, "utilisateur %.3f s\n", secondes(ru.ru_utime));
fprintf(stderr, "système %.3f s\n", secondes(ru.ru_stime));
if (WIFEXITED(status))
return WEXITSTATUS(status);
return 128 + WTERMSIG(status);
}
$ ./chrono sleep 1
réel 1.001 s
utilisateur 0.001 s
système 0.000 s
Trois détails :
argv + 1est le tableau des arguments privé de sa première case :argv[1]en devient le premier élément, et il se termine toujours parNULL. C'est exactement le tableau qu'attend la commande.- Les mesures sont écrites sur la sortie d'erreur, pour ne pas se mêler à la sortie de la commande, qu'on peut ainsi rediriger.
chronorend le code de retour de la commande, ou128 +le numéro du signal qui l'a tuée : c'est la convention du shell.
Rediriger avant exec
Puisque exec conserve les descripteurs ouverts, le fils peut les préparer entre fork et exec. C'est ainsi que le shell réalise ls -l > liste.txt : dans le fils, il ouvre le fichier, le place sur la sortie standard avec dup2 (chapitre 5), puis lance ls, qui écrit sur sa sortie standard sans savoir qu'elle a changé.
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
/* Comme le shell pour : ls -l > liste.txt */
int main(void)
{
pid_t pid = fork();
if (pid == 0) {
int fd = open("liste.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("liste.txt");
exit(1);
}
dup2(fd, STDOUT_FILENO); /* la sortie standard devient le fichier */
close(fd);
char *arg[] = { "ls", "-l", NULL };
execvp(arg[0], arg);
perror("ls");
exit(127);
}
waitpid(pid, NULL, 0);
printf("ls a écrit dans liste.txt\n");
return 0;
}
La redirection a lieu dans le fils seulement : la sortie standard du père n'a pas changé.
Un tube entre deux commandes
ls | wc -l suit le même principe, avec un tube (chapitre 5) et deux fils : le premier place l'entrée du tube sur sa sortie standard et devient ls ; le second place la sortie du tube sur son entrée standard et devient wc -l.
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
/* Comme le shell pour : ls | wc -l */
int main(void)
{
int t[2];
if (pipe(t) == -1) {
perror("pipe");
return 1;
}
if (fork() == 0) { /* premier fils : ls, écrit dans le tube */
dup2(t[1], STDOUT_FILENO);
close(t[0]);
close(t[1]);
execlp("ls", "ls", NULL);
perror("ls");
exit(127);
}
if (fork() == 0) { /* second fils : wc -l, lit le tube */
dup2(t[0], STDIN_FILENO);
close(t[0]);
close(t[1]);
execlp("wc", "wc", "-l", NULL);
perror("wc");
exit(127);
}
close(t[0]); /* le père n'utilise pas le tube */
close(t[1]);
wait(NULL);
wait(NULL);
return 0;
}
Les signaux
Des interruptions logicielles
Un signal est une notification envoyée à un processus, qui interrompt ce qu'il fait. Chaque signal a un numéro et un nom ; kill -l en donne la liste. Les plus courants :
| Signal | Numéro | Envoyé quand | Effet par défaut |
|---|---|---|---|
SIGINT | 2 | on tape Ctrl-C dans le terminal | termine le processus |
SIGKILL | 9 | on veut tuer le processus à coup sûr | termine le processus, sans recours |
SIGSEGV | 11 | accès mémoire invalide | termine le processus (et « Segmentation fault ») |
SIGPIPE | 13 | écriture dans un tube que plus personne ne lit | termine le processus |
SIGTERM | 15 | on demande poliment au processus de s'arrêter | termine le processus |
SIGCHLD | 17 | un fils s'est terminé | ignoré |
SIGABRT | 6 | abort() | termine le processus |
Les numéros sont ceux de Linux sur x86 ; ils peuvent changer d'un système à l'autre, les noms non. On utilise toujours les noms.
Certains signaux viennent du noyau, en réaction à ce que fait le programme (SIGSEGV, SIGPIPE) ou à un événement (SIGCHLD, SIGINT quand on tape Ctrl-C). D'autres sont envoyés par un processus à un autre. La commande kill et l'appel système du même nom servent à cela :
$ kill 12345 # envoie SIGTERM au processus 12345
$ kill -INT 12345 # envoie SIGINT
$ kill -9 12345 # envoie SIGKILL
#include <signal.h>
int kill(pid_t pid, int sig);
Malgré son nom, kill ne tue pas forcément : il envoie un signal, et c'est l'effet par défaut de la plupart d'entre eux. On n'envoie de signal qu'à ses propres processus, sauf à être root.
#include <signal.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
pid_t pid = fork();
if (pid == 0) {
for (;;)
pause(); /* attend un signal, indéfiniment */
}
sleep(1);
kill(pid, SIGTERM);
int status;
waitpid(pid, &status, 0);
if (WIFSIGNALED(status))
printf("fils %d tué par le signal %d\n", (int)pid, WTERMSIG(status));
return 0;
}
$ ./tuer
fils 344825 tué par le signal 15
Et une erreur de segmentation, vue par le père :
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
if (fork() == 0) {
int *p = NULL;
*p = 42; /* écriture à l'adresse 0 */
return 0;
}
int status;
wait(&status);
if (WIFSIGNALED(status))
printf("fils tué par le signal %d\n", WTERMSIG(status));
return 0;
}
$ ./segv
fils tué par le signal 11
Attraper un signal : sigaction
Pour la plupart des signaux, un processus peut remplacer l'effet par défaut : ignorer le signal, ou exécuter une fonction de son choix, un gestionnaire (handler). SIGKILL et SIGSTOP font exception : on ne peut ni les attraper, ni les ignorer. C'est ce qui garantit qu'on peut toujours arrêter un processus.
On installe un gestionnaire avec sigaction. (Il existe aussi une fonction signal, plus simple, mais son comportement varie d'un système à l'autre ; on ne l'utilise plus.)
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
static volatile sig_atomic_t interrompu = 0;
static void gestionnaire(int sig)
{
(void)sig;
interrompu = 1; /* rien d'autre : voir plus bas */
}
int main(void)
{
struct sigaction sa;
sa.sa_handler = gestionnaire;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
int tours = 0;
while (!interrompu) {
printf("tour %d\n", tours++);
sleep(1);
}
printf("Ctrl-C reçu après %d tours : on range et on s'en va\n", tours);
return 0;
}
$ ./sigint
tour 0
tour 1
tour 2
^CCtrl-C reçu après 3 tours : on range et on s'en va
Ctrl-C ne tue plus le programme : il appelle gestionnaire, qui lève un drapeau. La boucle le voit au tour suivant, et le programme se termine proprement. (sleep est d'ailleurs interrompu par le signal : on n'attend pas la fin de la seconde.)
Ce qu'on a le droit de faire dans un gestionnaire
Un signal peut arriver n'importe quand, entre deux instructions quelconques du programme : au milieu d'un printf, pendant que malloc réorganise ses listes internes. Le gestionnaire s'exécute alors, puis le programme reprend où il en était. Si le gestionnaire appelle lui-même printf ou malloc, il trouve leurs données à moitié modifiées, et le résultat est imprévisible.
La norme POSIX dresse donc la liste des fonctions qu'on peut appeler sans risque depuis un gestionnaire : les fonctions async-signal-safe. On y trouve write, _exit, kill, waitpid… mais ni printf, ni malloc, ni exit.
SIGCHLD et les zombies
Quand un fils se termine, le père reçoit SIGCHLD. Un serveur qui lance des fils sans vouloir attendre chacun d'eux peut s'en servir pour les récupérer au fur et à mesure : le gestionnaire (ou la boucle principale, prévenue par un drapeau) appelle waitpid(-1, &status, WNOHANG) en boucle, tant qu'il rend un pid. La boucle est nécessaire : si plusieurs fils meurent en même temps, le père peut ne recevoir qu'un seul SIGCHLD, car un signal qui arrive alors qu'il est déjà en attente n'est pas compté deux fois.
SIGPIPE
Écrire dans un tube dont toutes les extrémités de lecture sont fermées envoie SIGPIPE à l'écrivain, qui meurt. C'est ce qui arrête yes | head -3 : quand head a lu ses trois lignes et s'est terminé, la prochaine écriture de yes le tue. Un programme qui veut traiter l'erreur lui-même ignore SIGPIPE (avec sa.sa_handler = SIG_IGN) : write rend alors -1, avec errno égal à EPIPE.
Conclusion
Avec trois appels système, on sait maintenant tout faire :
forkcrée un processus, copie du père ;execlui fait exécuter un autre programme, en gardant ses descripteurs ;waitattend sa fin et récupère son code de retour.
Entre fork et exec, on prépare les descripteurs du fils : redirections et tubes. Les signaux, enfin, permettent d'interrompre un processus, ou de le prévenir d'un événement.
Deux processus ne partagent rien, sauf ce qu'ils ouvrent ensemble : ils sont bien isolés, mais communiquer leur coûte cher. Le chapitre suivant présente l'autre façon de faire plusieurs choses à la fois : plusieurs fils d'exécution dans un même processus, qui partagent toute la mémoire, avec les difficultés que cela pose.