Partie 4 - Processus

Support présentation

Qu’est-ce qu’un processus ?

Un processus est une instance d’un programme en cours d’exécution. Un même programme peut donc avoir plusieurs exécutions simultanées. Par analogie avec la programmation objet : le programme joue le rôle de la classe (Voiture) et chaque processus celui d’une instance (ma_voiture_rouge, ma_voiture_bleue…).

Un processus est une unité gérée par le système d’exploitation, qui s’intercale entre les processus et le matériel :

Les processus (espace utilisateur) communiquent avec le noyau par les appels système ; le noyau pilote le matériel (CPU, RAM, disque, etc.) par ses pilotes.

Chaque processus dispose de ses propres ressources :

  • une zone mémoire privée (code, données, stack, heap…, voir mémoire)

  • un PID (Process ID) : identifiant unique du processus

  • un PPID (Parent PID) : identifiant du processus parent

  • un UID (User ID) : utilisateur propriétaire

  • des descripteurs de fichiers (liaisons avec fichiers, entrées/sorties, sockets…, voir Descripteurs de fichiers)

  • un contexte d’exécution (program counter, registres CPU, état)

Le program counter (compteur ordinal) est le registre du CPU qui contient l’adresse de la prochaine instruction à exécuter : il indique où en est le processus dans son code.

À gauche, le programme /bin/ls, un fichier sur le disque. À droite, le processus PID 4242 : dans l'espace utilisateur, sa mémoire (code, données, heap, stack) ; dans le noyau, son PCB (PID, PPID, état, program counter qui désigne une instruction du code) et sa table des descripteurs (0, 1, 2 vers le terminal).

Un processus = un programme en cours d’exécution + sa mémoire + son état (PCB) + ses fichiers ouverts.

Toutes ces informations sont regroupées dans une structure noyau appelée PCB (Process Control Block). Le noyau tient une table des processus : pour chaque processus, une entrée qui mène à son PCB.

PCB d'un processus d'exemple, en quatre rubriques : identité, exécution, ressources, comptabilité.

Un processus peut engendrer d’autres processus (processus enfants), formant une arborescence. On peut l’afficher avec pstree (-p pour afficher les PID) :

pstree -p

Le processus initial lancé au démarrage (PID 1) est souvent nommé init ou systemd. Il adopte les processus orphelins (sauf si un autre processus subreaper, souvent systemd --user, s’en charge) et supervise de nombreux services (fichiers, périphériques, réseau, etc.).

La commande ps permet d’afficher les processus, par défaut uniquement ceux de l’utilisateur rattachés au terminal courant :

$ # & lance la commande en arrière-plan, fg permet de la récupérer ensuite
$ sleep 10 &
[1] 3816906

$ ps
    PID TTY          TIME CMD
3812988 pts/2    00:00:00 bash
3816906 pts/2    00:00:00 sleep
3817006 pts/2    00:00:00 ps

Différentes options permettent d’afficher plus d’informations comme a pour aussi voir les processus lancés par les autres utilisateurs, u pour un affichage détaillé (utilisateur, %CPU, %MEM…) ou x pour montrer les processus qui n’ont pas de terminal de contrôle :

$ ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0 168072 11744 ?        Ss   13:17   0:02 /sbin/init splash
root           2  0.0  0.0      0     0 ?        S    13:17   0:00 [kthreadd]
root           3  0.0  0.0      0     0 ?        S    13:17   0:00 [pool_workqueue_release]
...

La colonne STAT donne l’état du processus (voir le cycle de vie ci-dessous) :

Lettre

Signification

R

en exécution ou prêt à s’exécuter (running)

S

endormi : bloqué en attente d’un événement (sleeping)

T

stoppé (Ctrl+Z, SIGSTOP)

Z

zombie : terminé, mais pas encore récupéré par son parent

+

(après la lettre) processus au premier plan du terminal, par exemple S+

Les autres caractères (s, I, <…) sont décrits dans man ps.

Dans un même terminal, une seule commande (un job, qui peut regrouper plusieurs processus comme ls | grep x) peut tourner à la fois au premier plan (foreground), pendant que d’autres peuvent tourner en arrière-plan (background). Pour lancer un programme en arrière-plan, comme vu ci-dessus, ajoutez un & après la commande :

$ # gedit ouvre l'éditeur dans une fenêtre (sinon, remplacez par un autre programme graphique comme xeyes, ou par sleep 30)
$ gedit fichier.txt
$ # en fermant la fenêtre on reprend la main sur le terminal
$ # en ajoutant & :
$ gedit fichier.txt &
[1] 200404
$ # gedit s'ouvre en arrière-plan et le terminal est toujours accessible
$ # le 200404 est le PID du processus lancé avec gedit

Cycle de vie d’un processus

Au cours de son exécution, un processus passe par plusieurs états :

  • Nouveau (new) : créé par fork et chargé en mémoire ; l’ordonnanceur (scheduler) le fait ensuite passer à l’état prêt (ready)

  • Prêt (ready) : en attente d’être ordonnancé

  • Exécution (running) : le processus utilise le CPU

  • Bloqué (blocked) : en attente d’un événement (I/O, signal). Il repasse en état prêt quand l’événement survient

  • Stoppé (stopped) : suspension volontaire/forcée par un SIGSTOP ou un debugger

  • Zombie : terminé mais non récupéré par son parent (via wait ou waitpid)

  • Terminé (terminated) : effacé de la table des processus

Un signal (SIGSTOP, SIGKILL…) est un message envoyé à un processus par le noyau, par un autre processus ou depuis le clavier : les signaux sont présentés plus loin.

Schéma du cycle de vie d'un processus

Un processus peut aussi être tué par le noyau, par exemple en cas d’accès mémoire invalide (SIGSEGV, le segmentation fault) ou de division entière par zéro (SIGFPE).

Création de processus : fork

Ce que fait fork

Un processus est créé par fork() (<unistd.h>), qui duplique le processus qui l’appelle : le processus d’origine est le parent, la copie est l”enfant.

