I. Linux - l'appel système fork et ses pièges : introduction▲
Pour créer un processus fils sous Linux/Unix, nous pouvons utiliser clone(2) ou fork(2). On utilise clone(2) (spécifique Linux) pour mettre en œuvre les espaces de noms et le multithreading. On utilise fork(2) pour créer un vrai processus (fils) avec séparation de l'espace d'adressage et des ressources.
Dans cet article, je vais aborder fork, son modèle et ses pièges.
Commençons par un exemple simple :
int
main
(
void
)
{
puts
(
"
Bonjour
"
);
fork
(
);
puts
(
"
Au revoir
"
);
return
EXIT_SUCCESS;
}
Bonjour
Au revoir
Au revoir
Vous voyez « Au revoir » deux fois, car l'appel à fork se termine deux fois. Quand vous appelez fork, il y a création d'un processus fils et un double retour, un pour le processus père, et un pour le processus fils. Dans le processus père, fork retourne l'identifiant du processus fils, et dans le fils, fork retourne 0.
II. Un appel, deux retours : de quoi s'agit-il ?▲
Lors de l'utilisation d'un algorithme complexe, vous voudrez sûrement utiliser plus d'un processeur, mais vous devez écrire votre code en utilisant des modèles parallèles ; fork vous aide à implémenter un modèle simple : la jointure avec fork.
Nous avons par exemple un gros tableau (en mémoire partagée), et nous voulons calculer quelque chose dans chacun de ses éléments. Avec fork, c'est facile :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int
main
(
void
)
{
int
status;
puts
(
"
parent seulement
"
);
switch
(
fork
(
)) {
case
-
1
:
perror
(
"
parent: échec lors du fork
\n
"
);
return
EXIT_FAILURE;
case
0
:
printf
(
"
fils : effectue la moitié de la tâche
\n
"
);
return
EXIT_SUCCESS;
default
:
printf
(
"
parent: effectue la moitié de la tâche
\n
"
);
wait
(&
status);
break
;
}
puts
(
"
parent seulement
"
);
return
EXIT_SUCCESS;
}
Comme nous pouvons le voir, avant le fork, nous n'avons qu'un seul processus. Nous créons un processus fils, le fils fait son travail et s'arrête (en retournant une valeur à son processus père), le processus père fait son travail et attend la fin du processus fils donc après l'instruction switch, le père reste le seul processus actif.
L'instruction return EXIT_SUCCESS termine le processus fils et envoie le résultat au processus père. Le père sait pourquoi son fils s'est terminé (arrêt normal ou signal) et connaît sa valeur de retour (ou le numéro de signal le cas échéant) grâce au paramètre status de l'appel système wait (voir les pages de manuel pour plus de détails).
III. Qu'en est-il de la mémoire ?▲
Si nous déclarons un tableau puis le modifions après un fork, nous pouvons voir qu'il est dupliqué :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int
main
(
void
)
{
int
status;
int
arr[100000
]={
1
,2
,3
}
;
switch
(
fork
(
)) {
case
-
1
:
perror
(
"
parent: échec lors du fork
\n
"
);
return
EXIT_FAILURE;
case
0
:
sleep
(
3
);
printf
(
"
fils: arr[1]=%d
\n
"
,arr[1
]); // affiche arr[1]=2
break
;
default
:
arr[1
]=
200
;
wait
(&
status);
break
;
}
return
EXIT_SUCCESS;
}
Le fils attend trois secondes pour être sûr que le père a bien changé la valeur et affiche l'élément du tableau. Le résultat est arr[1]=2, car chaque processus dispose de son propre espace mémoire. Tout se passe comme si le noyau dupliquait la mémoire lors d'un fork.
IV. CoW (copy on write)▲
Pour gagner du temps lors d'un fork, le noyau ne duplique que le mappage mémoire. Par exemple, si nous avons cent pages mappées pour le tableau, le noyau va dupliquer les entrées TLB (Translation Lookaside Buffer) et basculer tous les mappages en lecture seule. Si les deux processus effectuent uniquement des lectures, ils accèdent à de la mémoire partagée, mais quand un des processus essaye d'écrire, il déclenche un défaut de page (page fault), le gestionnaire d'interruptions (trap) du noyau copie la page et change sa permission en lecture-écriture, puis remet le pointeur d'instruction à sa valeur initiale pour que le programme puisse exécuter à nouveau l'opération d'écriture.
Si l'on compare les temps d'exécution des deux affectations suivantes, la première prendra plus de temps :
x=
fork
(
);
if
(
x>
0
){
arr[1
]=
200
; // page fault, copie de la page, mise à jour de la TLB et écriture en mémoire
arr[2
]=
300
; // écriture en mémoire uniquement
wait
(&
status);
}
Pour le tester sur Ubuntu 64 bits, nous utilisons une fonction simple en assembleur x86 :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
/* lecture du compteur de cycles d'horloge (read timestamp counter) */
static
__inline__ unsigned
long
long
rdtsc
(
void
)
{
unsigned
hi, lo;
__asm__ __volatile__ (
"
rdtsc
"
: "
=a
"
(
lo), "
=d
"
(
hi));
return
((
unsigned
long
long
)lo)|(((
unsigned
long
long
)hi)<<
32
);
}
int
main
(
void
)
{
int
status;
long
long
t1,t2,t3,t4;
int
arr[1000000
]={
1
,2
,3
,4
,5
}
;
switch
(
fork
(
)) {
case
-
1
:
perror
(
"
parent: échec lors du fork
\n
"
);
return
EXIT_FAILURE;
case
0
:
printf
(
"
fils
\n
"
);
return
EXIT_SUCCESS;
default
:
t1=
rdtsc
(
);
arr[1
]=
20
;
t2=
rdtsc
(
);
arr[2
]=
30
;
t3=
rdtsc
(
);
arr[3
]=
40
;
t4=
rdtsc
(
);
printf
(
"
t1=%lld
\n
"
,t1);
printf
(
"
t2=%lld (%lld)
\n
"
,t2,t2-
t1);
printf
(
"
t3=%lld (%lld)
\n
"
,t3,t3-
t2);
printf
(
"
t4=%lld (%lld)
\n
"
,t4,t4-
t3);
wait
(&
status);
break
;
}
puts
(
"
parent seulement
"
);
return
EXIT_SUCCESS;
}
Sortie :
t1=6106259114485620
t2=6106259114485992 (372)
t3=6106259114486076 (84)
t4=6106259114486160 (84)
fils
parent seulement
Comme vous pouvez le voir dans la sortie, la première écriture a pris beaucoup plus de temps à cause du défaut de page (page fault).
V. Échec de fork ?▲
L'instruction fork échoue dans certaines situations :
- si on dépasse le nombre maximum de processus utilisateur autorisé (voir la commande limit -a) ;
- s'il n'y a plus de mémoire disponible ;
- s'il n'y a pas de MMU, etc. (voir les pages de manuel).
L'instruction fork échoue également si le processus père consomme plus de 50 % de la mémoire système. Prenons un exemple :
int
main
(
void
)
{
pid_t pid;
static
int
arr1[50000000
];
puts
(
"
Au revoir
"
);
memset
(
arr1,0
,sizeof
(
arr1));
pid=
fork
(
);
...
}
Le programme déclare un tableau de 200 Mo (car ici taille d'un int : 4 octets) et l'initialise en utilisant memset de façon à le mapper en mémoire physique. Comme nous l'avons vu, après un fork, la mémoire n'est pas dupliquée, mais le système vérifie si nous avons suffisamment de mémoire au cas où le fils écrirait. Si le système n'a pas 200 Mo libres, fork échouera.
Le comportement ci-dessus peut créer un bogue étrange dans la situation suivante :
- le père remplit un tableau de 200 Mo et fait un fork ;
- lors du fork, le système a 250 Mo de libre, fork retourne donc avec succès ;
- en raison du mécanisme de « Copy On Write », aucune mémoire supplémentaire n'est consommée ;
- un autre processus s'alloue et utilise 200 Mo ;
- le processus fils écrit des éléments du tableau, mais le noyau échoue lors du traitement d'un page fault.
Testons cela dans une image Qemu avec 512 Mo :
# cat /proc/meminfo
|
Exemple de code :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
int
main
(
void
)
{
int
pid,status,i;
static
int
arr1[50000000
];
memset
(
arr1,0
,sizeof
(
arr1));
pid=
fork
(
);
switch
(
pid) {
case
-
1
:
perror
(
"
parent: échec lors du fork
\n
"
);
return
EXIT_FAILURE;
case
0
:
for
(
i=
0
;i<
50
;i++
){
memset
(
arr1+
i*
1000000
,0
,4000000
);
sleep
(
30
);
puts
(
"
mem
"
);
}
break
;
default
:
printf
(
"
retour fork : %d
\n
"
, pid);
wait
(&
status);
break
;
}
return
EXIT_SUCCESS;
}
Lançons ce programme, puis effectuons un fork :
# cat /proc/meminfo
|
Nous pouvons voir que seuls 200 Mo sont consommés. Lançons maintenant un programme simple pour utiliser plus de mémoire :
int
main
(
void
)
{
int
x,status;
static
int
arr1[52000000
];
puts
(
"
Au revoir
"
);
memset
(
arr1,0
,sizeof
(
arr1));
sleep
(
1000
);
}
puis vérifions à nouveau :
# cat /proc/meminfo
|
Maintenant, le processus fils écrit 4 Mo toutes les 30 secondes et fait une copie des pages, quand il atteint les limites du système (100 Mo seulement) on observe un kernel oops :
app invoked oom-killer: gfp_mask=0x24200ca(GFP_HIGHUSER_MOVABLE), nodemask=0, order=0, oom_score_adj=0
app cpuset=/ mems_allowed=0
CPU: 0 PID: 750 Comm: app Not tainted 4.9.30 #35
Hardware name: ARM-Versatile Express
[<8011196c>] (unwind_backtrace) from [<8010cf2c>] (show_stack+0x20/0x24)
[<8010cf2c>] (show_stack) from [<803d32a4>] (dump_stack+0xac/0xd8)
[<803d32a4>] (dump_stack) from [<8023da88>] (dump_header+0x8c/0x1c4)
[<8023da88>] (dump_header) from [<801ef464>] (oom_kill_process+0x3a8/0x4b0)
[<801ef464>] (oom_kill_process) from [<801ef8c0>] (out_of_memory+0x124/0x418)
[<801ef8c0>] (out_of_memory) from [<801f48b4>] (__alloc_pages_nodemask+0xd6c/0xe0c)
[<801f48b4>] (__alloc_pages_nodemask) from [<80219338>] (wp_page_copy+0x78/0x580)
[<80219338>] (wp_page_copy) from [<8021a630>] (do_wp_page+0x148/0x670)
[<8021a630>] (do_wp_page) from [<8021cdd8>] (handle_mm_fault+0x33c/0xb00)
[<8021cdd8>] (handle_mm_fault) from [<80117930>] (do_page_fault+0x26c/0x384)
[<80117930>] (do_page_fault) from [<80101288>] (do_DataAbort+0x48/0xc4)
[<80101288>] (do_DataAbort) from [<8010dec4>] (__dabt_usr+0x44/0x60)
Exception stack(0x9ecc3fb0 to 0x9ecc3ff8)
3fa0: 77aaa7f8 00000000 0007c0f0 77dff000
3fc0: 00000000 00000000 000084a0 00000000 00000000 00000000 2b095000 7e94ad04
3fe0: 00000000 722ed8f8 00008678 2b13c158 20000010 ffffffff
Mem-Info:
active_anon:124980 inactive_anon:2 isolated_anon:0
active_file:23 inactive_file:31 isolated_file:0
unevictable:0 dirty:0 writeback:0 unstable:0
slab_reclaimable:457 slab_unreclaimable:598
mapped:46 shmem:8 pagetables:323 bounce:0
free:713 free_pcp:30 free_cma:0
Node 0 active_anon:499920kB inactive_anon:8kB active_file:92kB inactive_file:124kB unevictable:0kB isolated(anon):0kB isolated(file):0kB mapped:184kB dirty:0kB writeback:0kB shmem:32kB writeback_tmp:0kB unstable:0kB pages_scanned:56 all_unreclaimable? no
Normal free:2852kB min:2856kB low:3568kB high:4280kB active_anon:499920kB inactive_anon:8kB active_file:92kB inactive_file:124kB unevictable:0kB writepending:0kB present:524288kB managed:510824kB mlocked:0kB slab_reclaimable:1828kB slab_unreclaimable:2392kB kernel_stack:344kB pagetables:1292kB bounce:0kB free_pcp:120kB local_pcp:120kB free_cma:0kB
lowmem_reserve[]: 0 0
Normal: 7*4kB (UE) 5*8kB (UME) 4*16kB (UME) 1*32kB (U) 2*64kB (UM) 2*128kB (UM) 1*256kB (M) 0*512kB 0*1024kB 1*2048kB (U) 0*4096kB = 2852kB
62 total pagecache pages
0 pages in swap cache
Swap cache stats: add 0, delete 0, find 0/0
Free swap = 0kB
Total swap = 0kB
131072 pages RAM
0 pages HighMem/MovableOnly
3366 pages reserved
0 pages cma reserved
[ pid ] uid tgid total_vm rss nr_ptes nr_pmds swapents oom_score_adj name
[ 723] 0 723 598 6 3 0 0 0 syslogd
[ 725] 0 725 598 6 4 0 0 0 klogd
[ 737] 0 737 621 42 4 0 0 0 sh
[ 749] 0 749 51186 50747 103 0 0 0 app
[ 750] 0 750 51186 50813 102 0 0 0 app
[ 751] 0 751 51186 50752 103 0 0 0 eat
Out of memory: Kill process 750 (app) score 386 or sacrifice child
Killed process 750 (app) total-vm:204744kB, anon-rss:203180kB, file-rss:72kB, shmem-rss:0kB
oom_reaper: reaped process 750 (app), now anon-rss:4kB, file-rss:0kB, shmem-rss:0kB
Nous pouvons voir que le oops a été généré par un page fault (do_page_fault).
Pour éviter ce genre de cas, nous devons « préfaulter » le tableau (lire et écrire son contenu, au moins un élément par page) immédiatement après le fork.
VI. Les fichiers ne sont pas dupliqués après un fork▲
Il est important de comprendre qu'un objet de type descripteur de fichier n'est pas dupliqué lors d'un fork. De cette façon, nous pouvons partager des ressources entre le père et le fils. Tous les objets anonymes (pipes, mémoire partagée, etc.) ne peuvent être partagés qu'en utilisant leur descripteur de fichier. Une méthode (la plus simple) consiste à déclarer la ressource avant d'effectuer le fork, une autre méthode consiste à envoyer le descripteur de fichier en utilisant un unix domain socket. Si nous ouvrons un fichier ordinaire, le père et le fils utilisent le même objet noyau, c'est-à-dire la position, les drapeaux, les permissions et plus :
Exemple
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
int
main
(
void
)
{
int
status,fd,cc;
char
buf[32
];
fd=
open
(
__FILE__,O_RDWR);
if
(
fd==-
1
) {
perror
(
__FILE__);
return
EXIT_FAILURE;
}
switch
(
fork
(
)) {
case
-
1
:
perror
(
"
parent: échec lors du fork
\n
"
);
return
EXIT_FAILURE;
case
0
:
sleep
(
3
);
cc=
read
(
fd,buf,31
);
if
(
cc>
0
) {
buf[cc]=
'
\0
'
;
puts
(
buf);
}
return
EXIT_SUCCESS;
default
:
cc=
read
(
fd,buf,31
);
if
(
cc>
0
) {
buf[cc]=
'
\0
'
;
puts
(
buf);
}
wait
(&
status);
}
return
EXIT_SUCCESS;
}
Nous ouvrons un fichier (le code source lui-même), le père lit 31 caractères puis le fils lit aussi 31 caractères. Le père a mis à jour la position de lecture et le fils va donc lire les caractères à partir de l'emplacement où le père s'est arrêté.
Sortie :
#include <stdio.h>
#include <st
dlib.h>
#include <sys/types.h>
La fonction puts utilisée pour afficher les blocs de 31 caractères ajoute un saut de ligne à la fin de la chaîne à afficher. Ceci explique la rupture du texte en fin de deuxième ligne (stdlib.h coupé après les deux premiers caractères).
VII. Utiliser fork pour créer des tâches▲
Parce que la commande fork est spéciale, il est utile de bien connaître ses modes d'utilisation. Si nous voulons créer des tâches ayant du code commun (dans un projet temps réel par exemple), mais que nous voulons créer ces tâches sous forme de processus séparés (pas de threads) pour que si l'un de ceux-ci se crashe, il n'affecte pas les autres, nous créons les tâches dans une boucle puis le processus principal (boucle while dans la fonction main) attend la sortie de ses enfants et les relance le cas échéant.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/prctl.h>
#include <sys/types.h>
#include <sys/wait.h>
void
task1
(
void
)
{
prctl
(
PR_SET_NAME,"
tâche 1
"
);
while
(
1
)
{
puts
(
"
tâche 1
"
);
sleep
(
10
);
}
}
void
task2
(
void
)
{
prctl
(
PR_SET_NAME,"
tâche 2
"
);
while
(
1
)
{
puts
(
"
tâche 2
"
);
sleep
(
10
);
}
}
void
task3
(
void
)
{
prctl
(
PR_SET_NAME,"
tâche 3
"
);
while
(
1
)
{
puts
(
"
tâche 3
"
);
sleep
(
10
);
}
}
void
task4
(
void
)
{
prctl
(
PR_SET_NAME,"
tâche 4
"
);
while
(
1
)
{
puts
(
"
tâche 4
"
);
sleep
(
10
);
}
}
void
task5
(
void
)
{
int
c=
0
;
prctl
(
PR_SET_NAME,"
tâche 5
"
);
while
(
1
)
{
c++
;
if
(
c==
5
)
exit
(
12
);
puts
(
"
tâche 5
"
);
sleep
(
3
);
}
}
void
(*
arr[5
])(
void
)={
task1,task2,task3,task4,task5}
;
int
findpid
(
int
*
arr,int
size,int
val)
{
int
i;
for
(
i=
0
;i<
size;i++
)
{
if
(
arr[i] ==
val)
return
i;
}
return
-
1
;
}
int
main
(
void
) {
int
ids[5
];
int
v,i,status,pid,pos;
for
(
i=
0
; i<
5
; i++
)
{
v=
fork
(
);
if
(
v ==
0
)
{
arr[i](
);
return
EXIT_SUCCESS;
}
ids[i]=
v;
}
while
(
1
)
{
pid=
wait
(&
status);
pos=
findpid
(
ids,5
,pid);
printf
(
"
Au revoir père %d %d
\n
"
,pid,status);
printf
(
"
fils existe avec le statut %d
\n
"
, WEXITSTATUS
(
status));
v=
fork
(
);
if
(
v==
0
)
{
arr[pos](
);
return
EXIT_SUCCESS;
}
ids[pos]=
v;
}
return
EXIT_SUCCESS;
}
Si nous lançons le programme ci-dessus, nous verrons six processus s'exécuter. Le processus principal attend la fin d'un processus fils (par un signal ou la sortie normale) et le recrée. Si vous envoyez un signal à l'un des enfants, vous le verrez renaître. Il faut tuer le processus principal, puis chacun de ses fils pour arrêter les boucles infinies.
VIII. Père-fils avec des codes différents▲
Parfois, on a besoin de deux processus ayant une relation père-fils (pour envoyer des signaux ou partager des ressources), mais ayant un code complètement différent. Dans un routeur sans fil par exemple, nous avons un processus de gestion du routage et un serveur web. Nous voulons que le processus serveur web envoie un signal au gestionnaire de routage à chaque changement de configuration. Nous pouvons utiliser fork avec execve pour l'implémenter.
Par exemple : deux processus utilisant un pipe pour la communication.
Application parente :
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int
main
(
int
argc, char
**
argv) {
char
buf[24
];
int
fd,cc,i=
0
;
fd=
atoi
(
argv[0
]);
puts
(
"
parent démarré :
"
);
while
(
1
) {
i++
;
sprintf
(
buf,"
bonjour : %12d
"
,i);
cc=
write
(
fd,buf,23
);
if
(
cc==-
1
) break
;
sleep
(
2
);
}
return
EXIT_SUCCESS;
}
Compilez-la et appelez l'exécutable père.
Application fille :
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int
main
(
int
argc,char
**
argv)
{
char
buf[24
];
int
fd,cc;
puts
(
"
processus fils démarré
"
);
fd=
atoi
(
argv[0
]);
while
(
1
)
{
cc=
read
(
fd,buf,23
);
if
(
cc<
1
) break
;
buf[cc]=
'
\0
'
;
puts
(
buf);
}
return
EXIT_SUCCESS;
}
Compilez-la et appelez l'exécutable fils.
Écrivons maintenant le code qui va les connecter :
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int
main
(
void
)
{
int
arr[2
];
char
arg0[32
];
if
(
pipe
(
arr) ==
-
1
) {
perror
(
"
pipe
"
);
return
EXIT_FAILURE;
}
switch
(
fork
(
)) {
case
-
1
:
perror
(
"
fork
"
);
break
;
default
:
puts
(
"
démarrage parent
"
);
close
(
arr[0
]);
sprintf
(
arg0,"
%d
"
,arr[1
]);
execlp
(
"
./père
"
,arg0,NULL
);
break
;
case
0
:
puts
(
"
démarrage fils
"
);
close
(
arr[1
]);
sprintf
(
arg0,"
%d
"
,arr[0
]);
execlp
(
"
./fils
"
,arg0,NULL
);
break
;
}
return
EXIT_FAILURE;
}
Compilez et lancez ce programme (en ayant placé les exécutables précédents dans le même répertoire).
Nous créons un pipe. Dans le code du parent, nous fermons le descripteur de fichier de lecture du pipe, et transmettons le numéro de descripteur de fichier d'écriture comme premier argument de la fonction main (habituellement, cet argument contient le nom du programme). Dans le code du fils, nous faisons l'inverse.
Notez que dans ce modèle, si nous voulons changer le type de communication pour utiliser les unix domain socket (ou tout autre objet basé sur un descripteur de fichier), nous ne devrons modifier et compiler que ce dernier programme.
IX. Notes de la rédaction▲
Article par Liran B.H, le 17 décembre 2017, que nous remercions pour son autorisation de publication.
Source : http://devarea.com/linux-fork-system-call-and-its-pitfalls/
La rédaction remercie Chrtophe pour sa traduction, ainsi que jlliagre et ClaudeLELOUP pour leurs corrections.