Previous Up Next

Chapter 3  Les processus

Un processus est un programme en train de s’exécuter. Un processus se compose d’un texte de programme (du code machine) et d’un état du programme (point de contrôle courant, valeur des variables, pile des retours de fonctions en attente, descripteurs de fichiers ouverts, etc.)

Cette partie présente les appels systèmes Unix permettant de créer de nouveaux processus et de leur faire exécuter d’autres programmes.

3.1  Création de processus

L’appel système fork permet de créer un processus.

     
   val fork : unit -> int

Le nouveau processus (appelé “le processus fils”) est un clone presque parfait du processus qui a appelé fork (dit “le processus père” 1): les deux processus (père et fils) exécutent le même texte de programme, sont initialement au même point de contrôle (le retour de fork), attribuent les mêmes valeurs aux variables, ont des piles de retours de fonctions identiques, et détiennent les mêmes descripteurs de fichiers ouverts sur les mêmes fichiers. Ce qui distingue les deux processus, c’est la valeur renvoyée par fork: zéro dans le processus fils, un entier non nul dans le processus père. En testant la valeur de retour de fork, un programme peut donc déterminer s’il est dans le processus père ou dans le processus fils, et se comporter différemment dans les deux processus:

     
   match fork() with
     0 -> (* code execute uniquement par le fils *)
   | _ -> (* code execute uniquement par le pere *)

L’entier non nul renvoyé par fork dans le processus père est l’identificateur du processus fils. Chaque processus est identifié dans le noyau par un numéro, l’identificateur du processus (process id). Un processus peut obtenir son numéro d’identification par l’appel getpid"()".

Le processus fils est initialement dans le même état que le processus père (mêmes valeurs des variables, mêmes descripteurs de fichiers ouverts). Cet état n’est pas partagé entre le père et le fils, mais seulement dupliqué au moment du fork. Par exemple, si une variable est liée à une référence avant le fork, une copie de cette référence et de son contenu courant est faite au moment du fork; après le fork, chaque processus modifie indépendamment “sa” référence, sans que cela se répercute sur l’autre processus.

De même, les descripteurs de fichiers ouverts sont dupliqués au moment du fork: l’un peut être fermé et l’autre reste ouvert. Par contre, les deux descripteurs désignent la même entrée dans la table des fichiers (qui est allouée dans la mémoire système) et partagent donc leur position courante: si l’un puis l’autre lit, chacun lira une partie différente du fichier; de même les déplacement effectués par l’un avec lseek sont immédiatement visibles par l’autre. (Les descripteurs du fils et du père se comportent donc comme les deux descripteurs argument et résultat après exécution de la commande dup, mais sont dans des processus différents au lieu d’être dans le même processus.)

3.2  Exemple complet: la commande leave

La commande leave hhmm rend la main immédiatement, mais crée un processus en tâche de fond qui, à l’heure hhmm, rappelle qu’il est temps de partir.

     
   open Sys;;
   open Unix;;
   
   let leave () =
     let hh = int_of_string (String.sub Sys.argv.(1) 0 2)
     and mm = int_of_string (String.sub Sys.argv.(1) 2 2) in
     let now = localtime(time()) in
     let delay = (hh - now.tm_hour) * 3600 + (mm - now.tm_min) * 60 in
     if delay <= 0 then begin
       print_endline "Hey! That time has already passed!";
       exit 0
     end;
     if fork() <> 0 then exit 0;
     sleep delay;
     print_endline "\007\007\007Time to leave!";
     exit 0;;
   
   handle_unix_error leave ();;

On commence par un parsing rudimentaire de la ligne de commande, pour extraire l’heure voulue. On calcule ensuite la durée d’attente, en secondes (ligne 8). (L’appel time renvoie la date courante, en secondes depuis le premier janvier 1970, minuit. La fonction localtime transforme ça en année/mois/jour/heures/minutes/secondes.) On crée alors un nouveau processus par fork. Le processus père (celui pour lequel fork renvoie un entier non nul) termine immédiatement. Le shell qui a lancé leave rend donc aussitôt la main à l’utilisateur. Le processus fils (celui pour lequel fork renvoie zéro) continue à tourner. Il ne fait rien pendant la durée indiquée (appel sleep), puis affiche son message et termine.

3.3  Attente de la terminaison d’un processus

L’appel système wait attend qu’un des processus fils créés par fork ait terminé, et renvoie des informations sur la manière dont ce processus a terminé. Il permet la synchronisation père-fils, ainsi qu’une forme très rudimentaire de communication du fils vers le père.

     
   val wait : unit -> int * process_status
   val waitpid : wait_flag list -> int -> int * process_status

L’appel système primitif est waitpid et la fonction wait() n’est qu’un racourci pour l’expression waitpid [] (-1). L’appel système waitpid [] p attend la terminaison du processus p, si p>0 est strictement positif, ou d’un sous-ensemble de processus fils, du même groupe si p=0, quelconque si p=−1, ou du groupe −p si p<−1.