pid_t fork(void);

Retourne deux fois, une fois dans chaque processus : le PID de l’enfant dans le parent, et 0 dans l”enfant. En cas d’erreur, retourne -1 dans le parent, et l’enfant n’est pas créé (errno indique la cause).

Avant fork, un processus PID 4242 dont la prochaine instruction est pid = fork(). Le noyau crée un PCB (PID 4243, PPID 4242), copie la mémoire, la table des descripteurs et le contexte CPU. Après, deux processus presque identiques : le parent reprend après fork avec pid = 4243, l'enfant reprend après fork avec pid = 0 ; leurs descripteurs 0, 1, 2 désignent le même terminal.

Au moment du fork, le noyau :

  1. crée un nouveau PCB : l’enfant a son propre PID, et son PPID est le PID du parent ;

  2. copie la mémoire du parent (code, variables globales, heap, stack) ;

  3. copie la table des descripteurs de fichiers (voir plus bas) ;

  4. copie le contexte CPU, dont le program counter : l’enfant reprend donc juste après l’appel à fork, comme le parent.

L’enfant ne repart pas du début de main, comme le ferait un deuxième lancement du programme : c’est une copie du parent à l’instant du fork, avec les mêmes valeurs de variables et la même position dans le code.

La seule différence entre les deux processus est la valeur retournée par fork : c’est elle qui permet à chaque processus de savoir s’il est le parent ou l’enfant.

Chaque processus peut aussi demander son PID et celui de son parent (<unistd.h>) :

pid_t getpid(void);
pid_t getppid(void);

getpid retourne le PID du processus appelant, getppid celui de son parent. Ces deux fonctions n’échouent jamais.

La fonction wait() permet au parent d’attendre la fin d’exécution de l’enfant avant de terminer le programme (wait(NULL) : on ne récupère pas le statut de fin de l’enfant, voir wait).

 1#define _POSIX_C_SOURCE 200809L
 2#include <stdio.h>
 3#include <stdlib.h>
 4#include <sys/types.h>
 5#include <sys/wait.h>
 6#include <unistd.h>
 7
 8int main(void) {
 9    // Création d'un processus enfant
10    pid_t pid = fork();
11
12    // À partir d'ici
13    // le code est exécuté à la fois par le parent et par l'enfant
14
15    if (pid == -1) {
16        // fork retourne -1 s'il y a eu un problème avec la création du
17        // processus enfant
18        perror("fork");
19        exit(EXIT_FAILURE);
20    }
21
22    if (pid == 0) {
23        // le processus enfant est identifiable par fork qui retourne 0
24        printf("(enfant) PID = %d, PPID = %d\n", getpid(), getppid());
25        // dans l'enfant : _exit (après avoir vidé le tampon de stdout)
26        fflush(stdout);
27        _exit(0);
28    } else {
29        // le processus parent reçoit de fork le PID du processus enfant
30        printf("(parent) PID = %d, Enfant = %d\n", getpid(), pid);
31
32        // Attente de la fin du processus enfant
33        // (NULL : on ne récupère pas son statut de fin, voir wait plus bas)
34        wait(NULL);
35    }
36
37    return 0;
38}
(enfant) PID = 49195, PPID = 49194
(parent) PID = 49194, Enfant = 49195

Deux exécutions du même code

Après le fork, le même code continue dans les deux processus, à partir de la même ligne. Comme pid n’a pas la même valeur, le test if (pid == 0) ne donne pas le même résultat : chaque processus exécute une des deux branches du if, et le code placé après le if/else est exécuté par les deux.

Le même listing en deux colonnes. Parent (PID 4242) : il exécute printf("avant"), fflush, fork (pid = 4243), le test, la branche else (printf("parent")) puis printf("fin"). Enfant (PID 4243) : il n'exécute pas les lignes d'avant fork ; il reprend après fork avec pid = 0, exécute la branche if (printf("enfant")) puis printf("fin"). Sortie : avant, parent, enfant, fin 4242, fin 4243.

fork est appelé une fois mais retourne deux fois : une fois dans chaque processus.

Parent et enfant s’exécutent en parallèle (sur deux cœurs différents, ou à tour de rôle sur le même cœur, voir l’ordonnanceur) : l’ordre des lignes affichées peut varier d’une exécution à l’autre.

Mémoire copiée

Après le fork, le parent et l’enfant sont des copies indépendantes. L’enfant reçoit une copie virtuelle exacte de l’espace mémoire du parent (heap, stack, variables globales…). Voir l’exemple suivant, où l’enfant modifie la variable après le fork :

 1#define _POSIX_C_SOURCE 200809L
 2#include <stdio.h>
 3#include <stdlib.h>
 4#include <sys/types.h>
 5#include <sys/wait.h>
 6#include <unistd.h>
 7
 8int main(void) {
 9
10    int ma_variable = 4;
11
12    pid_t pid = fork();
13    if (pid == -1) {
14        perror("fork");
15        exit(EXIT_FAILURE);
16    }
17
18    if (pid == 0) {
19        // l'enfant modifie sa copie de la variable
20        ma_variable = 2;
21        printf("enfant : ma_variable = %d (adresse = %p)\n",
22               ma_variable,
23               (void *)&ma_variable);
24        // dans l'enfant : _exit (après avoir vidé le tampon de stdout)
25        fflush(stdout);
26        _exit(0);
27    } else {
28        // le parent attend la fin de l'enfant avant d'afficher
29        // (l'ordre des affichages est ainsi garanti)
30        wait(NULL);
31        printf("parent : ma_variable = %d (adresse = %p)\n",
32               ma_variable,
33               (void *)&ma_variable);
34    }
35
36    return 0;
37}
enfant : ma_variable = 2 (adresse = 0x7ffd5fab6b80)
parent : ma_variable = 4 (adresse = 0x7ffd5fab6b80)
Ligne de temps du programme : le parent se ramifie au fork en un enfant ; l'enfant met ma_variable à 2 puis se termine, le parent attend avec wait puis affiche 4 ; même adresse, deux copies.

