Pds Vol2 2023 01 06
Pds Vol2 2023 01 06
Pds Vol2 2023 01 06
Giuseppe Lipari
January 6, 2023
1
version 0.1
2
Contents
1 Multiprocessing 5
1.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
1.2 Processus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
1.3 Création des processus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.4 Terminaison d’un processus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.5 Chargement en mémoire et exécution d’un processus externe . . . . . . . . . . . . . . . . 10
1.6 La primitive dup . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
1.7 Questions récapitulatives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
1.7.1 Question : wait . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
1.7.2 Exercice : Fork . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
1.7.3 Exercice : spawn . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
3
4 CONTENTS
Multiprocessing
1.1 Introduction
Dans ce deuxième volume, nous abordons la programmation multiprocessus dans les systèmes Unix.
Dans le premier chapitre, nous verrons comment créer et terminer un processus, et comment exécuter des
processus externes. Dans le deuxième chapitre, on aborde un mécanisme basilaire pour communiquer entre
processus, les signaux. Dans le troisième chapitre, on aborde d’autres mécanismes de communication
plus sophistiqués (IPC : interprocess communication en anglais) : les tubes (pipes en anglais) et les
sockets.
Dans le chapitre 4 on revient sur les threads, et étudie d’autres mécanismes de synchronisation avec
des exemples pratiques, et aussi dans d’autres langages de programmations.
Dans le chapitre 5 on étudie le problème de programmer une application client / serveur en utilisant
les processus ou les threads.
1.2 Processus
Un processus est caractérisé par :
un identifiant de processus c’est un entier positif qui identifie uniquement le processus dans le sys-
tème d’exploitation, aussi appelé PID (Process ID en anglais) ;
5
6 CHAPTER 1. MULTIPROCESSING
les descripteurs de fichiers un tableau contenant tous les descripteurs des fichiers ouverts par le pro-
cessus ;
l’état d’exécution c’est l’état d’exécution du processus, gardé dans les structures de données internes
au noyau.
Dans la suite, beaucoup de primitives repose sur la connaissance du PID d’un processus pour pouvoir
interagir. Un processus peut connaître son propre PID avec la primitive getpid() :
# include < sys / types .h >
# include < unistd .h >
Cette primitive ne peut jamais retourner une erreur, sa valeur est toujours un entier positif. Le premier
processus qui est créé au démarrage du système d’exploitation est le processus INIT qui a PID égale à 1.
Une fois démarré, un processus ne change pas son PID. En revanche, quand un processus se termine son
PID est disponible pour être affecté à un nouveau processus.
Cette syscall crée un nouveau processus quasi identique au processus qui appelle la fonction, sauf
quelques petites différences. Après l’exécution de la fork() il y a deux processus qui s’exécutent :
Le processus fils est un clone (une copie quasi identique) du processus père : toutes les variables
globales et locales du processus père sont copiées dans l’espace mémoire du processus fils avec la même
valeur. Le processus père et le processus fils ne partagent pas leur mémoire : chaque processus à sa
copie des variables en mémoire. Les deux processus continuent leurs exécutions à partir de l’instruction
suivante la fork().
Une première différence est, bien sûr, le PID : chaque processus a un PID unique. Une deuxième
différence est la valeur de retour de la fork(). En effet, la primitive est appelée par le processus père,
mais elle retourne deux fois : dans le processus père et dans le processus fils. Dans le père, la valeur de
retour est égale au PID du fils qui vient d’être créé ; dans le fils, la valeur de retour de la fork() est
zéro. Si la valeur de retour est négative (-1), la fonction fork() a échoué.
Considérez l’exemple suivant.
Example 1.3.1
1.4. TERMINAISON D’UN PROCESSUS 7
1 int main ()
2 {
3 pid_t child ;
4 int a = 0;
5
8 child = fork () ;
9 if ( child <0) {
10 printf ( " Could not create the process \ n " ) ;
11 exit ( -1) ;
12 } else if ( child >0) {
13 sleep (2) ; /* Code ex é cut é seulement */
14 printf ( " Parent (2) : a =% d \ n " , a ) ; /* par le processus p è re */
15 }
16 else {
17 a ++; /* Code ex é cut é seulement */
18 printf ( " Child (1) : a =% d \ n " , a ) ; /* par le processus fils */
19 }
20 printf ( " Fin \ n " ) ; /* Code ex é cut é par les deux processus */
21 }
la primitive fork() retourne le PID du processus fils dans le processus père, et zéro dans le processus
fils. Par consequent, même si les deux processus père et fils continuent leur exécution après le retour de
la primitive fork(), le code entre les lignes 12 et 15 est exécuté seulement par le processus père, et le
code entre les lignes 16 et 19 est exécuté seulement par le processus fils. Finalement, l’instruction à la
ligne 20 est exécutée par les deux processus.
La fonction sleep(2) à la ligne 13 suspends l’exécution du processus pendant 2 seconds. Dans la
plupart des exécutions1 , l’ordre d’affichage du programme précédent est :
Parent (1) : a=0
Child (1) : a=1
Fin
Parent (2) : a=0
Fin
Notez que, après l’exécution de la fork(), il y a deux variables nommées a : une de ces variables
réside dans l’espace mémoire du processus père, l’autre dans l’espace mémoire du fils. Quand le fils
incrément sa variable a, il n’a aucun effet sur la variable a du père. Notez que la chaîne de caractères
Fin est imprimée 2 fois : par le processus père et par le processus fils. ■
garanties. Dans ce cas, il n’y a pas de garantie que le processus fils se terminera avant le processus père ; dans certaines
conditions de surcharge, il est en effet possible que le père se termine avant le fils.
8 CHAPTER 1. MULTIPROCESSING
le deux cas, le processus retourne une valeur : soit comme argument des fonctions exit(retval) ou
_exit(retval) ; soit avec l’instruction return retval.
Cette valeur doit être un nombre entier avec signe sur 8 bits, donc entre -128 et 127. Si on donne un
nombre plus élévé, seulement le 8 bits de poids faible sont rétenus.
Le processus père peut attendre la terminaison d’un processus fils et obtenir sa valeur de retour avec
les primitives de la famille wait :
# include < sys / types .h >
# include < sys / wait .h >
La primitive wait() attende que un des fils du processus termine. Elle est donc une primitive blo-
quante. Elle a le fonctionnement suivant :
• si le processus appellant la wait n’a pas de fils, la syscall échoue, en retournant un code d’erreur ;
• si le processus appellant a créé des processus enfants avec fork(), mais qu’aucun n’est encore
terminé, la syscall bloque le processus ;
• si le processus appelant a des fils qui sont déjà terminés, le PID est renvoyé comme valeur de retour,
et la valeur de sortie est encodée dans la variable pointé par le paramètre status.
8 int main ()
9 {
10 pid_t ch ;
11
Le programme commence par créer un processus fils qui retourne la valeur 42 après avoir imprimé
son PID et le PID de son père obtenue avec la primitive getppid().
Le processus père attends la terminaison du fils et récupéres son PID dans la variable ch_p, sa valeur
est égale à la valeur de la variable ch. La valeur de retour est encodée dans l’octet de poids faible de
la variable status. La variable status contient aussi des informations sur l’état de terminaisons du
processus fils. On peut obtenir ces informations avec des macros comme WIFEXITED(status) qui vaut 1
si le processus s’est terminé normalement ; WIFSIGNALED(status) qui vaut 1 si le processus est terminé
après avoir reçu un signal (voir le chapitre suivant) ; et WIFSTOPPED() qui vaut 1 si le processus a été
suspendu (voir le chapitre suivant).
Dans le cas d’une terminaison normale, il est possible de récupérer la valeur de sortie avec la macro
WEXITSTATUS() qui retourne les 8 bits de poids faible de status.
À la ligne 20, le processus père appele la primitive sleep(2) qui suspend le processus pour 2 seconds.
Il est donc très probable qu’au moment de l’appel de la primitive wait() le processus fils aie déjà
terminé. Cependant, le noyau ne peut pas eliminer immediatement le processus fils ; il doit conserver
les informations qui seront plus tard récupérées par la wait() du processus père, et en particulier il doit
conserver son PID, sa valeur de sortie et les information concernant son état de terminaison.
Un processus qui a terminé, mais dont les informations n’ont pas encore été récupérees pas le processus
père, est dit un processus zombie : il ne peut plus exécuter, mais il ne peut pas encore être éliminé du
système. Il sera éliminé définitivement seulement au moment de la wait().
Si le processus père se termine sans avoir récupéré les informations avec un appel à wait(), les
processus fils sont hérités par le processus INIT. En effet, tous les processus descendent du processus
initial, INIT. Ce dernier est un processus spécial qui ne se termine jamais, et qui se charge d’appeler
la wait() en permanence pour éviter l’accumulation de processus zombie dans le système. Un fils peut
savoir si le processus père est terminé en comparant la valeur de retour de getppid() avec 1 (le PID de
INIT).
La primitive waitpid() est une version évoluée de la primitive wait(). Elle permet d’attendre un
processus fils en particulier, et de ne pas se bloquer dans le cas que le processus n’a pas encore terminé.
• Le premier paramètre indique le pid du processus fils qu’on veut attendre ; trois possibilités :
<-1 attend un des fils qui appartient au process group spécifié par la valeur -pid (voir la section
sur les process groups ci-dessous) ;
-1 attend un des fils ;
0 attend un des fils qui appartient au même process group du processus qui appel la waitpid() ;
>0 attend le fils avec le pid spécifié.
WNOHANG retourne immédiatement avec erreur si le fils n’a pas encore terminé (comportement non
bloquant) ;
WUNTRACED retourne aussi si le fils est stoppé (voir chapitre suivant sur les signaux) ;
WCONTINUED retourne aussi si le fils était stoppé et il a reçu un signal di SIGCONT (voir chapitre
suivant sur les signaux).
Pour simplicité, ici on décriera la fonction execv, on renvoie le lecteur au manuel de votre système
pour plus de détails sur les autres fonctions.
La fonction execv prends en paramètre :
• pathname : le chemin relatif ou absolu vers le fichier exécutable qui contient le code du programme
à charger ;
1.5. CHARGEMENT EN MÉMOIRE ET EXÉCUTION D’UN PROCESSUS EXTERNE 11
9 int main ()
10 {
11 pid_t ch ;
12 int status ;
13
14 ch = fork () ;
15 assert ( ch >= 0) ;
16 if ( ch == 0) {
17 char * arguments [] = { " ls " , " -a " , " -l " , 0 };
18 int ret = execv ( " / usr / bin / lsa " , arguments ) ;
19 printf ( " Valeur de retour : %d , errno = % d \ n " , ret , errno ) ;
20 perror ( " Error in the execv " ) ;
21 exit ( EXIT_FAILURE ) ;
22 }
23 wait (& status ) ;
24 if ( WIFEXITED ( status ) ) {
25 printf ( " Child process exiting with code % d \ n " , WEXITSTATUS ( status ) ) ;
26 }
12 CHAPTER 1. MULTIPROCESSING
27 else {
28 printf ( " Child process exited abnormally \ n " ) ;
29 }
30 return EXIT_SUCCESS ;
31 }
Le programme crée un processus fils qui essaie d’exécuter le programme externe /usr/bin/lsa.
Comme ce dernier n’existe pas, la primitive execv échouera et retournera la valeur -1 qui sera ensuite
imprimé par le processus fils, ainsi que la valeur de la variable errno et le message d’erreur correspondant
(No such file or directory). Le processus père attends la terminaison du fils et imprime sa valeur de sortie.
Compilez et exécutez ce programme pour vérifier la sortie. Ensuite, modifié le premier argument de
execv() en /usr/bin/ls et notez qu’il n’y a pas d’erreur, et donc la printf() à la ligne 16 n’est jamais
exécuté ; à sa place, la sortie de la commande "ls", c’est-à-dire le contenu du répertoire courant, sera
imprimé sur le terminal. ■
Comme montré dans l’exemple, les primitives fork() et execv() sont souvent utilisées en combinaison
; le processus père crée un fils qui charge le nouveau programme en mémoire avec une exec(). Cette
opération est souvent appéllée spawn en anglais et dans d’autres système d’exploitation est faite par
une seule primitive. Par exemple, l’API Windows fourni la famille de primitives _spawn2 qui créent un
nouveau processus qui exécute le nouveau programme.
Example 1.5.2
Considerez le programme suivant, qu’on appellera prog-pid.
1 # include < stdio .h >
2 # include < unistd .h >
3
13 return 0;
14 }
Ce programme simplement imprime sur le terminal son pid, le pid de son père et la liste des arguments
sur la ligne de commande.
Le programme suivant crée un fils pour exécuter le programme externe prog-pid après avoir imprimé
son pid et le pid de son père.
1 # include < stdio .h >
2 # include < unistd .h >
3 # include < stdlib .h >
4 # include < sys / types .h >
5 # include < sys / wait .h >
2 https://learn.microsoft.com/en-us/cpp/c-runtime-library/spawn-wspawn-functions?view=msvc-170
1.6. LA PRIMITIVE DUP 13
21 }
22 else {
23 printf ( " Parent process . My pid_t = \% d \ n " , getpid () ) ;
24 wait (0) ;
25 }
26 return EXIT_SUCCESS ;
27 }
Notez que les pids affichés par le fils et par le programme externe sont les mêmes : il s’agit du même
processus ! ■
Notes
Il faut faire attention que les entrées correspondantes dans la table de fichiers ouverts, qui se
trouve dans le noyau, ne sont pas dupliqués : les deux processus père et fils partagent donc les
informations mémorisées dans ces entrées, comme le file status flag, le current offset et le pointer
vers le v-node. Par conséquent, une lecture de fichier dans le père modifie l’offset aussi pour le fils
!
Lors d’un exec(), le tableau de descripteur de fichiers n’est pas remplacé. Après l’appel, le nouveau
programme peut reutiliser les mêmes fichiers ouverts avant l’appel à exec. Cette fonctionnalité peut être
utilisée pour faire une redirection.
Supposons de vouloir mémoriser la sortie du programme ls sur un fichier ls.log. Dans l’exemple
suivant, on utilisera la primitive dup2() avec fork() et exec() pour faire ça.
La primitive int dup2(int fd1, int fd2);
• ferme le descripteur fd2 (s’il était ouvert)
• copie le descripteur fd1 sur fd2.
Example 1.6.1
Dans le code suivant, on crée un processus fils, qui ouvre le fichier ls.log en écriture et duplique
son descripteur sur le descripteur 1 (stdout). À partir de maintenant, toute sortie sur le descripteur 1
(la sortie standard) sera écrite sur le fichier ls.log. Ensuite, le fils lance l’exécution du programme ls.
Comme la execv ne change pas le tableau de fichiers ouverts, la sortie du programme sera sur le fichier
ls.log.
1 # include < stdio .h >
2 # include < stdlib .h >
3 # include < unistd .h >
4 # include < assert .h >
5 # include < errno .h >
6 # include < sys / types .h >
7 # include < sys / wait .h >
8 # include < sys / stat .h >
9 # include < fcntl .h >
10
11 int main ()
12 {
13 pid_t ch ;
14 int status ;
15
16 ch = fork () ;
17 assert ( ch >= 0) ;
18 if ( ch == 0) {
19 int f = open ( " ls . log " , O_WRONLY | O_CREAT ) ;
20 if (f <0) {
21 perror ( " Cannot open ls . log file " ) ;
22 exit ( EXIT_FAILURE ) ;
1.7. QUESTIONS RÉCAPITULATIVES 15
23 }
24 dup2 (f , 1) ;
25 char * arguments [] = { " ls " , " -a " , " -l " , 0 };
26 int ret = execv ( " / usr / bin / ls " , arguments ) ;
27 fprintf ( stderr , " Valeur de retour : %d , errno = % d \ n " , ret , errno ) ;
28 perror ( " Error in the execv " ) ;
29 exit ( EXIT_FAILURE ) ;
30 }
31 wait (& status ) ;
32 if ( WIFEXITED ( status ) ) {
33 printf ( " Child process exiting with code % d \ n " , WEXITSTATUS ( status ) ) ;
34 }
35 else {
36 printf ( " Child process exited abnormally \ n " ) ;
37 }
38 return EXIT_SUCCESS ;
39 }
int main () {
pid_t child ;
int i ;
}
wait ( NULL ) ;
wait ( NULL ) ;
printf ( " % d \ n " , i ) ;
return EXIT_SUCCESS ;
}
La fonction crée un nouveau processus fils qui lance la execv() sur la commande cmd avec arguments
argv[] et attends sa terminaison. La fonction renvoie la valeur de sortie du programme cmd, ou -1 en
cas d’erreur.
Testez la nouvelle fonction en re-écrivant les exemples 1 et 2 avec spawnv.
Chapter 2
Nous avons vu comment créer et terminer des processus, comment charger le code et les données d’un
processus à partir d’une image sur disque et comment synchroniser un processus avec la fin de ses enfants
en récupérant les valeurs qui leur sont retournées. En général, les processus qui composent un programme
concurrent doivent cependant se synchroniser et communiquer entre eux de manière non triviale. De
plus, le noyau doit parfois informer un processus qu’une certaine condition (exceptionnelle ou non) s’est
produite.
Dans ce chapitre, nous traiterons des signaux, c’est-à-dire des communications asynchrones qui sig-
nalent une certaine condition. Les signaux peuvent être utilisés soit pour communiquer des conditions
d’erreur ou des exceptions, soit pour synchroniser deux processus entre eux.
• ignorer le signal,
Un signal qui n’a pas encore généré l’une des trois actions ci-dessus est dit suspendu.
Dans les sections suivantes, quelques primitives de noyau seront introduites pour permettre à un
processus de :
17
18 CHAPTER 2. LES SIGNAUX POSIX
La fonction retourne soit le pointer vers l’ancien signal handler, soit SIG_ERR en cas d’erreur.
Voici un petit exemple de configuration d’un nouveau handler pour le signal d’identification SIGUSR1.
# include < signal .h >
# include " myutils . h "
Le comportement de ce syscall n’est cependant pas standard, car dans certains systèmes, le signal
handler est réinitialisé à la valeur par défaut lorsqu’un signal est déclenché, tandis que sur d’autres il
reste réglé à la valeur spécifiée par signal même après le déclenchement du signal.
Pour éviter ce problème, la norme POSIX définit un nouvel appel système, sigaction, qui est standard
et donc un programme écrit en utilisant cet appel est certainement plus portable. Voici le prototype de
sigaction :
2.3. TODO COMPORTEMENT DES PRIMITIVES BLOQUANTES 19
oldact si différente de NULL, pointeur vers la structure dans laquelle les informations concernant l’ancien
gestionnaire sont enregistrées.
struct sigaction {
void (* sa_handler ) () ;
sigset_t sa_mask ;
int sa_flags ;
};
sa_handler Pointeur vers le code du nouveau gestionnaire, ou SIG_DFL si nous voulons le comportement
standard, ou SIG_IGN si nous voulons ignorer le signal.
sa_mask Masque les signaux qui doivent être bloqués lors de l’exécution du handler.
sa_flags Flags pour spécifier le comportement du système lors de l’exécution du gestionnaire.
Mécanismes de communications
Inter-processus
21
22 CHAPTER 3. MÉCANISMES DE COMMUNICATIONS INTER-PROCESSUS
Chapter 4
23
24 CHAPTER 4. LES THREADS – PART 2
Appendix A
25
26 APPENDIX A. SOLUTIONS AUX EXERCICES