Précédent Index Suivant

Processus

Unix est un système qui associe à chaque exécution d'un programme un processus. Dans [CDM96] Card, Dumas et Mével résument ainsi la différence entre programme et processus : << un programme en lui-même n'est pas un processus : un programme est une entité passive (un fichier exécutable résidant sur un disque), alors qu'un processus est une entité active avec un compteur ordinal spécifiant l'instruction suivante à exécuter et un ensemble de ressources associées. >>

Unix est un système dit multi-tâches : plusieurs processus peuvent être exécutés simultanément. Il est préemptif, l'exécution des processus est confiée à un processus particulier chargé de leur ordonnancement. Un processus n'est donc pas entièrement maître de ses ressources. Au premier chef, un processus n'est pas maître du moment de son exécution et ce n'est pas parce qu'il est créé qu'un processus est exécuté sur le champ.

Chaque processus dispose de son propre espace mémoire. Les processus peuvent communiquer à travers des fichiers ou des tubes de communication. Nous sommes en présence du modèle de parallélisme à mémoire distribuée simulé sur une seule machine.

Le système attribue aux processus un identificateur unique : un entier appelé PID (Process IDentifier). De plus, sous Unix, à l'exception notable du processus initial, tout processus est engendré par un autre processus que l'on appelle son père.

On peut connaître l'ensemble des processus actifs par la commande Unix ps3 :
$ ps -f
PID    PPID    CMD
1767   1763   csh
2797   1767   ps -f
L'emploi de l'option -f fait apparaître, pour chaque processus actif, son identificateur (PID), celui de son père (PPID) et le programme invoqué (CMD). Ici, nous avons deux processus, l'interprète de commandes csh et la commande ps elle-même. On note que ps étant invoquée depuis l'interprète de commandes csh, le père de son processus est le processus associé à l'exécution de csh.

Exécution d'un programme

Environnement d'exécution

Trois valeurs sont associées à un programme exécuté depuis un interprète de commandes du système d'exploitation :
  1. la ligne de commande ayant servi à son exécution qui est contenue dans la valeur Sys.argv,
  2. les variables d'environnement de l'interprète de commandes que l'on peut récupérer grâce à la fonction Sys.getenv,
  3. un statut d'exécution lorsque le programme termine.
Ligne de commande
La ligne de commande permet de récupérer les arguments ou options d'appel d'un programme. Celui-ci peut alors déterminer son comportement en fonction de ces valeurs. En voici un petit exemple. On écrit le petit programme suivant dans le fichier argv_ex.ml :

if Array.length Sys.argv = 1 then
Printf.printf "Hello world\n"
else if Array.length Sys.argv = 2 then
Printf.printf "Hello %s\n" Sys.argv.(1)
else Printf.printf "%s : trop d'arguments\n" Sys.argv.(0)


On le compile :
$ ocamlc -o argv_ex argv_ex.ml
Et on exécute successivement :
$ argv_ex
Hello world
$ argv_ex lecteur
Hello lecteur
$ argv_ex cher lecteur
./argv_ex : trop d'arguments
Variables d'environnement
Les variables d'environnement contiennent différentes valeurs nécessaires à la bonne marche du système ou de certaines applications. Le nombre et le nom de ces variables dépendent à la fois du système d'exploitation et de configurations propres aux utilisateurs. Le contenu de ces variables est accessible par la fonction getenv qui prend en argument le nom d'une variable d'environnement sous forme d'une chaîne de caractères :

# Sys.getenv "HOSTNAME";;
- : string = "zinc.pps.jussieu.fr"


Statut d'exécution

La valeur de retour d'un programme est un entier fixé, en général, automatiquement par le système suivant que le programme se termine en erreur ou non. Le développeur peut toujours mettre fin explicitement à son programme en précisant la valeur du statut d'exécution par appel à la fonction :

# Pervasives.exit ;;
- : int -> 'a = <fun>


Lancement de processus

Un programme est lancé à partir d'un processus que l'on appelle le processus courant. L'exécution du programme devient un nouveau processus. On obtient trois cas de figure : On peut aussi dupliquer le processus courant et obtenir ainsi deux instances du même processus qui ne diffèrent que par leur PID. C'est le fameux fork que nous décrivons dans la suite.