Déroulé du programme : la ligne du parent se ramifie au fork, chaque processus continue avec sa propre copie de ma_variable.

L’adresse affichée est identique, mais les valeurs diffèrent : c’est une adresse virtuelle, et chaque processus a sa propre table des pages, qui traduit cette adresse vers une page physique différente (voir la pagination). Pour éviter de tout copier au moment du fork, les pages sont d’abord partagées en lecture seule, puis copiées seulement quand l’un des deux processus les modifie : c’est le Copy-On-Write (voir le schéma du chapitre Mémoire).

Ici, le wait du parent garantit l’ordre des affichages.

Descripteurs de fichiers copiés

fork copie aussi la table des descripteurs (voir Descripteurs de fichiers). Les deux tables désignent les mêmes fichiers ouverts : le parent et l’enfant écrivent sur le même terminal, et s’ils lisent un même fichier, ils partagent la même position courante.

Après open de fichier.txt (fd 3) puis fork : la table du parent et la table de l'enfant contiennent 0, 1, 2 et 3. Les entrées 0, 1, 2 des deux tables désignent le même fichier ouvert (le terminal), les entrées 3 désignent le même fichier ouvert fichier.txt (position 120).

Ce partage est la base des pipes : un pipe (tube en français) créé avant le fork est accessible par le parent et par l’enfant.

Buffers et fork

Important

Règle à appliquer :

  • avant fork : fflush(stdout) ;

  • dans l’enfant, avant _exit : fflush(stdout).

printf n’écrit pas directement : le texte est d’abord stocké dans le buffer (tampon en français) de stdout, dans la mémoire du processus (voir Sorties et Bufferisation). Sans ces fflush, un texte encore dans le buffer au moment du fork est copié dans l’enfant et peut être affiché deux fois ; et un texte affiché par l’enfant peut être perdu, car _exit (voir plus bas) ne vide pas les buffers.

Synchronisation parent / enfant : wait

En général, il vaut mieux qu’un parent attende la terminaison de ses enfants avant de se terminer lui-même.

L’appel à wait ou waitpid (<sys/wait.h>, man 2 wait) permet au parent d’attendre l’enfant :

pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
  • pid (waitpid) : PID de l’enfant à attendre, ou -1 pour n’importe quel enfant

  • status : adresse d’un entier où sera enregistré le compte rendu de terminaison de l’enfant (ou NULL si on ne s’y intéresse pas)

  • options (waitpid) : 0 pour attendre en bloquant, ou WNOHANG pour retourner immédiatement si aucun enfant n’est terminé

wait attend la fin de n’importe quel enfant, comme waitpid(-1, &status, 0).

Retourne le PID de l’enfant terminé, ou -1 en cas d’erreur (errno indique la cause, ECHILD s’il n’y a plus d’enfant à attendre). Avec WNOHANG, waitpid retourne 0 si des enfants existent mais qu’aucun n’est terminé.

Le compte rendu status ne s’utilise pas directement : on l’interroge avec des macros de <sys/wait.h> :

int WIFEXITED(int status);
int WEXITSTATUS(int status);
int WIFSIGNALED(int status);
int WTERMSIG(int status);
  • status : l’entier rempli par wait ou waitpid (passé par valeur, sans &)

WIFEXITED (W = wait, if exited) est vrai (non nul) si l’enfant s’est terminé normalement, par un return dans main, exit ou _exit ; WEXITSTATUS donne alors son code de retour (de 0 à 255).

WIFSIGNALED (if signaled) est vrai si l’enfant a été tué par un signal ; WTERMSIG donne alors le numéro de ce signal.

Ce sont des macros et non des fonctions : les signatures indiquent seulement le type attendu et le type du résultat. WEXITSTATUS et WTERMSIG n’ont de sens que si la macro de test correspondante est vraie.

Exemple :

int status;
if (wait(&status) == -1) {
    perror("wait");
    exit(EXIT_FAILURE);
}