Le premier résultat est le numéro du processus fils intercepté par wait. Le deuxième résultat peut être:

WEXITED(r)le processus fils a terminé normalement (par exit ou en arrivant au bout du programme); r est le code de retour (l’argument passé à exit)
WSIGNALED(sig)le processus fils a été tué par un signal (ctrl-C, kill, etc.; voir plus bas pour les signaux); sig identifie le type du signal
WSTOPPED(sig)le processus fils a été stoppé par le signal sig; ne se produit que dans le cas très particulier où un processus (typiquement un debugger) est en train de surveiller l’exécution d’un autre (par l’appel ptrace).

Si un des processus fils a déjà terminé au moment où le père exécute wait, l’appel wait retourne immédiatement. Sinon, wait bloque le père jusqu’à ce qu’un des fils termine (comportement dit “de rendez-vous”). Pour attendre N fils, il faut répéter N fois wait.

La commande waitpid accepte deux options facultatifs comme premier argument: L’option WNOHANG indique de ne pas attendre, s’il il a des fils qui répondent à la demande mais qui n’ont pas encore terminé. Dans ce cas, le premier résultat est 0 et le second non défini. L’option WUNTRACED indique de retourner également les fils qui ont été arrêté par le signal sigstop. La commande lève l’erreur ECHILD si aucun fils du processus appelant ne répond à la spécification p (en particulier, si p vaut −1 et que le processus courrant n’a pas ou plus de fils).

Exemple:

