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 :

 
Sélectionnez
int main(void)
{
    puts("Bonjour");
    fork();
    puts("Au revoir");
      return EXIT_SUCCESS;
}
 
Sélectionnez
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 :

 
Sélectionnez
#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é :

 
Sélectionnez
#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 :

 
Sélectionnez
    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 :

 
Sélectionnez
#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 :

 
Sélectionnez
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 :

 
Sélectionnez
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 :

 
Sélectionnez
# cat /proc/meminfo

Image non disponible

Exemple de code :

 
Sélectionnez
#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 :

 
Sélectionnez
# cat /proc/meminfo

Image non disponible

Nous pouvons voir que seuls 200 Mo sont consommés. Lançons maintenant un programme simple pour utiliser plus de mémoire :

 
Sélectionnez
int main(void)
{
    int x,status;
    static int arr1[52000000];
    puts("Au revoir");
    memset(arr1,0,sizeof(arr1));
    sleep(1000);
}

puis vérifions à nouveau :

 
Sélectionnez
# cat /proc/meminfo

Image non disponible

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 :

 
Sélectionnez
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

 
Sélectionnez
#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 :

 
Sélectionnez
#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.

 
Sélectionnez
#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 :

père.c
Sélectionnez
#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 :

fils.c
Sélectionnez
#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 :

 
Sélectionnez
#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.