if (WIFEXITED(status)) {
    printf("Code retour = %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
    printf("Tué par signal %d\n", WTERMSIG(status));
}

waitpid permet de cibler un enfant en particulier :

waitpid(pid, &status, 0);

ou de récupérer un enfant terminé s’il y en a un, sans bloquer :

waitpid(-1, &status, WNOHANG);

On passe &status car une fonction C ne retourne qu’une valeur : pour obtenir un deuxième résultat (ici le compte rendu, en plus du PID), on lui donne l’adresse d’une variable où l’écrire (voir Passage par valeur, passage par adresse).

Remplacement d’un processus : exec

La famille exec* remplace le programme du processus courant (code, données, stack, heap) par un nouveau programme. Le PID reste identique : exec ne crée pas de processus, il change le programme exécuté par le processus courant. Pour lancer un autre programme tout en continuant le sien, on combine fork et exec (voir plus bas).

Exemple :

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <unistd.h>

int main(void) {

    // lance la commande ls située dans /bin/ls avec l'argument -l
    execl("/bin/ls", "ls", "-l", NULL);

    // si exec fonctionne sans problème, cette ligne n'est jamais atteinte
    // si exec échoue (execl retourne -1), affiche l'erreur correspondante
    perror("execl");
    return 1;
}

Le premier argument est le fichier à exécuter ; les suivants forment le tableau argv que recevra le main du nouveau programme (voir la fonction main au chapitre 1) :

execl("/bin/ls", "ls", "-l", NULL) : "/bin/ls" est le fichier exécuté ; "ls", "-l" et NULL deviennent argv[0], argv[1] et argv[2] du main de ls, où argc vaut 2.

Le nom du programme apparaît donc deux fois (chemin du fichier, puis argv[0]), et la liste se termine toujours par NULL.

Variantes de exec

Toutes les fonctions exec* (<unistd.h>) remplacent le programme du processus courant. La différence porte sur la manière de passer les arguments, et sur la recherche du programme dans le PATH :

int execl(const char *pathname, const char *arg, ... /*, (char *) NULL */);
int execlp(const char *file, const char *arg, ... /*, (char *) NULL */);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
  • pathname : chemin du fichier à exécuter ("/bin/ls")

  • file : nom du programme, cherché dans les répertoires du PATH ("ls")

  • arg, ... : les arguments du nouveau programme, un par paramètre (argv[0], argv[1]…), terminés par NULL

  • argv : les mêmes arguments, dans un tableau terminé par NULL

Ne retourne pas en cas de succès, puisque le programme appelant est remplacé. Retourne -1 en cas d’erreur (errno indique la cause).

Le même appel avec chacune des quatre variantes :

arguments en liste (l)

arguments dans un tableau (v)

chemin complet

execl("/bin/ls", "ls", "-l", NULL);

execv("/bin/ls", args);

recherche dans le PATH (p)

execlp("ls", "ls", "-l", NULL);

execvp("ls", args);

avec, pour les versions v :

char *args[] = {"ls", "-l", NULL};

Résumé mnémotechnique, d’après le suffixe (les lettres placées après exec) :

  • l → liste d’arguments ; v → vecteur (tableau argv[]) ;

  • p → recherche du programme dans le PATH ; sinon, il faut donner le chemin complet ;

  • e → un dernier argument donne les variables d’environnement du nouveau programme (execle, execve) ; sinon, il garde l’environnement courant.

Usage classique : fork + exec pour lancer un autre programme dans un enfant :

pid_t pid = fork();
if (pid == 0) {
    execl("/bin/date", "date", NULL);
    perror("execl");
    _exit(1);
}

exit ou _exit ?

Un processus peut se terminer avec exit (<stdlib.h>, libc), qui vide d’abord les buffers de la libc (printf, fprintf…), ou avec _exit (<unistd.h>, appel système) :

void _exit(int status);
  • status : code de retour du processus, que le parent lit avec WEXITSTATUS

Termine le processus immédiatement, sans vider les buffers de la libc. Ne retourne jamais.

Dans l”enfant, on utilise _exit(), en particulier après un exec* raté : l’enfant a reçu une copie des buffers du parent, et exit() les viderait une deuxième fois. Si l’enfant a lui-même affiché avec printf, il fait fflush(stdout) avant _exit() (voir buffers et fork). Dans le parent (ou dans un programme sans fork), on utilise exit() (ou return dans main).

Zombies et orphelins

  • Zombie : processus terminé dont le parent n’a pas encore lu le code retour.

    • visible dans ps avec l’état Z

    • disparaît dès que le parent fait wait, ou quand le parent se termine (il est alors adopté puis récupéré par init ou le subreaper)

  • Orphelin : processus dont le parent est mort → adopté par init (PID 1) ou par un processus subreaper (sur un poste de travail, souvent systemd --user). Son PPID change donc : getppid() ne retourne plus le PID du parent d’origine.

Zombie : l'enfant se termine, il reste à l'état zombie (Z) jusqu'au wait du parent, qui lit son code retour. Orphelin : le parent se termine avant l'enfant ; l'enfant continue et est adopté par le PID 1 ou par un subreaper, donc getppid() change.

Ordonnanceur et priorités

Une machine exécute en général beaucoup plus de processus qu’elle n’a de cœurs. L”ordonnanceur (scheduler) du noyau partage les cœurs entre les processus prêts : chacun reçoit à tour de rôle une courte tranche de temps (quelques millisecondes), puis le noyau sauvegarde son contexte dans son PCB et donne le cœur à un autre processus (changement de contexte). Un processus bloqué (en attente d’une lecture, d’un wait…) ne reçoit pas de temps CPU.

C’est l’ordonnanceur qui décide quel processus s’exécute, et à quel moment : c’est pourquoi l’ordre d’exécution du parent et de l’enfant n’est pas prévisible.

Un processus peut s’endormir volontairement (il est alors bloqué) pendant une durée donnée avec sleep (<unistd.h>) :

unsigned int sleep(unsigned int seconds);
  • seconds : durée de l’attente, en secondes (un nombre entier : sleep(0.1) est converti en sleep(0) et n’attend pas)

Met le processus en sommeil pendant seconds secondes, ou jusqu’à l’arrivée d’un signal intercepté (voir les signaux).

Retourne 0 si la durée s’est écoulée, sinon le nombre de secondes restantes (l’attente a été interrompue par un signal).

Nice et priorité utilisateur

Chaque processus possède une valeur de niceness (gentillesse) qui influence sa priorité CPU :

  • Plage de -20 (très prioritaire) à +19 (très gentil, peu prioritaire).

  • Plus la valeur est basse, plus le processus reçoit de CPU.

Exemples :

# Lancer un programme avec une priorité plus faible (nice=10)
nice -n 10 ./long_calcul

# Modifier la priorité d'un processus existant
# (une valeur négative, donc plus prioritaire, nécessite les droits root)
sudo renice -5 -p <pid>

Afficher la priorité et la valeur nice avec ps :

$ ps -o pid,comm,pri,ni -p <pid>
PID  COMMAND   PRI  NI
4242 mon_prog   19   0
  • PRI : priorité interne du noyau (calculée à partir de NI)

  • NI : valeur nice visible/utilisateur

Outils d’observation

  • ps -o pid,ppid,stat,cmd -p <pid> : état du processus

  • top / htop : charge CPU/mémoire en temps réel

  • pstree -p : hiérarchie parent/enfant

  • strace -f ./prog : trace des appels système

  • /proc/<pid>/ : informations détaillées sur le processus

Note

Exploration pratique : le répertoire /proc

Sous Linux, chaque processus dispose d’un dossier dans le pseudo-système de fichiers /proc :

  • /proc/<pid>/ contient des fichiers représentant l’état et les ressources du processus.

  • /proc/self/ est un raccourci vers le dossier du processus courant.

Exemples :

$ echo $$                 # Affiche le PID du shell courant
12345

$ ls /proc/$$             # Liste les fichiers associés à ce processus (extrait)
attr  cmdline  cwd  environ  fd  maps  mem  mounts  status  task  ...

$ cat /proc/$$/cmdline; echo   # Commande ayant lancé ce processus (arguments séparés par des \0, sans \n final)
bash

$ grep -E '^(Name|State|Pid|PPid)' /proc/$$/status
Name:   bash
State:  S (sleeping)
Pid:    12345
PPid:   678

Quelques fichiers utiles :

  • cmdline : commande ayant lancé le processus

  • status : informations détaillées (UID, état, mémoire, etc.)

  • fd/ : descripteurs de fichiers ouverts

  • maps : mappage mémoire du processus

Ce mécanisme montre que le noyau expose les processus comme des fichiers, ce qui permet de les interroger avec les outils habituels sur les fichiers (cat, grep), sans appel système spécifique.

Résumé pratique : modèle fork → exec → wait

Schéma classique pour lancer un nouveau programme, utilisé par le shell pour chaque commande :

  1. le parent fait fork

  2. l’enfant fait exec

  3. le parent fait wait

Diagramme de séquence. Le shell (PID 4242) fait fork, ce qui crée l'enfant (PID 4243, copie du shell), puis il fait waitpid et reste bloqué. L'enfant fait execlp("ls", "ls", "-l", NULL) : son code est remplacé par celui de ls, avec le même PID. ls affiche la liste des fichiers puis se termine avec le code 0, ce qui débloque le waitpid du shell ; WEXITSTATUS(status) vaut 0 et le shell affiche le prompt suivant.
 1#define _POSIX_C_SOURCE 200809L
 2#include <stdio.h>
 3#include <stdlib.h>
 4#include <unistd.h>
 5#include <sys/wait.h>
 6
 7int main(void) {
 8    pid_t pid = fork();
 9    if (pid < 0) { perror("fork"); exit(EXIT_FAILURE); }
10
11    if (pid == 0) {
12        execl("/bin/ls", "ls", "-l", NULL);
13        perror("execl");
14        _exit(1);
15    } else {
16        int status = 0;
17        if (waitpid(pid, &status, 0) == -1) {
18            perror("waitpid");
19            exit(EXIT_FAILURE);
20        }
21        if (WIFEXITED(status))
22            printf("Code retour = %d\n", WEXITSTATUS(status));
23    }
24}

Vérifiez toujours la valeur de retour des appels système (fork, exec, waitpid, open…). En cas d’échec, utilisez perror() pour afficher un message clair sur la cause de l’erreur (voir gestion des erreurs).

Cas d’utilisation de fork

  • Exécution d’un autre programme : un shell utilise fork + exec pour lancer une nouvelle commande (ls, gcc…).

  • Isolation et robustesse : un crash dans un processus n’affecte pas les autres (contrairement aux threads).

  • Services système : les démons/serveurs Unix (ex. sshd, cron) créent un processus enfant par connexion ou tâche.

  • Sécurité : sandboxing ou chroot → isoler un processus potentiellement dangereux.

  • Traitement concurrent simple : lancer plusieurs programmes indépendants (ex. simulation parallèle où chaque processus travaille sur une portion différente de données, puis communication via pipes/sockets).

Communication inter-processus

Introduction

Un processus est isolé : il possède sa propre mémoire virtuelle et ne peut pas accéder directement à la mémoire des autres processus. Pour collaborer, les processus ont besoin de mécanismes de communication inter-processus (IPC, Inter-Process Communication).

Linux (et POSIX en général) propose plusieurs mécanismes IPC :

  • Signaux : messages simples envoyés par le noyau ou un autre processus (ex. SIGINT, SIGKILL)

  • Pipes : communication en flux entre processus apparentés

  • FIFO (pipes nommés) : pipes accessibles entre processus non apparentés

  • Files de messages : envoi/réception de messages structurés (non abordées dans ce cours)

  • Mémoire partagée : plusieurs processus accèdent à une même zone mémoire (non abordée dans ce cours)

  • Sémaphores : synchronisation d’accès à des ressources partagées (non abordés dans ce cours)

  • Sockets : communication locale ou réseau (voir chapitre Sockets)

Signaux

Un signal est un message très simple (un numéro) envoyé à un processus. C’est un mécanisme asynchrone : le signal peut arriver à n’importe quel moment de l’exécution du processus qui le reçoit. Il peut être généré par :

  • le clavier (Ctrl+C → SIGINT) ;

  • un autre processus (commande kill ou fonction kill) ;

  • le noyau (division entière par zéro → SIGFPE).

La commande kill -L permet de lister les différents signaux (voir aussi man 7 signal) :

$ kill -L
 1) SIGHUP   2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT  7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV 12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT       17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