Processus indépendants

Le module Unix offre une fonction portable de lancement d'un processus correspondant à l'exécution d'un programme.

# Unix.create_process ;;
- : string ->
string array ->
Unix.file_descr -> Unix.file_descr -> Unix.file_descr -> int
= <fun>
Le premier argument est le nom du programme (qui peut être un chemin), le deuxième, le tableau d'arguments du programme, les trois derniers sont les descripteurs devant servir à l'entrée standard, à la sortie standard et à la sortie en erreur du processus. La valeur de retour est le numéro du processus créé.

Il existe une variante de cette fonction permettant de préciser la valeur de variables d'environnement :

# Unix.create_process_env ;;
- : string ->
string array ->
string array ->
Unix.file_descr -> Unix.file_descr -> Unix.file_descr -> int
= <fun>
Ces deux fonctions sont utilisables sous Unix ou Windows.

Empilement de processus

Il n'est pas toujours utile que le processus lancé le soit de manière concurrente. En effet le processus père peut avoir besoin d'attendre la fin du processus qu'il vient de lancer pour poursuivre sa tâche. Les deux fonctions suivantes prennent comme argument le nom d'une commande et l'exécutent.

# Sys.command;;
- : string -> int = <fun>
# Unix.system;;
- : string -> Unix.process_status = <fun>
Elles diffèrent par le type du code retour. Le type process_status est détaillé à la page ??. Pendant l'exécution de la commande le processus père est bloqué.

Remplacement de processus courant

Le remplacement du processus courant par la commande qu'il vient de lancer permet de limiter le nombre de processus en cours d'exécution. Les quatre fonctions suivantes effectuent ce travail :

# Unix.execv ;;
- : string -> string array -> unit = <fun>
# Unix.execve ;;
- : string -> string array -> string array -> unit = <fun>
# Unix.execvp ;;
- : string -> string array -> unit = <fun>
# Unix.execvpe ;;
- : string -> string array -> string array -> unit = <fun>
Leur premier argument est le nom du programme. En utilisant execvp ou execvpe, ce nom peut indiquer un chemin dans l'arborescence des fichiers. Le second argument contient les arguments du programme qu'il est possible de passer sur la ligne de commande. Le dernier argument des fonctions execve et execvpe permet en plus d'indiquer la valeur des variables système utiles au programme.

Création d'un processus par duplication

L'appel système originel de création de processus sous Unix est :

# Unix.fork ;;
- : unit -> int = <fun>


La fonction fork engendre un nouveau processus et non un nouveau programme. Son effet exact est de dupliquer le processus appelant. Le code du nouveau processus est le même que celui de son père. Sous Unix un même code peut servir à plusieurs processus, chacun possédant son propre contexte d'exécution. On parle alors de code réentrant.

