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, fork rend le pid du fils ;
  • dans le fils, fork rend 0 ;
  • en cas d'échec (trop de processus, plus de mémoire), fork rend -1 dans 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 :

MacroSignification
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 */
  • file est le programme à lancer. S'il ne contient pas de /, il est cherché dans les répertoires de la variable PATH, comme le fait le shell (c'est le p de execvp). Sinon, c'est un chemin vers l'exécutable.
  • argv est le tableau des arguments, celui que recevra le main du nouveau programme. Par convention, argv[0] est le nom du programme, et la dernière case doit contenir NULL, qui marque la fin du tableau.
  • execlp prend les arguments un par un, et le dernier doit être NULL lui 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| Z

Voici 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 + 1 est le tableau des arguments privé de sa première case : argv[1] en devient le premier élément, et il se termine toujours par NULL. 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.
  • chrono rend le code de retour de la commande, ou 128 + 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 :

SignalNuméroEnvoyé quandEffet par défaut
SIGINT2on tape Ctrl-C dans le terminaltermine le processus
SIGKILL9on veut tuer le processus à coup sûrtermine le processus, sans recours
SIGSEGV11accès mémoire invalidetermine le processus (et « Segmentation fault »)
SIGPIPE13écriture dans un tube que plus personne ne littermine le processus
SIGTERM15on demande poliment au processus de s'arrêtertermine le processus
SIGCHLD17un fils s'est terminéignoré
SIGABRT6abort()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 :

  • fork crée un processus, copie du père ;
  • exec lui fait exécuter un autre programme, en gardant ses descripteurs ;
  • wait attend 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.