... jusqu'à 64

Les signaux les plus courants (numéros sous Linux) :

Signal

N°

Origine habituelle

Effet par défaut

Interceptable

SIGHUP

1

fermeture du terminal / déconnexion

termine le processus

oui

SIGINT

2

Ctrl+C

termine le processus

oui

SIGKILL

9

kill -9

termine le processus

non

SIGPIPE

13

noyau : écriture dans un pipe sans lecteur

termine le processus

oui

SIGTERM

15

kill (signal par défaut) : demande d’arrêt

termine le processus

oui

SIGCHLD

17

noyau : un enfant s’est terminé

ignoré

oui

SIGCONT

18

kill -CONT, fg

reprend un processus stoppé

oui

SIGSTOP

19

kill -STOP

stoppe le processus

non

SIGTSTP

20

Ctrl+Z

stoppe le processus

oui

Depuis le terminal, on peut désigner le signal de trois manières :

# avec son numéro :
kill -9 <pid>
# le nom complet du signal :
kill -SIGKILL <pid>
# le nom sans le préfixe SIG :
kill -KILL <pid>
# sans option, kill envoie SIGTERM :
kill <pid>

Exemple d’utilisation :

$ # ouverture de mon_fichier.txt avec gedit en arrière-plan
$ gedit mon_fichier.txt &
[1] 202266
$ # envoi d'un signal SIGINT au processus
$ kill -SIGINT 202266
[1]+  Interrompre             gedit mon_fichier.txt