Voyons cela sur le petit programme suivant (on utilise la fonction getpid qui retourne le PID du processus associé à l'exécution du programme) :
Printf.printf "avant fork : %d\n" (Unix.getpid ())  ;;
flush stdout ;;
Unix.fork () ;;
Printf.printf "après fork : %d\n" (Unix.getpid ()) ;;
flush stdout ;;


On obtient l'affichage suivant :
avant fork : 1447
après fork : 1447
après fork : 1448


Après l'exécution du fork, deux processus exécutent la suite du code. C'est ce qui provoque deux fois l'affichage du PID << après >>. On remarque qu'un des processus a gardé le PID de départ (le père) alors que l'autre en a un nouveau (le fils) qui correspond à la valeur de retour de l'appel à fork. Pour le processus père la valeur de retour de fork est le PID du fils alors que pour le fils, elle vaut 0.

C'est cette différence de valeur de retour de fork qui permet, dans un même programme source, de différencier le code exécuté par le fils du code exécuté par le père :
Printf.printf "avant fork : %d\n" (Unix.getpid ())  ;;
flush stdout ;;
let pid = Unix.fork () ;;
if pid=0 then (* -- Code du fils *)
Printf.printf "je suis le fils : %d\n" (Unix.getpid ())
else (* -- Code du pere *)
Printf.printf "je suis le père : %d du fils : %d\n" (Unix.getpid ()) pid ;;
flush stdout ;;


Voici la trace de l'exécution de ce programme :
avant fork : 1456
je suis le père : 1456 du fils : 1457
je suis le fils : 1457


On peut aussi utiliser la valeur de retour dans un filtrage :
match Unix.fork () with
0 -> Printf.printf "je suis le fils : %d\n" (Unix.getpid ())
| pid -> Printf.printf "je suis le père : %d du fils : %d\n"
(Unix.getpid ()) pid ;;


La fécondité d'un processus peut être très grande. Elle est cependant limitée à un nombre fini de descendants par la configuration du système d'exploitation. L'exemple suivant crée deux générations de processus avec grand père, pères, fils, oncles et cousins.
let pid0 = Unix.getpid ();;
let print_generation1 pid ppid =
Printf.printf "Je suis %d, fils de %d\n" pid ppid;
flush stdout ;;

let print_generation2 pid ppid pppid =
Printf.printf "Je suis %d, fils de %d, petit fils de %d\n"
pid ppid pppid;
flush stdout ;;

match Unix.fork() with
0 -> let pid01 = Unix.getpid ()
in ( match Unix.fork() with
0 -> print_generation2 (Unix.getpid ()) pid01 pid0
| _ -> print_generation1 pid01 pid0)
| _ -> match Unix.fork () with
0 -> ( let pid02 = Unix.getpid ()
in match Unix.fork() with
0 -> print_generation2 (Unix.getpid ()) pid02 pid0
| _ -> print_generation1 pid02 pid0 )
| _ -> Printf.printf "Je suis %d, père et grand père\n" pid0 ;;




On obtient :
Je suis 1548, père et grand père
Je suis 1549, fils de 1548
Je suis 1550, fils de 1548
Je suis 1552, fils de 1549, petit fils de 1548
Je suis 1553, fils de 1550, petit fils de 1548


Ordre et moment d'exécution

En enchaînant sans précaution la création de processus, on peut obtenir des effets poétiques à la M. Jourdain :
match Unix.fork () with
0 -> Printf.printf "Marquise " ; flush stdout
| _ -> match Unix.fork () with
0 -> Printf.printf "vos beaux yeux me font " ; flush stdout
| _ -> Printf.printf"mourir d'amour\n" ; flush stdout ;;





Ce qui peut donner le résultat suivant :
mourir d'amour
Marquise vos beaux yeux me font


Pour obtenir un M. Jourdain prosateur, il faut que notre programme soit capable de s'assurer lui-même de l'ordre d'exécution des processus le composant. De façon plus générale, lorsqu'une application met en oeuvre plusieurs processus elle doit, quand besoin est, s'assurer de leur synchronisation. Selon le modèle de parallélisme utilisé, cette synchronisation est réalisée par la communication entre processus ou par des attentes sur condition. Cette problématique est plus amplement présentée dans les deux prochains chapitres. En attendant, on peut rendre M. Jourdain prosateur de deux façons :
Délai d'attente
Un processus peut suspendre son activité en appelant la fonction :

# Unix.sleep ;;
- : int -> unit = <fun>
L'argument fournit le nombre de secondes de suspension du processus appelant avant la reprise d'activité.

En utilisant cette fonction, on écrira :
match Unix.fork () with
0 -> Printf.printf "Marquise " ; flush stdout
| _ -> Unix.sleep 1 ;
match Unix.fork () with
0 -> Printf.printf"vos beaux yeux me font "; flush stdout
| _ -> Unix.sleep 1 ; Printf.printf"mourir d'amour\n" ; flush stdout ;;


Et on pourra obtenir :
Marquise vos beaux yeux me font mourir d'amour


Néanmoins, cette méthode n'est pas sûre. A priori, rien n'empêche le système d'allouer suffisamment de temps à l'un des processus pour qu'il puisse à la fois réaliser son temps de sommeil et son affichage. Nous préférerons donc dans notre cas la méthode ci-dessous qui séquentialise les processus.

Attente de terminaison du fils
Un processus père a la possibilité d'attendre la mort de son fils par appel à la fonction :

# Unix.wait ;;
- : unit -> int * Unix.process_status = <fun>


L'exécution du père est suspendue jusqu'à terminaison de l'un de ses fils. Si un wait est exécuté par un processus n'ayant plus de fils, alors l'exception Unix_error est déclenchée. Nous reviendrons ultérieurement sur la valeur de retour de wait. Ignorons la pour l'instant et faisons dire de la prose à M. Jourdain :
match Unix.fork () with
0 -> Printf.printf "Marquise " ; flush stdout
| _ -> ignore (Unix.wait ()) ;
match Unix.fork () with
0 -> Printf.printf "vos beaux yeux me font " ; flush stdout
| _ -> ignore (Unix.wait ()) ;
Printf.printf "mourir d'amour\n" ;
flush stdout




Et, de fait, il dit :
Marquise vos beaux yeux me font mourir d'amour


Warning


fork est propre au système Unix


Filiation, mort et funérailles d'un processus

La fonction wait n'a pas pour seule utilité l'attente de terminaison du fils utilisée ci-dessus. Elle est également chargée de consacrer la mort du processus fils.

Lorsqu'un processus est créé, le système ajoute une entrée dans la table qui lui sert à gérer l'ensemble des processus. Lorsqu'il meurt, un processus ne disparaît pas automatiquement de cette table. C'est au père, par appel à wait, d'assurer la suppression de ses fils de la table des processus. S'il ne le fait pas, le processus fils subsiste dans la table des processus. On parle alors de processus zombie.

Au démarrage du système, est lancé un premier processus appelé init. Après initialisation d'un certain nombre de paramètres du système, un rôle essentiel de ce << grand ancêtre >> est de prendre en charge les processus orphelins et d'exécuter le wait qui les rayera de la table des processus à leur terminaison.

Attente de fin d'un processus donné

Il existe une variante de la fonction wait, nommée waitpid et portée sous Windows :

# Unix.waitpid ;;
- : Unix.wait_flag list -> int -> int * Unix.process_status = <fun>
Le premier argument spécifie les modalités d'attente et le second, quel processus ou quel groupe de processus il faut traiter.

À la mort d'un processus, deux informations sont accessibles au père comme résultat de l'appel à wait ou waitpid : le numéro du processus terminé et son statut de terminaison. Ce dernier est représenté par une valeur de type Unix.process_status. Ce type a trois constructeurs dont chacun a un argument entier. La dernière valeur n'a de sens que pour la fonction waitpid qui peut, par son premier argument, se mettre à l'écoute de tels signaux. Nous détaillons les signaux et leur traitement page ??.

Gestion de l'attente par un ancêtre

Pour éviter de gérer soi-même la terminaison d'un processus fils, il est possible de faire traiter cette attente par un processus ancêtre. C'est l'astuce du << double fork >> qui permet à un processus de n'avoir pas à se soucier des funérailles de ses descendants en les confiant au processus init. En voici le principe : un processus P0 crée un processus P1 qui à son tour crée un troisième processus P2 puis termine. Ainsi, P2 se retrouve orphelin et est adopté par init qui se chargera d'attendre sa terminaison. Le processus initial P0 peut alors exécuter un wait sur P1 qui sera de courte durée. L'idée est de confier au petit fils le travail que l'on aurait confié au fils.

Le schéma de mise en oeuvre est le suivant :

# match Unix.fork() with (* P0 crée P1 *)
0 -> if Unix.fork() = 0 then exit 0 ; (* P1 crée P2 et termine *)
Printf.printf "P2 fait son travail\n" ;
exit 0
| pid -> ignore (Unix.waitpid [] pid) ; (* P0 attend la mort de P1 *)
Printf.printf "P0 peut faire autre chose sans attendre\n" ;;
P2 fait son travail
P0 peut faire autre chose sans attendre
- : unit = ()
Nous verrons une utilisation de ce principe pour traiter les requêtes envoyées à un serveur au chapitre 20.


Précédent Index Suivant