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âche : 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é simulé sur une seule machine.
Le système attribue aux processus un unique identificateur (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, sous UNIX connaître l'ensemble des processus actifs par la
commande 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, le shell csh et la commande ps
elle-même. On note que ps étant invoquée depuis
l'interprète de commande csh, le père de son processus est
celui du processus associé à l'exécution de csh.
Exécution d'un programme
Environnement
À un programme exécuté depuis un shell4 sont associés
trois valeur :
-
la ligne de commande ayant servi à son exécution qui est
contenue dans la valeur Sys.argv,
- les variables d'environnement du shell que l'on peut
récupérer grâce à la fonction Sys.getenv,
- 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 :
(* == 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 ainsi :
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 get_env. Par exemple, sous UNIX :
# Sys.getenv
"SHELL"
;;
Uncaught exception: Not_found
La variable d'environnement SHELL contient le nom de
l'interpréteur de commandes utilisé.
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 :
-
les deux processus sont indépendants et peuvent s'exécuter concurremment;
- le processus père est en attente sur la fin d'exécution
du processus fils;
- le processus lancé remplace le processus père qui disparaît.
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 a souvent 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 de la 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 X.
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 fonction 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, mais elle
n'engendre pas 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 : 796
après fork : 796
après fork : 797
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 : 1634
je suis le père : 1634 du fils : 1635
je suis le fils : 1635
On peut aussi utilisé 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 1682, père et grand père
Je suis 1683, fils de 1682
Je suis 1684, fils de 1682
Je suis 1686, fils de 1683, petit fils de 1682
Je suis 1687, fils de 1684, petit fils de 1682
Ordre et moment d'exécution
On peut ainsi
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.
Nous étudions, dans la
suite de ce chapitre, quelques solutions applicables aux processus
créés par fork.
On peut rendre M. Jourdain prosateur de deux façons :
-
laisser du temps au fils pour écrire son bout de phrase avant
d'écrire le sien propre.
- attendre la mort du fils qui aura écrit son bout de phrase
avant d'écrire son propre bout de phrase.
Nous obtenons ainsi deux programmes possibles.
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 suivante 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'appel à cette fonction suspend l'exécution du père jusqu'à
terminaison de l'un de ses fils. Si un wait est exécuté
par un processus n'ayant plus de fils, 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, 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 quelque 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é une valeur de type
Unix.process_status.
Ce type à trois constructeurs dont
chacun à un argument entier.
-
WEXITED n : le processus s'est terminé normalement
avec le code de retour n.
- WSIGNALED n : le processus a été tué par le
signal n.
- WSTOPPED n : le processus a été stoppé par le
signal n.
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
X.
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.
L'astuce du << double fork >> 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 a son tour crée un troisième
processus P2 puis termine. Ainsi, P2 de 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 5.