Envoyer un signal en C : kill

La fonction kill (<signal.h>, man 2 kill) est l’équivalent en C de la commande kill :

int kill(pid_t pid, int sig);
  • pid : PID du processus destinataire

  • sig : signal à envoyer (SIGTERM, SIGINT…)

Retourne 0, ou -1 en cas d’erreur (errno indique la cause).

Par exemple, le parent demande à son enfant de s’arrêter :

if (kill(pid_enfant, SIGTERM) == -1) {
    perror("kill");
}

kill est une fonction POSIX : avec -std=c2x, il faut la macro #define _POSIX_C_SOURCE 200809L (ou #define _GNU_SOURCE) avant les #include (voir la note du cours).

Intercepter un signal : sigaction

Un processus peut intercepter un signal avec une fonction appelée handler (gestionnaire), de signature void nom_du_handler(int sig) (le nom est libre, on_signal dans l’exemple ci-dessous).

Le handler ne s’exécute pas « quand le programme a fini ce qu’il faisait » : il interrompt le programme là où il en est, entre deux instructions quelconques, puis le programme reprend exactement au même endroit.

Ligne de temps de main : pendant un printf, Ctrl+C (SIGINT) arrive ; main est mis en pause et le handler on_signal s'exécute (stop = 1), puis main reprend exactement au même endroit (suite du printf) ; au test suivant de while (!stop), la boucle s'arrête.

D’où deux règles :

  • le handler se contente de modifier une variable de type volatile sig_atomic_t (un « drapeau ») ;

  • tout le reste (affichage, arrêt de la boucle…) est fait dans le programme principal, qui teste ce drapeau.

printf est à éviter dans le handler : il peut interrompre un printf du programme principal, alors que le buffer de stdout est à moitié rempli.

Le handler est installé avec la fonction sigaction (<signal.h>, man 2 sigaction) :

int sigaction(int signum,
              const struct sigaction *act,
              struct sigaction *oldact);
  • signum : signal à intercepter (SIGINT…)

  • act : nouvelle action, décrite par une struct sigaction (le handler, les signaux bloqués, les options)

  • oldact : adresse où enregistrer l’action précédente, ou NULL si on ne s’y intéresse pas

Retourne 0, ou -1 en cas d’erreur (errno indique la cause).

Comme pour kill, avec -std=c2x, sigaction et struct sigaction ne sont déclarées que si #define _POSIX_C_SOURCE 200809L est placé en première ligne, avant tous les #include (voir la note sur _POSIX_C_SOURCE).

L’installation suit toujours la même recette :

struct sigaction sa = {0};      // structure dont tous les champs valent 0
sa.sa_handler = on_signal;      // la fonction à appeler (pointeur de fonction)
sigemptyset(&sa.sa_mask);       // aucun signal supplémentaire bloqué pendant le handler
sa.sa_flags = SA_RESTART;       // relancer read, write, wait… s'ils sont interrompus
if (sigaction(SIGINT, &sa, NULL) == -1) {   // SIGINT sera désormais intercepté
    perror("sigaction");
    exit(EXIT_FAILURE);
}
  • sa_handler reçoit le nom de la fonction, sans parenthèses : c’est un pointeur de fonction (voir Pointeurs de fonctions) ;

  • sa_mask est l’ensemble des signaux bloqués pendant l’exécution du handler, vidé avec sigemptyset (le signal en cours de traitement est déjà bloqué par défaut) ;

  • sa_flags vaut en général SA_RESTART, pour que certains appels bloquants (read, write, wait, etc.) soient relancés automatiquement s’ils sont interrompus par un signal (mettre 0 si, au contraire, vous souhaitez gérer un échec avec EINTR).

Note

Certaines fonctions comme sleep, nanosleep ou pause ne sont jamais relancées automatiquement, même avec SA_RESTART : un sleep est interrompu dès qu’un signal intercepté arrive. Il faut alors gérer soi-même la reprise.

Le champ sa_mask est de type sigset_t, un ensemble de signaux, que l’on manipule avec les fonctions suivantes (<signal.h>, man 3 sigsetops) :

int sigemptyset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
  • set : l’ensemble de signaux (par exemple &sa.sa_mask)

  • signum : le signal à ajouter, retirer ou tester

sigemptyset vide l’ensemble, sigaddset y ajoute un signal, sigdelset en retire un, et sigismember teste si un signal en fait partie.

Retourne 0 (pour sigismember : 1 si le signal fait partie de l’ensemble, 0 sinon), ou -1 en cas d’erreur (errno indique la cause).

Vous trouverez aussi la fonction signal() dans d’anciens codes : c’est une interface plus ancienne, dont le comportement varie selon les systèmes ; préférez sigaction.

Exemple : intercepter SIGINT (Ctrl+C) pour arrêter proprement une boucle :

 1#define _POSIX_C_SOURCE 200809L // pour sigaction avec -std=c2x
 2#include <signal.h> // sigaction, sig_atomic_t, SIGINT
 3#include <stdio.h>  // printf, perror
 4#include <stdlib.h> // EXIT_SUCCESS, EXIT_FAILURE
 5#include <unistd.h> // pause, getpid
 6
 7// drapeau modifié par le handler
 8volatile sig_atomic_t stop = 0;
 9