la fonction fork_search ci-dessous fait une recherche linéaire dans un vecteur, en deux processus. Elle s’appuie sur la fonction simple_search, qui fait la recherche linéaire simple.

     
   open Unix
   exception Found;;
   
   let simple_search cond v =
     try
       for i = 0 to Array.length v - 1 do
         if cond v.(ithen raise Found
       done;
       false
     with Found -> true;;
   
   let fork_search cond v =
     let n = Array.length v in
     match fork() with
       0 ->
         let found = simple_search cond (Array.sub v (n/2) (n-n/2)) in
         exit (if found then 0 else 1)
     | _ ->
         let found = simple_search cond (Array.sub v 0 (n/2)) in
         match wait() with
           (pidWEXITED retcode) -> found or (retcode = 0)
         | (pid_)               -> failwith "fork_search";;

Après le fork, le processus fils parcourt la moitié haute du tableau, et sort avec le code de retour 1 s’il a trouvé un élément satisfaisant le prédicat cond, ou 0 sinon (lignes 16 et 17). Le processus père parcourt la moitié basse du tableau, puis appelle wait pour se synchroniser avec le processus fils (lignes 19 et 20). Si le fils a terminé normalement, on combine son code de retour avec le booléen résultat de la recherche dans la moitié basse du tableau. Sinon, quelque chose d’horrible s’est produit, et la fonction fork_search échoue.

En plus de la synchronisation entre processus, l’appel wait assure aussi la récupération de toutes les ressources utilisées par le processus fils. Quand un processus termine, il passe dans un état dit “zombie”, où la plupart des ressources qu’il utilise (espace mémoire, etc) ont été désallouées, mais pas toutes: il continue à occuper un emplacement dans la table des processus, afin de pouvoir transmettre son code de retour au père via l’appel wait. Ce n’est que lorsque le père a exécuté wait que le processus zombie disparaît de la table des processus. Cette table étant de taille fixe, il importe, pour éviter le débordement, de faire wait sur les processus qu’on lance.

Si le processus père termine avant le processus fils, le fils se voit attribuer le processus numéro 1 (init) comme père. Ce processus contient une boucle infinie de wait, et va donc faire disparaître complètement le processus fils dès qu’il termine. Ceci débouche sur une technique utile dans le cas où on ne peut pas facilement appeler wait sur chaque processus qu’on a créé (parce qu’on ne peut pas se permettre de bloquer en attendant la terminaison des fils, par exemple): la technique “du double fork”.

     
   match fork() with
     0 -> if fork() <> 0 then exit 0;
          (* faire ce que le fils doit faire *)
   | _ -> wait();
          (* faire ce que le pere doit faire *)

Le fils termine par exit juste après le deuxième fork. Le petit-fils se retrouve donc orphelin, et est adopté par le processus init. Il ne laissera donc pas de processus zombie. Le père exécute wait aussitôt pour récupérer le fils. Ce wait ne bloque pas longtemps puisque le fils termine très vite.

3.4  Lancement d’un programme

Les appels système execve, execv et execvp lancent l’exécution d’un programme à l’intérieur du processus courant. Sauf en cas d’erreur, ces appels ne retournent jamais: ils arrêtent le déroulement du programme courant et se branchent au début du nouveau programme.

     
   val execve : string -> string array -> string array -> unit
   val execv  : string -> string array -> unit
   val execvp : string -> string array -> unit

Le premier argument est le nom du fichier contenant le code du programme à exécuter. Dans le cas de execvp, ce nom est également recherché dans les répertoires du path d’exécution (la valeur de la variable d’environnement PATH).

Le deuxième argument est la ligne de commande à transmettre au programme exécuté; ce vecteur de chaînes va se retrouver dans Sys.argv du programme exécuté.

Dans le cas de execve, le troisième argument est l’environnement à transmettre au programme exécuté; execv et execvp transmettent inchangé l’environnement courant.

Les appels execve, execv et execvp ne retournent jamais de résultat: ou bien tout se passe sans erreurs, et le processus se met à exécuter un autre programme; ou bien une erreur se produit (fichier non trouvé, etc.), et l’appel déclenche l’exception Unix_error.

Exemple:

les trois formes ci-dessous sont équivalentes:

     
   execve "/bin/ls" [|"ls""-l""/tmp"|] (environment())
   execv  "/bin/ls" [|"ls""-l""/tmp"|]
   execvp "ls"      [|"ls""-l""/tmp"|]

Exemple:

voici un “wrapper” autour de la commande grep, qui ajoute l’option -i (confondre majuscules et minuscules) à la liste d’arguments:

     
   open Sys;;
   open Unix;;
   let grep () =
     execvp "grep"
       (Array.concat
          [ [|"grep""-i"|];
            (Array.sub Sys.argv 1 (Array.length Sys.argv - 1)) ])
   ;;
   handle_unix_error grep ();;

Exemple:

voici un “wrapper” autour de la commande emacs, qui change le type du terminal:

     
   open Sys;;
   open Unix;;
   let emacs () =
     execve "/usr/bin/emacs" Sys.argv
       (Array.concat [ [|"TERM=hacked-xterm"|]; (environment()) ]);;
   handle_unix_error emacs ();;

C’est le même processus qui a fait exec qui exécute le nouveau programme. En conséquence, le nouveau programme hérite de certains morceaux de l’environnement d’exécution du programme qui a fait exec:

3.5  Exemple complet: un mini-shell

Le programme qui suit est un interprète de commandes simplifié: il lit des lignes sur l’entrée standard, les coupe en mots, lance la commande correspondante, et recommence jusqu’à une fin de fichier sur l’entrée standard. On commence par la fonction qui coupe une chaîne de caractères en une liste de mots. Pas de commentaires pour cette horreur.

     
   open Unix;;
   
   let split_words s =
     let rec skip_blanks i =
       if i < String.length s & s.[i] = ' '
       then skip_blanks (i+1)
       else i in
     let rec split start i =
       if i >= String.length s then
         [String.sub s start (i-start)]
       else if s.[i] = ' ' then
         let j = skip_blanks i in
         String.sub s start (i-start) :: split j j
       else
         split start (i+1) in
     Array.of_list (split 0 0);;

On passe maintenant à la boucle principale de l’interpréteur.

     
   let exec_command cmd =
     try execvp cmd.(0) cmd
     with Unix_error(err__) ->
       Printf.printf "Cannot execute %s : %s\n%!"
         cmd.(0) (error_message err);
       exit 255
   
   let print_status program status =
     match status with
       WEXITED 255 -> ()
     | WEXITED status ->
         Printf.printf "%s exited with code %d\n%!" program status;
     | WSIGNALED signal ->
         Printf.printf "%s killed by signal %d\n%!" program signal;
     | WSTOPPED signal ->
         Printf.printf "%s stopped (???)\n%!" program;;

La fonction exec_command exécute une commande avec récupération des erreurs. Le code de retour 255 indique que la commande n’a pas pu être exécutée. (Ce n’est pas une convention standard; on espère que peu de commandes renvoient le code de retour 255.) La fonction print_status décode et imprime l’information d’état retournée par un processus, en ignorant le code de retour 255.

     
   let minishell () =
     try
       while true do
         let cmd = input_line Pervasives.stdin in
         let words = split_words cmd in
         match fork() with
           0 -> exec_command words
         | pid_son ->
             let pidstatus = wait() in
             print_status "Program" status
       done
     with End_of_file ->
       ();;
   
   handle_unix_error minishell ();;

À chaque tour de boucle, on lit une ligne sur l’entrée standard, via la fonction input_line de la bibliothèque standard Pervasives. (Cette fonction déclenche l’exception End_of_file quand la fin de fichier est atteinte, faisant sortir de la boucle.) On coupe la ligne en mots, puis on fait fork. Le processus fils fait exec_command pour lancer la commande avec récupération des erreurs. Le processus père appelle wait pour attendre que la commande termine et imprime l’information d’état renvoyée par wait.

Exercice 10   Ajouter la possibilité d’exécuter des commandes en tâche de fond, si elles sont suivies par &.
(Voir le corrigé)

1
Les dénominations politiquement correctes sont “processus parent” et “processus enfant”. Il est aussi accepté d’alterner avec “incarnation mère” et “incarnation fille”.

Previous Up Next