10void on_signal(int sig) {
11    (void)sig; // paramètre inutilisé : évite l'avertissement « unused parameter »
12    stop = 1;  // demande l'arrêt de la boucle
13}
14
15int main(void) {
16    // installation du handler, selon la recette ci-dessus
17    struct sigaction sa = {0};
18    sa.sa_handler = on_signal;
19    sigemptyset(&sa.sa_mask);
20    sa.sa_flags = SA_RESTART;
21    if (sigaction(SIGINT, &sa, NULL) == -1) { perror("sigaction"); return EXIT_FAILURE; }
22
23    printf("PID = %d. Appuyez sur Ctrl+C pour envoyer SIGINT.\n", getpid());
24    while (!stop) {
25        pause(); // endort le processus jusqu'à l'arrivée d'un signal
26    }
27    printf("\nSIGINT reçu, fin de la boucle\n");
28    return EXIT_SUCCESS;
29}

Sortie :

PID = 12345. Appuyez sur Ctrl+C pour envoyer SIGINT.
^C
SIGINT reçu, fin de la boucle

Les variables partagées avec le handler (ici stop) sont de type volatile sig_atomic_t : sig_atomic_t est un type entier dont la lecture et l’écriture se font en une seule opération, même si un signal interrompt le programme ; volatile indique au compilateur que la valeur peut changer à tout moment (voir Autres mots-clés).

L’attente passive d’un signal se fait avec pause (<unistd.h>) :

int pause(void);

Met le processus en sommeil jusqu’à la réception d’un signal. Quand le signal arrive, le handler s’exécute, puis pause retourne.

Retourne toujours -1, avec errno == EINTR.

Le nom lisible du signal est obtenu avec strsignal (<string.h>) :

char *strsignal(int sig);
  • sig : numéro du signal

Retourne une chaîne lisible correspondant au numéro de signal ("Interrupt" pour SIGINT).

Pour attendre une durée donnée plutôt qu’un signal, on utilise sleep (présentée plus haut).

Pipes anonymes

Un pipe est un canal de communication unidirectionnel entre deux processus apparentés (souvent un parent et son enfant). Il est créé avec pipe (<unistd.h>) :

int pipe(int fd[2]);
  • fd : tableau de deux entiers, qui reçoit deux descripteurs de fichier : fd[0] pour l’extrémité lecture (sortie du pipe), fd[1] pour l’extrémité écriture (entrée du pipe)

Retourne 0, ou -1 en cas d’erreur (errno indique la cause).

La lecture et l’écriture se font avec read et write. Il est aussi possible d’utiliser la libc pour simplifier les échanges.

Si on veut échanger des messages dans les deux sens, alors il faut créer deux pipes.

Le pipe est créé avant le fork : comme fork copie la table des descripteurs (voir Descripteurs de fichiers copiés), le parent et l’enfant ont alors chacun fd[0] et fd[1], soit 4 descripteurs vers le même pipe. Chaque processus ferme ensuite l’extrémité qu’il n’utilise pas :

1. pipe(fd) : le processus a les descripteurs 3 (fd[0], lecture) et 4 (fd[1], écriture), reliés à la sortie et à l'entrée d'un pipe dans le noyau. 2. Après fork : le parent et l'enfant ont chacun 3 et 4, soit 4 descripteurs vers le même pipe. 3. Après les close : le parent garde seulement fd[1] (il écrit), l'enfant garde seulement fd[0] (il lit). EOF pour le lecteur quand plus aucun descripteur ne désigne l'entrée du pipe.

Un lecteur ne reçoit la fin de fichier (read retourne 0) que lorsque tous les descripteurs d’écriture du pipe sont fermés. Si le lecteur garde lui-même fd[1] ouvert, il ne verra jamais EOF.

À l’inverse, écrire dans un pipe dont toutes les extrémités de lecture sont fermées envoie le signal SIGPIPE à l’écrivain, qui est alors tué (même mécanisme pour les sockets, voir Sockets).

Exemple complet, où l’enfant envoie un message au parent, qui lit jusqu’à la fin de fichier :

 1#define _POSIX_C_SOURCE 200809L
 2#include <stdio.h>
 3#include <stdlib.h>
 4#include <string.h>
 5#include <sys/wait.h>
 6#include <unistd.h>
 7
 8int main(void) {
 9    int fd[2]; // fd[0] = lecture, fd[1] = écriture
10    if (pipe(fd) == -1) { perror("pipe"); exit(EXIT_FAILURE); }
11
12    pid_t pid = fork();
13    if (pid == -1) { perror("fork"); exit(EXIT_FAILURE); }
14
15    if (pid == 0) { // Enfant : il écrit
16        close(fd[0]); // ferme le côté lecture
17        const char *msg = "calcul terminé\n";
18        if (write(fd[1], msg, strlen(msg)) == -1) { perror("write"); }
19        close(fd[1]); // le parent pourra recevoir EOF
20        _exit(0);
21    } else { // Parent : il lit jusqu'à EOF
22        close(fd[1]); // ferme le côté écriture, sinon pas d'EOF
23        char buf[64];
24        ssize_t n;
25        while ((n = read(fd[0], buf, sizeof(buf) - 1)) > 0) {
26            buf[n] = '\0'; // read n'ajoute pas de '\0' à la fin des octets lus
27            printf("Parent a reçu : %s", buf);
28        }
29        if (n == -1) { perror("read"); }
30        close(fd[0]);
31        waitpid(pid, NULL, 0);
32    }
33    return 0;
34}

Sortie :

Parent a reçu : calcul terminé

Un pipe transporte un flux d’octets, pas des messages : deux write successifs ("ping#1" puis "ping#2") peuvent être reçus par un seul read ("ping#1ping#2"), et un read peut aussi recevoir moins d’octets que ce qui a été écrit. Pour découper des messages, on termine chacun par \n et on lit ligne par ligne, par exemple avec fgets (voir Simplifier les échanges avec la libc).

Pour voir les descripteurs d’un processus, et vérifier lesquels sont encore ouverts, listez /proc/<pid>/fd (un pipe apparaît sous la forme pipe:[numéro]) :

$ ls -l /proc/12346/fd
lrwx------ 1 user user 64 ... 0 -> /dev/pts/2
lrwx------ 1 user user 64 ... 1 -> /dev/pts/2
lrwx------ 1 user user 64 ... 2 -> /dev/pts/2
lr-x------ 1 user user 64 ... 3 -> pipe:[1234567]

FIFO

Une FIFO (First In First Out) est similaire à un pipe, mais existe dans le système de fichiers : il peut être ouvert par des processus non apparentés.

La FIFO est créée avec mkfifo (<sys/types.h>, <sys/stat.h>) :

int mkfifo(const char *pathname, mode_t mode);
  • pathname : chemin de la FIFO

  • mode : permissions initiales (affectées par umask)

Retourne 0, ou -1 en cas d’erreur (errno indique la cause, EEXIST si le fichier existe déjà).

L’un des processus (ou les deux) crée la FIFO :

const char *path = "canal";
if (mkfifo(path, 0666) == -1 && errno != EEXIST) {
    // si errno == EEXIST, alors la FIFO existe déjà, pas d'erreur dans ce cas
    perror("mkfifo");
}

Chaque processus ouvre ensuite la FIFO avec open : O_RDONLY côté lecteur, O_WRONLY côté écrivain. L”open est bloquant : il ne retourne que lorsque l’autre côté a lui aussi ouvert la FIFO. Un programme lancé seul reste donc en attente, sans rien afficher, tant que l’autre n’est pas lancé : c’est normal.

Diagramme de séquence entre le lecteur (terminal 1), la FIFO canal et l'écrivain (terminal 2). Le lecteur fait open(O_RDONLY) et reste bloqué tant qu'il n'y a pas d'écrivain. L'écrivain fait open(O_WRONLY) : les deux open retournent. L'écrivain fait write("Hello\n"), le lecteur reçoit 6 octets avec read ; l'écrivain fait close, le read suivant du lecteur renvoie 0 (EOF).

Les deux processus peuvent ainsi communiquer en écrivant d’un côté avec write(fd, message, taille_message) et en lisant de l’autre côté avec read(fd, buffer, taille_buffer). On peut aussi utiliser la libc pour simplifier les échanges.

Comme pour un pipe, le lecteur reçoit EOF (read retourne 0) quand tous les écrivains ont fermé la FIFO.

Avant de terminer, chaque côté ferme la FIFO avec close(fd) (ou avec fclose s’il a créé un FILE * avec fdopen, car fclose ferme aussi le descripteur, voir plus bas). À la fin, on peut supprimer la FIFO avec unlink (<unistd.h>) :

Par exemple, unlink(path); supprime la FIFO canal créée plus haut.

Vous pouvez aussi créer une FIFO dans le terminal :

mkfifo canal
# terminal 1
cat < canal
# terminal 2
echo "coucou" > canal
# terminal 1 affiche : coucou

Simplifier les échanges avec la libc

Pour simplifier l’échange de messages, il est possible de passer par les fonctions de la libc, comme fprintf et fgets, en créant un FILE * à partir du descripteur avec fdopen (<stdio.h>) :

FILE *fdopen(int fd, const char *mode);
  • fd : descripteur déjà ouvert (extrémité d’un pipe, FIFO ouverte avec open…)

  • mode : "r" ou "w", comme pour fopen ; il doit correspondre au mode d’ouverture du descripteur

Retourne un FILE * associé au descripteur, ou NULL en cas d’erreur (errno indique la cause).

Le FILE * ajoute un buffer de la libc devant le descripteur, comme pour un fichier (voir Bufferisation) : fprintf remplit le buffer, et les données ne partent dans le pipe que lorsque le buffer est vidé.

fdopen est une fonction POSIX : avec -std=c2x, il faut mettre #define _POSIX_C_SOURCE 200809L (ou #define _GNU_SOURCE) avant les #include, sinon elle n’est pas déclarée.

// côté parent : on envoie les données
// fd_ecriture : fd[1] pour un pipe, le résultat de open pour une FIFO
FILE *to_child = fdopen(fd_ecriture, "w");

// Le buffer est vidé à chaque '\n' (bufferisation par ligne)
setvbuf(to_child, NULL, _IOLBF, 0);

fprintf(to_child, "coucou!\n");

fclose(to_child);
// côté enfant : on reçoit les données
// fd_lecture : fd[0] pour un pipe, le résultat de open pour une FIFO
FILE *from_parent = fdopen(fd_lecture, "r");

char line[256];
if (fgets(line, (int)sizeof line, from_parent) != NULL) { // NULL : EOF ou erreur
    line[strcspn(line, "\r\n")] = '\0';
    printf("[enfant] le parent a envoyé : %s\n", line);
}

fclose(from_parent);

Par défaut, un FILE * obtenu avec fdopen sur un pipe ou une FIFO n’est vidé que lorsque son buffer est plein (bufferisation par bloc). On change ce comportement avec setvbuf (<stdio.h>) :

int setvbuf(FILE *stream, char *buf, int mode, size_t size);
  • stream : le flux à configurer, juste après son ouverture (avant toute lecture ou écriture)

  • buf : buffer à utiliser, ou NULL pour laisser la libc s’en charger

  • mode : _IONBF (pas de buffer), _IOLBF (par ligne) ou _IOFBF (par bloc), détaillés plus bas

  • size : taille de buf (0 quand buf vaut NULL)

Retourne 0, ou une valeur non nulle en cas d’erreur.

Par exemple, setvbuf(flux, NULL, _IOLBF, 0) passe le flux en bufferisation par ligne : le buffer est vidé à chaque \n, comme stdout sur un terminal.

Sans _IOLBF (ou sans fflush après chaque fprintf), le message resterait dans le buffer de l’écrivain, et le fgets d’en face attendrait jusqu’au fflush/fclose de l’écrivain. Si l’écrivain attend lui-même une réponse, chaque processus attend l’autre : le programme est bloqué définitivement, sans message d’erreur.

Si un FILE * a été créé avec fdopen, on le ferme avec fclose, qui ferme aussi le descripteur : il ne faut donc pas appeler close en plus.